Compare commits

..

13 Commits

Author SHA1 Message Date
imfozilbek
8f995fc596 feat(ipuaro): add error handling matrix and ErrorHandler service
Implemented comprehensive error handling system according to v0.16.0 roadmap:

- ERROR_MATRIX with 9 error types (redis, parse, llm, file, command, conflict, validation, timeout, unknown)
- Enhanced IpuaroError with options, defaultOption, context properties
- New methods: getMeta(), hasOption(), toDisplayString()
- ErrorHandler service with handle(), wrap(), withRetry() methods
- Utility functions: getErrorOptions(), isRecoverableError(), toIpuaroError()
- 59 new tests (27 for IpuaroError, 32 for ErrorHandler)
- Coverage maintained at 97.59%

Breaking changes:
- IpuaroError constructor signature changed to (type, message, options?)
- ErrorChoice deprecated in favor of ErrorOption
2025-12-01 15:50:30 +05:00
imfozilbek
f947c6d157 feat(ipuaro): add CLI entry point (v0.15.0)
- Add onboarding module for pre-flight checks (Redis, Ollama, model, project)
- Implement start command with TUI rendering and dependency injection
- Implement init command for .ipuaro.json config file creation
- Implement index command for standalone project indexing
- Add CLI options: --auto-apply, --model, --help, --version
- Register all 18 tools via tools-setup helper
- Add 29 unit tests for CLI commands
- Update CHANGELOG and ROADMAP for v0.15.0
2025-12-01 15:03:45 +05:00
imfozilbek
33d52bc7ca feat(ipuaro): add slash commands for TUI (v0.14.0)
- Add useCommands hook with command parser
- Implement 8 commands: /help, /clear, /undo, /sessions, /status, /reindex, /eval, /auto-apply
- Integrate commands into App.tsx with visual feedback
- Add 38 unit tests for commands
- Update ROADMAP.md to reflect current status
2025-12-01 14:33:30 +05:00
imfozilbek
2c6eb6ce9b feat(ipuaro): add PathValidator security utility (v0.13.0)
Add centralized path validation to prevent path traversal attacks.

- PathValidator class with sync/async validation methods
- Protects against '..' and '~' traversal patterns
- Validates paths are within project root
- Refactored all 7 file tools to use PathValidator
- 51 new tests for PathValidator
2025-12-01 14:02:23 +05:00
imfozilbek
7d18e87423 feat(ipuaro): add TUI advanced components (v0.12.0)
Add DiffView, ConfirmDialog, ErrorDialog, and Progress components
for enhanced terminal UI interactions.
2025-12-01 13:34:17 +05:00
imfozilbek
fd1e6ad86e feat(ipuaro): add TUI components and hooks (v0.11.0) 2025-12-01 13:00:14 +05:00
imfozilbek
259ecc181a chore(ipuaro): bump version to 0.10.0 2025-12-01 12:28:27 +05:00
imfozilbek
0f2ed5b301 feat(ipuaro): add session management (v0.10.0)
- Add ISessionStorage interface and RedisSessionStorage implementation
- Add ContextManager for token budget and compression
- Add StartSession, HandleMessage, UndoChange use cases
- Update CHANGELOG and TODO documentation
- 88 new tests (1174 total), 97.73% coverage
2025-12-01 12:27:22 +05:00
imfozilbek
56643d903f chore(ipuaro): bump version to 0.9.0 2025-12-01 02:55:42 +05:00
imfozilbek
f5f904a847 feat(ipuaro): add git and run tools (v0.9.0)
Git tools:
- GitStatusTool: repository status (branch, staged, modified, untracked)
- GitDiffTool: uncommitted changes with diff output
- GitCommitTool: create commits with confirmation

Run tools:
- CommandSecurity: blacklist/whitelist shell command validation
- RunCommandTool: execute shell commands with security checks
- RunTestsTool: auto-detect and run vitest/jest/mocha/npm test

All 18 planned tools now implemented.
Tests: 1086 (+233), Coverage: 98.08%
2025-12-01 02:54:29 +05:00
imfozilbek
2ae1ac13f5 feat(ipuaro): add analysis tools (v0.8.0)
- GetDependenciesTool: get files a file imports
- GetDependentsTool: get files that import a file
- GetComplexityTool: get complexity metrics
- GetTodosTool: find TODO/FIXME/HACK comments

Tests: 853 (+120), Coverage: 97.91%
2025-12-01 02:23:36 +05:00
imfozilbek
caf7aac116 feat(ipuaro): add search tools (v0.7.0) 2025-12-01 02:05:27 +05:00
imfozilbek
4ad5a209c4 feat(ipuaro): add edit tools (v0.6.0)
Add file editing capabilities:
- EditLinesTool: replace lines with hash conflict detection
- CreateFileTool: create files with directory auto-creation
- DeleteFileTool: delete files from filesystem and storage

Total: 664 tests, 97.77% coverage
2025-12-01 01:44:45 +05:00
115 changed files with 20032 additions and 226 deletions

View File

@@ -5,6 +5,536 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.16.0] - 2025-12-01 - Error Handling
### Added
- **Error Handling Matrix (0.16.2)**
- `ERROR_MATRIX`: Defines behavior for each error type
- Per-type options: retry, skip, abort, confirm, regenerate
- Per-type defaults and recoverability settings
- Comprehensive error type support: redis, parse, llm, file, command, conflict, validation, timeout, unknown
- **IpuaroError Enhancements (0.16.1)**
- `ErrorOption` type: New type for available recovery options
- `ErrorMeta` interface: Error metadata with type, recoverable flag, options, and default
- `options` property: Available recovery options from matrix
- `defaultOption` property: Default option for the error type
- `context` property: Optional context data for debugging
- `getMeta()`: Returns full error metadata
- `hasOption()`: Checks if an option is available
- `toDisplayString()`: Formatted error message with suggestion
- New factory methods: `llmTimeout()`, `fileNotFound()`, `commandBlacklisted()`, `unknown()`
- **ErrorHandler Service**
- `handle()`: Async error handling with user callback
- `handleSync()`: Sync error handling with defaults
- `wrap()`: Wraps async functions with error handling
- `withRetry()`: Wraps functions with automatic retry logic
- `resetRetries()`: Resets retry counters
- `getRetryCount()`: Gets current retry count
- `isMaxRetriesExceeded()`: Checks if max retries reached
- Configurable options: maxRetries, autoSkipParseErrors, autoRetryLLMErrors
- **Utility Functions**
- `getErrorOptions()`: Get available options for error type
- `getDefaultErrorOption()`: Get default option for error type
- `isRecoverableError()`: Check if error type is recoverable
- `toIpuaroError()`: Convert any error to IpuaroError
- `createErrorHandler()`: Factory function for ErrorHandler
### Changed
- **IpuaroError Constructor**
- New signature: `(type, message, options?)` with options object
- Options include: recoverable, suggestion, context
- Matrix-based defaults for all properties
- **ErrorChoice → ErrorOption**
- `ErrorChoice` type deprecated in shared/types
- Use `ErrorOption` from shared/errors instead
- Updated HandleMessage and useSession to use ErrorOption
### Technical Details
- Total tests: 1420 (59 new tests)
- Coverage: 97.59% maintained
- New test files: ErrorHandler.test.ts
- Updated test file: IpuaroError.test.ts
---
## [0.15.0] - 2025-12-01 - CLI Entry Point
### Added
- **Onboarding Module (0.15.3)**
- `checkRedis()`: Validates Redis connection with helpful error messages
- `checkOllama()`: Validates Ollama availability with install instructions
- `checkModel()`: Checks if LLM model is available, offers to pull if missing
- `checkProjectSize()`: Warns if project has >10K files
- `runOnboarding()`: Runs all pre-flight checks before starting
- **Start Command (0.15.1)**
- Full TUI startup with dependency injection
- Integrates onboarding checks before launch
- Interactive model pull prompt if model missing
- Redis, storage, LLM, and tools initialization
- Clean shutdown with disconnect on exit
- **Init Command (0.15.1)**
- Creates `.ipuaro.json` configuration file
- Default template with Redis, LLM, and edit settings
- `--force` option to overwrite existing config
- Helpful output showing available options
- **Index Command (0.15.1)**
- Standalone project indexing without TUI
- File scanning with progress output
- AST parsing with error handling
- Metadata analysis and storage
- Symbol index and dependency graph building
- Duration and statistics reporting
- **CLI Options (0.15.2)**
- `--auto-apply`: Enable auto-apply mode for edits
- `--model <name>`: Override LLM model
- `--help`: Show help
- `--version`: Show version
- **Tools Setup Helper**
- `registerAllTools()`: Registers all 18 tools with the registry
- Clean separation from CLI logic
### Changed
- **CLI Architecture**
- Refactored from placeholder to full implementation
- Commands in separate modules under `src/cli/commands/`
- Dynamic version from package.json
- `start` command is now default (runs with `ipuaro` or `ipuaro start`)
### Technical Details
- Total tests: 1372 (29 new CLI tests)
- Coverage: ~98% maintained (CLI excluded from coverage thresholds)
- New test files: onboarding.test.ts, init.test.ts, tools-setup.test.ts
---
## [0.14.0] - 2025-12-01 - Commands
### Added
- **useCommands Hook**
- New hook for handling slash commands in TUI
- `parseCommand()`: Parses command input into name and arguments
- `isCommand()`: Checks if input is a slash command
- `executeCommand()`: Executes command and returns result
- `getCommands()`: Returns all available command definitions
- **8 Slash Commands**
- `/help` - Shows all commands and hotkeys
- `/clear` - Clears chat history (keeps session)
- `/undo` - Reverts last file change from undo stack
- `/sessions [list|load|delete] [id]` - Manage sessions
- `/status` - Shows system status (LLM, context, stats)
- `/reindex` - Forces full project reindexation
- `/eval` - LLM self-check for hallucinations
- `/auto-apply [on|off]` - Toggle auto-apply mode
- **Command Result Display**
- Visual feedback box for command results
- Green border for success, red for errors
- Auto-clear after 5 seconds
### Changed
- **App.tsx Integration**
- Added `useCommands` hook integration
- Command handling in `handleSubmit`
- New state for `autoApply` and `commandResult`
- Reindex placeholder action
### Technical Details
- Total tests: 1343 (38 new useCommands tests)
- Test coverage: ~98% maintained
- Modular command factory functions for maintainability
- Commands extracted to separate functions to stay under line limits
---
## [0.13.0] - 2025-12-01 - Security
### Added
- **PathValidator Utility (0.13.3)**
- Centralized path validation for all file operations
- Prevents path traversal attacks (`..`, `~`)
- Validates paths are within project root
- Sync (`validateSync`) and async (`validate`) validation methods
- Quick check method (`isWithin`) for simple validations
- Resolution methods (`resolve`, `relativize`, `resolveOrThrow`)
- Detailed validation results with status and reason
- Options for file existence, directory/file type checks
- **Security Module**
- New `infrastructure/security` module
- Exports: `PathValidator`, `createPathValidator`, `validatePath`
- Type exports: `PathValidationResult`, `PathValidationStatus`, `PathValidatorOptions`
### Changed
- **Refactored All File Tools to Use PathValidator**
- GetLinesTool: Uses PathValidator for path validation
- GetFunctionTool: Uses PathValidator for path validation
- GetClassTool: Uses PathValidator for path validation
- GetStructureTool: Uses PathValidator for path validation
- EditLinesTool: Uses PathValidator for path validation
- CreateFileTool: Uses PathValidator for path validation
- DeleteFileTool: Uses PathValidator for path validation
- **Improved Error Messages**
- More specific error messages from PathValidator
- "Path contains traversal patterns" for `..` attempts
- "Path is outside project root" for absolute paths outside project
- "Path is empty" for empty/whitespace paths
### Technical Details
- Total tests: 1305 (51 new PathValidator tests)
- Test coverage: ~98% maintained
- No breaking changes to existing tool APIs
- Security validation is now consistent across all 7 file tools
---
## [0.12.0] - 2025-12-01 - TUI Advanced
### Added
- **DiffView Component (0.12.1)**
- Inline diff display with green (added) and red (removed) highlighting
- Header with file path and line range: `┌─── path (lines X-Y) ───┐`
- Line numbers with proper padding
- Stats footer showing additions and deletions count
- **ConfirmDialog Component (0.12.2)**
- Confirmation dialog with [Y] Apply / [N] Cancel / [E] Edit options
- Optional diff preview integration
- Keyboard input handling (Y/N/E keys, Escape)
- Visual selection feedback
- **ErrorDialog Component (0.12.3)**
- Error dialog with [R] Retry / [S] Skip / [A] Abort options
- Recoverable vs non-recoverable error handling
- Disabled buttons for non-recoverable errors
- Keyboard input with Escape support
- **Progress Component (0.12.4)**
- Progress bar display: `[=====> ] 45% (120/267 files)`
- Color-coded progress (cyan < 50%, yellow < 100%, green = 100%)
- Configurable width
- Label support for context
### Changed
- Total tests: 1254 (unchanged - TUI components excluded from coverage)
- TUI layer now has 8 components + 2 hooks
- All v0.12.0 roadmap items complete
---
## [0.11.0] - 2025-12-01 - TUI Basic
### Added
- **TUI Types (0.11.0)**
- `TuiStatus`: Status type for TUI display (ready, thinking, tool_call, awaiting_confirmation, error)
- `BranchInfo`: Git branch information (name, isDetached)
- `AppProps`: Main app component props
- `StatusBarData`: Status bar display data
- **App Shell (0.11.1)**
- Main TUI App component with React/Ink
- Session initialization and state management
- Loading and error screens
- Hotkey integration (Ctrl+C, Ctrl+D, Ctrl+Z)
- Session time tracking
- **StatusBar Component (0.11.2)**
- Displays: `[ipuaro] [ctx: 12%] [project] [branch] [time] status`
- Context usage with color warning at >80%
- Git branch with detached HEAD support
- Status indicator with colors (ready=green, thinking=yellow, error=red)
- **Chat Component (0.11.3)**
- Message history display with role-based styling
- User messages (green), Assistant messages (cyan), System messages (gray)
- Tool call display with parameters
- Response stats: time, tokens, tool calls
- Thinking indicator during LLM processing
- **Input Component (0.11.4)**
- Prompt with `> ` prefix
- History navigation with ↑/↓ arrow keys
- Saved input restoration when navigating past history
- Disabled state during processing
- Custom placeholder support
- **useSession Hook (0.11.5)**
- Session state management with React hooks
- Message handling integration
- Status tracking (ready, thinking, tool_call, error)
- Undo support
- Clear history functionality
- Abort/interrupt support
- **useHotkeys Hook (0.11.6)**
- Ctrl+C: Interrupt (1st), Exit (2nd within 1s)
- Ctrl+D: Exit with session save
- Ctrl+Z: Undo last change
### Changed
- Total tests: 1254 (was 1174)
- Coverage: 97.75% lines, 92.22% branches
- TUI layer now has 4 components + 2 hooks
- TUI excluded from coverage thresholds (requires React testing setup)
---
## [0.10.0] - 2025-12-01 - Session Management
### Added
- **ISessionStorage (0.10.1)**
- Session storage service interface
- Methods: saveSession, loadSession, deleteSession, listSessions
- Undo stack management: pushUndoEntry, popUndoEntry, getUndoStack
- Session lifecycle: getLatestSession, sessionExists, touchSession
- **RedisSessionStorage (0.10.2)**
- Redis implementation of ISessionStorage
- Session data in Redis hashes (project, history, context, stats)
- Undo stack in Redis lists (max 10 entries)
- Sessions list for project-wide queries
- 22 unit tests
- **ContextManager (0.10.3)**
- Manages context window token budget
- File context tracking with addToContext/removeFromContext
- Usage monitoring: getUsage, getAvailableTokens, getRemainingTokens
- Auto-compression at 80% threshold via LLM summarization
- Context state export for session persistence
- 23 unit tests
- **StartSession (0.10.4)**
- Use case for session initialization
- Creates new session or loads latest for project
- Optional sessionId for specific session loading
- forceNew option to always create fresh session
- 10 unit tests
- **HandleMessage (0.10.5)**
- Main orchestrator use case for message handling
- LLM interaction with tool calling support
- Edit confirmation flow with diff preview
- Error handling with retry/skip/abort choices
- Status tracking: ready, thinking, tool_call, awaiting_confirmation, error
- Event callbacks: onMessage, onToolCall, onToolResult, onConfirmation, onError
- 21 unit tests
- **UndoChange (0.10.6)**
- Use case for reverting file changes
- Validates file hasn't changed since edit
- Restores original content from undo entry
- Updates storage after successful undo
- 12 unit tests
### Changed
- Total tests: 1174 (was 1086)
- Coverage: 97.73% lines, 92.21% branches
- Application layer now has 4 use cases implemented
- All planned session management features complete
---
## [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 ## [0.5.0] - 2025-12-01 - Read Tools
### Added ### Added

View File

@@ -148,9 +148,10 @@ packages/ipuaro/
--- ---
## Version 0.1.0 - Foundation ⚙️ ## Version 0.1.0 - Foundation ⚙️
**Priority:** CRITICAL **Priority:** CRITICAL
**Status:** Complete (v0.1.0 released)
### 0.1.1 - Project Setup ### 0.1.1 - Project Setup
@@ -310,9 +311,10 @@ interface Config {
--- ---
## Version 0.2.0 - Redis Storage 🗄️ ## Version 0.2.0 - Redis Storage 🗄️
**Priority:** CRITICAL **Priority:** CRITICAL
**Status:** Complete (v0.2.0 released)
### 0.2.1 - Redis Client ### 0.2.1 - Redis Client
@@ -367,9 +369,10 @@ class RedisStorage implements IStorage {
--- ---
## Version 0.3.0 - Indexer 📂 ## Version 0.3.0 - Indexer 📂
**Priority:** CRITICAL **Priority:** CRITICAL
**Status:** Complete (v0.3.0, v0.3.1 released)
### 0.3.1 - File Scanner ### 0.3.1 - File Scanner
@@ -456,9 +459,10 @@ class Watchdog {
--- ---
## Version 0.4.0 - LLM Integration 🤖 ## Version 0.4.0 - LLM Integration 🤖
**Priority:** CRITICAL **Priority:** CRITICAL
**Status:** Complete (v0.4.0 released)
### 0.4.1 - Ollama Client ### 0.4.1 - Ollama Client
@@ -531,9 +535,10 @@ function parseToolCalls(response: string): ToolCall[]
--- ---
## Version 0.5.0 - Read Tools 📖 ## Version 0.5.0 - Read Tools 📖
**Priority:** HIGH **Priority:** HIGH
**Status:** Complete (v0.5.0 released)
4 tools for reading code without modification. 4 tools for reading code without modification.
@@ -609,9 +614,10 @@ class GetStructureTool implements ITool {
--- ---
## Version 0.6.0 - Edit Tools ✏️ ## Version 0.6.0 - Edit Tools ✏️
**Priority:** HIGH **Priority:** HIGH
**Status:** Complete (v0.6.0 released)
3 tools for file modifications. All require confirmation (unless autoApply). 3 tools for file modifications. All require confirmation (unless autoApply).
@@ -662,9 +668,10 @@ class DeleteFileTool implements ITool {
--- ---
## Version 0.7.0 - Search Tools 🔍 ## Version 0.7.0 - Search Tools 🔍
**Priority:** HIGH **Priority:** HIGH
**Status:** Complete (v0.7.0 released)
### 0.7.1 - find_references ### 0.7.1 - find_references
@@ -699,9 +706,10 @@ class FindDefinitionTool implements ITool {
--- ---
## Version 0.8.0 - Analysis Tools 📊 ## Version 0.8.0 - Analysis Tools 📊
**Priority:** MEDIUM **Priority:** MEDIUM
**Status:** Complete (v0.8.0 released)
### 0.8.1 - get_dependencies ### 0.8.1 - get_dependencies
@@ -742,9 +750,10 @@ class FindDefinitionTool implements ITool {
--- ---
## Version 0.9.0 - Git & Run Tools 🚀 ## Version 0.9.0 - Git & Run Tools 🚀
**Priority:** MEDIUM **Priority:** MEDIUM
**Status:** Complete (v0.9.0 released) — includes CommandSecurity (Blacklist/Whitelist)
### 0.9.1 - git_status ### 0.9.1 - git_status
@@ -798,9 +807,10 @@ class FindDefinitionTool implements ITool {
--- ---
## Version 0.10.0 - Session Management 💾 ## Version 0.10.0 - Session Management 💾
**Priority:** HIGH **Priority:** HIGH
**Status:** Complete (v0.10.0 released) — includes HandleMessage orchestrator (originally planned for 0.14.0)
### 0.10.1 - Session Entity ### 0.10.1 - Session Entity
@@ -873,9 +883,10 @@ class ContextManager {
--- ---
## Version 0.11.0 - TUI Basic 🖥️ ## Version 0.11.0 - TUI Basic 🖥️
**Priority:** CRITICAL **Priority:** CRITICAL
**Status:** Complete (v0.11.0 released) — includes useHotkeys (originally planned for 0.16.0)
### 0.11.1 - App Shell ### 0.11.1 - App Shell
@@ -945,9 +956,10 @@ interface Props {
--- ---
## Version 0.12.0 - TUI Advanced 🎨 ## Version 0.12.0 - TUI Advanced 🎨
**Priority:** HIGH **Priority:** HIGH
**Status:** Complete (v0.12.0 released)
### 0.12.1 - DiffView ### 0.12.1 - DiffView
@@ -1009,9 +1021,10 @@ interface Props {
--- ---
## Version 0.13.0 - Security 🔒 ## Version 0.13.0 - Security 🔒
**Priority:** HIGH **Priority:** HIGH
**Status:** Complete (v0.13.0 released) — Blacklist/Whitelist done in v0.9.0, PathValidator in v0.13.0
### 0.13.1 - Blacklist ### 0.13.1 - Blacklist
@@ -1055,11 +1068,14 @@ function validatePath(path: string, projectRoot: string): boolean
--- ---
## Version 0.14.0 - Orchestrator 🎭 ## [DONE] Original 0.14.0 - Orchestrator 🎭
**Priority:** CRITICAL > **Note:** This was implemented in v0.10.0 as part of Session Management
### 0.14.1 - HandleMessage Use Case <details>
<summary>Originally planned (click to expand)</summary>
### HandleMessage Use Case (Done in v0.10.5)
```typescript ```typescript
// src/application/use-cases/HandleMessage.ts // src/application/use-cases/HandleMessage.ts
@@ -1091,7 +1107,7 @@ class HandleMessage {
} }
``` ```
### 0.14.2 - Edit Flow ### Edit Flow (Done in v0.10.5)
```typescript ```typescript
// Edit handling inside HandleMessage: // Edit handling inside HandleMessage:
@@ -1104,17 +1120,49 @@ class HandleMessage {
// - Update storage (lines, AST, meta) // - Update storage (lines, AST, meta)
``` ```
**Tests:** </details>
- [ ] Unit tests for HandleMessage
- [ ] E2E tests for full message flow
--- ---
## Version 0.15.0 - Commands 📝 ## [DONE] Original 0.16.0 - Hotkeys & Polish ⌨️ ✅
**Priority:** MEDIUM > **Note:** useHotkeys done in v0.11.0, ContextManager auto-compression in v0.10.3
7 slash commands for TUI. <details>
<summary>Originally planned (click to expand)</summary>
### Hotkeys (Done in v0.11.0)
```typescript
// src/tui/hooks/useHotkeys.ts
Ctrl+C // Interrupt generation (1st), exit (2nd)
Ctrl+D // Exit with session save
Ctrl+Z // Undo (= /undo)
/ // Input history
Tab // Path autocomplete
```
### Auto-compression (Done in v0.10.3)
```typescript
// Triggered at >80% context:
// 1. LLM summarizes old messages
// 2. Remove tool results older than 5 messages
// 3. Update status bar (ctx% changes)
// No modal notification - silent
```
</details>
---
## Version 0.14.0 - Commands 📝 ✅
**Priority:** HIGH
**Status:** Complete (v0.14.0 released)
8 slash commands for TUI.
```typescript ```typescript
// src/tui/hooks/useCommands.ts // src/tui/hooks/useCommands.ts
@@ -1130,47 +1178,16 @@ class HandleMessage {
``` ```
**Tests:** **Tests:**
- [ ] Unit tests for command handlers - [x] Unit tests for command handlers (38 tests)
--- ---
## Version 0.16.0 - Hotkeys & Polish ⌨️ ## Version 0.15.0 - CLI Entry Point 🚪 ✅
**Priority:** MEDIUM
### 0.16.1 - Hotkeys
```typescript
// src/tui/hooks/useHotkeys.ts
Ctrl+C // Interrupt generation (1st), exit (2nd)
Ctrl+D // Exit with session save
Ctrl+Z // Undo (= /undo)
/ // Input history
Tab // Path autocomplete
```
### 0.16.2 - Auto-compression
```typescript
// Triggered at >80% context:
// 1. LLM summarizes old messages
// 2. Remove tool results older than 5 messages
// 3. Update status bar (ctx% changes)
// No modal notification - silent
```
**Tests:**
- [ ] Integration tests for hotkeys
- [ ] Unit tests for compression
---
## Version 0.17.0 - CLI Entry Point 🚪
**Priority:** HIGH **Priority:** HIGH
**Status:** Complete (v0.15.0 released)
### 0.17.1 - CLI Commands ### 0.15.1 - CLI Commands
```typescript ```typescript
// src/cli/index.ts // src/cli/index.ts
@@ -1180,7 +1197,7 @@ ipuaro init // Create .ipuaro.json config
ipuaro index // Index only (no TUI) ipuaro index // Index only (no TUI)
``` ```
### 0.17.2 - CLI Options ### 0.15.2 - CLI Options
```bash ```bash
--auto-apply # Enable auto-apply mode --auto-apply # Enable auto-apply mode
@@ -1189,7 +1206,7 @@ ipuaro index // Index only (no TUI)
--version # Show version --version # Show version
``` ```
### 0.17.3 - Onboarding ### 0.15.3 - Onboarding
```typescript ```typescript
// src/cli/commands/start.ts // src/cli/commands/start.ts
@@ -1202,40 +1219,75 @@ ipuaro index // Index only (no TUI)
``` ```
**Tests:** **Tests:**
- [ ] E2E tests for CLI - [x] Unit tests for CLI commands (29 tests)
--- ---
## Version 0.18.0 - Error Handling ⚠️ ## Version 0.16.0 - Error Handling ⚠️
**Priority:** HIGH **Priority:** HIGH
**Status:** Complete (v0.16.0 released)
### 0.18.1 - Error Types ### 0.16.1 - Error Types
```typescript ```typescript
// src/shared/errors/IpuaroError.ts // src/shared/errors/IpuaroError.ts
type ErrorType = "redis" | "parse" | "llm" | "file" | "command" | "conflict" type ErrorType = "redis" | "parse" | "llm" | "file" | "command" | "conflict" | "validation" | "timeout" | "unknown"
type ErrorOption = "retry" | "skip" | "abort" | "confirm" | "regenerate"
interface ErrorMeta {
type: ErrorType
recoverable: boolean
options: ErrorOption[]
defaultOption: ErrorOption
}
class IpuaroError extends Error { class IpuaroError extends Error {
type: ErrorType type: ErrorType
recoverable: boolean recoverable: boolean
suggestion?: string suggestion?: string
options: ErrorOption[]
defaultOption: ErrorOption
context?: Record<string, unknown>
getMeta(): ErrorMeta
hasOption(option: ErrorOption): boolean
toDisplayString(): string
} }
``` ```
### 0.18.2 - Error Handling Matrix ### 0.16.2 - Error Handling Matrix
| Error | Recoverable | Options | | Error | Recoverable | Options | Default |
|-------|-------------|---------| |-------|-------------|---------|---------|
| Redis unavailable | No | Retry / Abort | | Redis unavailable | No | Retry / Abort | Abort |
| AST parse failed | Yes | Skip file / Abort | | AST parse failed | Yes | Skip / Abort | Skip |
| LLM timeout | Yes | Retry / Skip / Abort | | LLM timeout | Yes | Retry / Skip / Abort | Retry |
| File not found | Yes | Skip / Abort | | File not found | Yes | Skip / Abort | Skip |
| Command not in whitelist | Yes | Confirm / Skip / Abort | | Command not in whitelist | Yes | Confirm / Skip / Abort | Confirm |
| Edit conflict | Yes | Apply / Skip / Regenerate | | Edit conflict | Yes | Skip / Regenerate / Abort | Skip |
| Validation error | Yes | Skip / Abort | Skip |
| Timeout | Yes | Retry / Skip / Abort | Retry |
| Unknown | No | Abort | Abort |
### 0.16.3 - ErrorHandler Service ✅
```typescript
// src/shared/errors/ErrorHandler.ts
class ErrorHandler {
handle(error: IpuaroError, contextKey?: string): Promise<ErrorHandlingResult>
handleSync(error: IpuaroError, contextKey?: string): ErrorHandlingResult
wrap<T>(fn: () => Promise<T>, errorType: ErrorType, contextKey?: string): Promise<Result>
withRetry<T>(fn: () => Promise<T>, errorType: ErrorType, contextKey: string): Promise<T>
resetRetries(contextKey?: string): void
getRetryCount(contextKey: string): number
isMaxRetriesExceeded(contextKey: string): boolean
}
```
**Tests:** **Tests:**
- [ ] Unit tests for error handling - [x] Unit tests for IpuaroError (27 tests)
- [x] Unit tests for ErrorHandler (32 tests)
--- ---
@@ -1244,16 +1296,16 @@ class IpuaroError extends Error {
**Target:** Stable release **Target:** Stable release
**Checklist:** **Checklist:**
- [ ] All 18 tools implemented and tested - [x] All 18 tools implemented and tested ✅ (v0.9.0)
- [ ] TUI fully functional - [x] TUI fully functional ✅ (v0.11.0, v0.12.0)
- [ ] Session persistence working - [x] Session persistence working ✅ (v0.10.0)
- [ ] Error handling complete - [x] Error handling complete ✅ (v0.16.0)
- [ ] Performance optimized - [ ] Performance optimized
- [ ] Documentation complete - [ ] Documentation complete
- [ ] 80%+ test coverage - [x] 80%+ test coverage ✅ (~98%)
- [ ] 0 ESLint errors - [x] 0 ESLint errors
- [ ] Examples working - [ ] Examples working
- [ ] CHANGELOG.md up to date - [x] CHANGELOG.md up to date
--- ---
@@ -1327,5 +1379,6 @@ sessions:list # List<session_id>
--- ---
**Last Updated:** 2025-11-29 **Last Updated:** 2025-12-01
**Target Version:** 1.0.0 **Target Version:** 1.0.0
**Current Version:** 0.16.0

View File

@@ -1,40 +1,95 @@
# ipuaro TODO # ipuaro TODO
## Completed
### Version 0.1.0 - Foundation
- [x] Project setup (package.json, tsconfig, vitest)
- [x] Domain entities (Session, Project)
- [x] Domain value objects (FileData, FileAST, FileMeta, ChatMessage, etc.)
- [x] Domain service interfaces (IStorage, ILLMClient, ITool, IIndexer)
- [x] Shared config loader with Zod validation
- [x] IpuaroError class
### Version 0.2.0 - Redis Storage
- [x] RedisClient with AOF config
- [x] Redis schema implementation
- [x] RedisStorage class
### Version 0.3.0 - Indexer
- [x] FileScanner with gitignore support
- [x] ASTParser with tree-sitter
- [x] MetaAnalyzer for complexity
- [x] IndexBuilder for symbols
- [x] Watchdog for file changes
### Version 0.4.0 - LLM Integration
- [x] OllamaClient implementation
- [x] System prompt design
- [x] Tool definitions (18 tools)
- [x] Response parser (XML format)
### Version 0.5.0 - Read Tools
- [x] ToolRegistry implementation
- [x] get_lines tool
- [x] get_function tool
- [x] get_class tool
- [x] get_structure tool
### Version 0.6.0 - Edit Tools
- [x] edit_lines tool
- [x] create_file tool
- [x] delete_file tool
### Version 0.7.0 - Search Tools
- [x] find_references tool
- [x] find_definition tool
### Version 0.8.0 - Analysis Tools
- [x] get_dependencies tool
- [x] get_dependents tool
- [x] get_complexity tool
- [x] get_todos tool
### Version 0.9.0 - Git & Run Tools
- [x] git_status tool
- [x] git_diff tool
- [x] git_commit tool
- [x] CommandSecurity (blacklist/whitelist)
- [x] run_command tool
- [x] run_tests tool
### Version 0.10.0 - Session Management
- [x] ISessionStorage interface
- [x] RedisSessionStorage implementation
- [x] ContextManager use case
- [x] StartSession use case
- [x] HandleMessage use case
- [x] UndoChange use case
## In Progress ## In Progress
### Version 0.2.0 - Redis Storage ### Version 0.11.0 - TUI Basic
- [ ] RedisClient with AOF config - [ ] App shell (Ink/React)
- [ ] Redis schema implementation - [ ] StatusBar component
- [ ] RedisStorage class - [ ] Chat component
- [ ] Input component
## Planned ## Planned
### Version 0.3.0 - Indexer ### Version 0.12.0 - TUI Advanced
- [ ] FileScanner with gitignore support - [ ] DiffView component
- [ ] ASTParser with tree-sitter - [ ] ConfirmDialog component
- [ ] MetaAnalyzer for complexity - [ ] ErrorDialog component
- [ ] IndexBuilder for symbols - [ ] Progress component
- [ ] Watchdog for file changes
### Version 0.4.0 - LLM Integration ### Version 0.13.0+ - Commands & Polish
- [ ] OllamaClient implementation - [ ] Slash commands (/help, /clear, /undo, /sessions, /status)
- [ ] System prompt design - [ ] Hotkeys (Ctrl+C, Ctrl+D, Ctrl+Z)
- [ ] Tool definitions (XML format) - [ ] Auto-compression at 80% context
- [ ] Response parser
### Version 0.5.0+ - Tools ### Version 0.14.0 - CLI Entry Point
- [ ] Read tools (get_lines, get_function, get_class, get_structure) - [ ] Full CLI commands (start, init, index)
- [ ] Edit tools (edit_lines, create_file, delete_file) - [ ] Onboarding flow (Redis check, Ollama check, model pull)
- [ ] Search tools (find_references, find_definition)
- [ ] Analysis tools (get_dependencies, get_dependents, get_complexity, get_todos)
- [ ] Git tools (git_status, git_diff, git_commit)
- [ ] Run tools (run_command, run_tests)
### Version 0.10.0+ - Session & TUI
- [ ] Session management
- [ ] Context compression
- [ ] TUI components (StatusBar, Chat, Input, DiffView)
- [ ] Slash commands (/help, /clear, /undo, etc.)
## Technical Debt ## Technical Debt
@@ -51,4 +106,4 @@ _None at this time._
--- ---
**Last Updated:** 2025-01-29 **Last Updated:** 2025-12-01

View File

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

View File

@@ -0,0 +1,229 @@
import type { ContextState, Session } from "../../domain/entities/Session.js"
import type { ILLMClient } from "../../domain/services/ILLMClient.js"
import { type ChatMessage, createSystemMessage } from "../../domain/value-objects/ChatMessage.js"
import { CONTEXT_COMPRESSION_THRESHOLD, CONTEXT_WINDOW_SIZE } from "../../domain/constants/index.js"
/**
* File in context with token count.
*/
export interface FileContext {
path: string
tokens: number
addedAt: number
}
/**
* Compression result.
*/
export interface CompressionResult {
compressed: boolean
removedMessages: number
tokensSaved: number
summary?: string
}
const COMPRESSION_PROMPT = `Summarize the following conversation history in a concise way,
preserving key information about:
- What files were discussed or modified
- What changes were made
- Important decisions or context
Keep the summary under 500 tokens.`
const MESSAGES_TO_KEEP = 5
const MIN_MESSAGES_FOR_COMPRESSION = 10
/**
* Manages context window token budget and compression.
*/
export class ContextManager {
private readonly filesInContext = new Map<string, FileContext>()
private currentTokens = 0
private readonly contextWindowSize: number
constructor(contextWindowSize: number = CONTEXT_WINDOW_SIZE) {
this.contextWindowSize = contextWindowSize
}
/**
* Add a file to the context.
*/
addToContext(file: string, tokens: number): void {
const existing = this.filesInContext.get(file)
if (existing) {
this.currentTokens -= existing.tokens
}
this.filesInContext.set(file, {
path: file,
tokens,
addedAt: Date.now(),
})
this.currentTokens += tokens
}
/**
* Remove a file from the context.
*/
removeFromContext(file: string): void {
const existing = this.filesInContext.get(file)
if (existing) {
this.currentTokens -= existing.tokens
this.filesInContext.delete(file)
}
}
/**
* Get current token usage ratio (0-1).
*/
getUsage(): number {
return this.currentTokens / this.contextWindowSize
}
/**
* Get current token count.
*/
getTokenCount(): number {
return this.currentTokens
}
/**
* Get available tokens.
*/
getAvailableTokens(): number {
return this.contextWindowSize - this.currentTokens
}
/**
* Check if compression is needed.
*/
needsCompression(): boolean {
return this.getUsage() > CONTEXT_COMPRESSION_THRESHOLD
}
/**
* Update token count (e.g., after receiving a message).
*/
addTokens(tokens: number): void {
this.currentTokens += tokens
}
/**
* Get files in context.
*/
getFilesInContext(): string[] {
return Array.from(this.filesInContext.keys())
}
/**
* Sync context state from session.
*/
syncFromSession(session: Session): void {
this.filesInContext.clear()
this.currentTokens = 0
for (const file of session.context.filesInContext) {
this.filesInContext.set(file, {
path: file,
tokens: 0,
addedAt: Date.now(),
})
}
this.currentTokens = Math.floor(session.context.tokenUsage * this.contextWindowSize)
}
/**
* Update session context state.
*/
updateSession(session: Session): void {
session.context.filesInContext = this.getFilesInContext()
session.context.tokenUsage = this.getUsage()
session.context.needsCompression = this.needsCompression()
}
/**
* Compress context using LLM to summarize old messages.
*/
async compress(session: Session, llm: ILLMClient): Promise<CompressionResult> {
const history = session.history
if (history.length < MIN_MESSAGES_FOR_COMPRESSION) {
return {
compressed: false,
removedMessages: 0,
tokensSaved: 0,
}
}
const messagesToCompress = history.slice(0, -MESSAGES_TO_KEEP)
const messagesToKeep = history.slice(-MESSAGES_TO_KEEP)
const tokensBeforeCompression = await this.countHistoryTokens(messagesToCompress, llm)
const summary = await this.summarizeMessages(messagesToCompress, llm)
const summaryTokens = await llm.countTokens(summary)
const summaryMessage = createSystemMessage(`[Previous conversation summary]\n${summary}`)
session.history = [summaryMessage, ...messagesToKeep]
const tokensSaved = tokensBeforeCompression - summaryTokens
this.currentTokens -= tokensSaved
this.updateSession(session)
return {
compressed: true,
removedMessages: messagesToCompress.length,
tokensSaved,
summary,
}
}
/**
* Create a new context state.
*/
static createInitialState(): ContextState {
return {
filesInContext: [],
tokenUsage: 0,
needsCompression: false,
}
}
private async summarizeMessages(messages: ChatMessage[], llm: ILLMClient): Promise<string> {
const conversation = this.formatMessagesForSummary(messages)
const response = await llm.chat([
createSystemMessage(COMPRESSION_PROMPT),
createSystemMessage(conversation),
])
return response.content
}
private formatMessagesForSummary(messages: ChatMessage[]): string {
return messages
.filter((m) => m.role !== "tool")
.map((m) => {
const role = m.role === "user" ? "User" : "Assistant"
const content = this.truncateContent(m.content, 500)
return `${role}: ${content}`
})
.join("\n\n")
}
private truncateContent(content: string, maxLength: number): string {
if (content.length <= maxLength) {
return content
}
return `${content.slice(0, maxLength)}...`
}
private async countHistoryTokens(messages: ChatMessage[], llm: ILLMClient): Promise<number> {
let total = 0
for (const message of messages) {
total += await llm.countTokens(message.content)
}
return total
}
}

View File

@@ -0,0 +1,382 @@
import { randomUUID } from "node:crypto"
import type { Session } from "../../domain/entities/Session.js"
import type { ILLMClient } from "../../domain/services/ILLMClient.js"
import type { ISessionStorage } from "../../domain/services/ISessionStorage.js"
import type { IStorage } from "../../domain/services/IStorage.js"
import type { DiffInfo, ToolContext } from "../../domain/services/ITool.js"
import {
type ChatMessage,
createAssistantMessage,
createSystemMessage,
createToolMessage,
createUserMessage,
} from "../../domain/value-objects/ChatMessage.js"
import type { ToolCall } from "../../domain/value-objects/ToolCall.js"
import { createErrorResult, type ToolResult } from "../../domain/value-objects/ToolResult.js"
import { createUndoEntry, type UndoEntry } from "../../domain/value-objects/UndoEntry.js"
import { type ErrorOption, IpuaroError } from "../../shared/errors/IpuaroError.js"
import {
buildInitialContext,
type ProjectStructure,
SYSTEM_PROMPT,
} from "../../infrastructure/llm/prompts.js"
import { parseToolCalls } from "../../infrastructure/llm/ResponseParser.js"
import type { IToolRegistry } from "../interfaces/IToolRegistry.js"
import { ContextManager } from "./ContextManager.js"
/**
* Status during message handling.
*/
export type HandleMessageStatus =
| "ready"
| "thinking"
| "tool_call"
| "awaiting_confirmation"
| "error"
/**
* Edit request for confirmation.
*/
export interface EditRequest {
toolCall: ToolCall
filePath: string
description: string
diff?: DiffInfo
}
/**
* User's choice for edit confirmation.
*/
export type EditChoice = "apply" | "skip" | "edit" | "abort"
/**
* Event callbacks for HandleMessage.
*/
export interface HandleMessageEvents {
onMessage?: (message: ChatMessage) => void
onToolCall?: (call: ToolCall) => void
onToolResult?: (result: ToolResult) => void
onConfirmation?: (message: string, diff?: DiffInfo) => Promise<boolean>
onError?: (error: IpuaroError) => Promise<ErrorOption>
onStatusChange?: (status: HandleMessageStatus) => void
onUndoEntry?: (entry: UndoEntry) => void
}
/**
* Options for HandleMessage.
*/
export interface HandleMessageOptions {
autoApply?: boolean
maxToolCalls?: number
}
const DEFAULT_MAX_TOOL_CALLS = 20
/**
* Use case for handling a user message.
* Main orchestrator for the LLM interaction loop.
*/
export class HandleMessage {
private readonly storage: IStorage
private readonly sessionStorage: ISessionStorage
private readonly llm: ILLMClient
private readonly tools: IToolRegistry
private readonly contextManager: ContextManager
private readonly projectRoot: string
private projectStructure?: ProjectStructure
private events: HandleMessageEvents = {}
private options: HandleMessageOptions = {}
private aborted = false
constructor(
storage: IStorage,
sessionStorage: ISessionStorage,
llm: ILLMClient,
tools: IToolRegistry,
projectRoot: string,
) {
this.storage = storage
this.sessionStorage = sessionStorage
this.llm = llm
this.tools = tools
this.projectRoot = projectRoot
this.contextManager = new ContextManager(llm.getContextWindowSize())
}
/**
* Set event callbacks.
*/
setEvents(events: HandleMessageEvents): void {
this.events = events
}
/**
* Set options.
*/
setOptions(options: HandleMessageOptions): void {
this.options = options
}
/**
* Set project structure for context building.
*/
setProjectStructure(structure: ProjectStructure): void {
this.projectStructure = structure
}
/**
* Abort current processing.
*/
abort(): void {
this.aborted = true
this.llm.abort()
}
/**
* Execute the message handling flow.
*/
async execute(session: Session, message: string): Promise<void> {
this.aborted = false
this.contextManager.syncFromSession(session)
if (message.trim()) {
const userMessage = createUserMessage(message)
session.addMessage(userMessage)
session.addInputToHistory(message)
this.emitMessage(userMessage)
}
await this.sessionStorage.saveSession(session)
this.emitStatus("thinking")
let toolCallCount = 0
const maxToolCalls = this.options.maxToolCalls ?? DEFAULT_MAX_TOOL_CALLS
while (!this.aborted) {
const messages = await this.buildMessages(session)
const startTime = Date.now()
let response
try {
response = await this.llm.chat(messages)
} catch (error) {
await this.handleLLMError(error, session)
return
}
if (this.aborted) {
return
}
const parsed = parseToolCalls(response.content)
const timeMs = Date.now() - startTime
if (parsed.toolCalls.length === 0) {
const assistantMessage = createAssistantMessage(parsed.content, undefined, {
tokens: response.tokens,
timeMs,
toolCalls: 0,
})
session.addMessage(assistantMessage)
this.emitMessage(assistantMessage)
this.contextManager.addTokens(response.tokens)
this.contextManager.updateSession(session)
await this.sessionStorage.saveSession(session)
this.emitStatus("ready")
return
}
const assistantMessage = createAssistantMessage(parsed.content, parsed.toolCalls, {
tokens: response.tokens,
timeMs,
toolCalls: parsed.toolCalls.length,
})
session.addMessage(assistantMessage)
this.emitMessage(assistantMessage)
toolCallCount += parsed.toolCalls.length
if (toolCallCount > maxToolCalls) {
const errorMsg = `Maximum tool calls (${String(maxToolCalls)}) exceeded`
const errorMessage = createSystemMessage(errorMsg)
session.addMessage(errorMessage)
this.emitMessage(errorMessage)
this.emitStatus("ready")
return
}
this.emitStatus("tool_call")
const results: ToolResult[] = []
for (const toolCall of parsed.toolCalls) {
if (this.aborted) {
return
}
this.emitToolCall(toolCall)
const result = await this.executeToolCall(toolCall, session)
results.push(result)
this.emitToolResult(result)
}
const toolMessage = createToolMessage(results)
session.addMessage(toolMessage)
this.contextManager.addTokens(response.tokens)
if (this.contextManager.needsCompression()) {
await this.contextManager.compress(session, this.llm)
}
this.contextManager.updateSession(session)
await this.sessionStorage.saveSession(session)
this.emitStatus("thinking")
}
}
private async buildMessages(session: Session): Promise<ChatMessage[]> {
const messages: ChatMessage[] = []
messages.push(createSystemMessage(SYSTEM_PROMPT))
if (this.projectStructure) {
const asts = await this.storage.getAllASTs()
const metas = await this.storage.getAllMetas()
const context = buildInitialContext(this.projectStructure, asts, metas)
messages.push(createSystemMessage(context))
}
messages.push(...session.history)
return messages
}
private async executeToolCall(toolCall: ToolCall, session: Session): Promise<ToolResult> {
const startTime = Date.now()
const tool = this.tools.get(toolCall.name)
if (!tool) {
return createErrorResult(
toolCall.id,
`Unknown tool: ${toolCall.name}`,
Date.now() - startTime,
)
}
const context: ToolContext = {
projectRoot: this.projectRoot,
storage: this.storage,
requestConfirmation: async (msg: string, diff?: DiffInfo) => {
return this.handleConfirmation(msg, diff, toolCall, session)
},
onProgress: (_msg: string) => {
this.events.onStatusChange?.("tool_call")
},
}
try {
const validationError = tool.validateParams(toolCall.params)
if (validationError) {
return createErrorResult(toolCall.id, validationError, Date.now() - startTime)
}
const result = await tool.execute(toolCall.params, context)
return result
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
return createErrorResult(toolCall.id, errorMessage, Date.now() - startTime)
}
}
private async handleConfirmation(
msg: string,
diff: DiffInfo | undefined,
toolCall: ToolCall,
session: Session,
): Promise<boolean> {
if (this.options.autoApply) {
if (diff) {
this.createUndoEntryFromDiff(diff, toolCall, session)
}
return true
}
this.emitStatus("awaiting_confirmation")
if (this.events.onConfirmation) {
const confirmed = await this.events.onConfirmation(msg, diff)
if (confirmed && diff) {
this.createUndoEntryFromDiff(diff, toolCall, session)
}
return confirmed
}
if (diff) {
this.createUndoEntryFromDiff(diff, toolCall, session)
}
return true
}
private createUndoEntryFromDiff(diff: DiffInfo, toolCall: ToolCall, session: Session): void {
const entry = createUndoEntry(
randomUUID(),
diff.filePath,
diff.oldLines,
diff.newLines,
`${toolCall.name}: ${diff.filePath}`,
toolCall.id,
)
session.addUndoEntry(entry)
void this.sessionStorage.pushUndoEntry(session.id, entry)
session.stats.editsApplied++
this.events.onUndoEntry?.(entry)
}
private async handleLLMError(error: unknown, session: Session): Promise<void> {
this.emitStatus("error")
const ipuaroError =
error instanceof IpuaroError
? error
: IpuaroError.llm(error instanceof Error ? error.message : String(error))
if (this.events.onError) {
const choice = await this.events.onError(ipuaroError)
if (choice === "retry") {
this.emitStatus("thinking")
return this.execute(session, "")
}
}
const errorMessage = createSystemMessage(`Error: ${ipuaroError.message}`)
session.addMessage(errorMessage)
this.emitMessage(errorMessage)
this.emitStatus("ready")
}
private emitMessage(message: ChatMessage): void {
this.events.onMessage?.(message)
}
private emitToolCall(call: ToolCall): void {
this.events.onToolCall?.(call)
}
private emitToolResult(result: ToolResult): void {
this.events.onToolResult?.(result)
}
private emitStatus(status: HandleMessageStatus): void {
this.events.onStatusChange?.(status)
}
}

View File

@@ -0,0 +1,62 @@
import { randomUUID } from "node:crypto"
import { Session } from "../../domain/entities/Session.js"
import type { ISessionStorage } from "../../domain/services/ISessionStorage.js"
/**
* Options for starting a session.
*/
export interface StartSessionOptions {
/** Force creation of a new session even if one exists */
forceNew?: boolean
/** Specific session ID to load */
sessionId?: string
}
/**
* Result of starting a session.
*/
export interface StartSessionResult {
session: Session
isNew: boolean
}
/**
* Use case for starting a session.
* Creates a new session or loads the latest one for a project.
*/
export class StartSession {
constructor(private readonly sessionStorage: ISessionStorage) {}
/**
* Execute the use case.
*
* @param projectName - The project name to start a session for
* @param options - Optional configuration
* @returns The session and whether it was newly created
*/
async execute(
projectName: string,
options: StartSessionOptions = {},
): Promise<StartSessionResult> {
if (options.sessionId) {
const session = await this.sessionStorage.loadSession(options.sessionId)
if (session) {
await this.sessionStorage.touchSession(session.id)
return { session, isNew: false }
}
}
if (!options.forceNew) {
const latestSession = await this.sessionStorage.getLatestSession(projectName)
if (latestSession) {
await this.sessionStorage.touchSession(latestSession.id)
return { session: latestSession, isNew: false }
}
}
const session = new Session(randomUUID(), projectName)
await this.sessionStorage.saveSession(session)
return { session, isNew: true }
}
}

View File

@@ -0,0 +1,119 @@
import { promises as fs } from "node:fs"
import type { Session } from "../../domain/entities/Session.js"
import type { ISessionStorage } from "../../domain/services/ISessionStorage.js"
import type { IStorage } from "../../domain/services/IStorage.js"
import { canUndo, type UndoEntry } from "../../domain/value-objects/UndoEntry.js"
import { md5 } from "../../shared/utils/hash.js"
/**
* Result of undo operation.
*/
export interface UndoResult {
success: boolean
entry?: UndoEntry
error?: string
}
/**
* Use case for undoing the last file change.
*/
export class UndoChange {
constructor(
private readonly sessionStorage: ISessionStorage,
private readonly storage: IStorage,
) {}
/**
* Execute undo operation.
*
* @param session - The current session
* @returns Result of the undo operation
*/
async execute(session: Session): Promise<UndoResult> {
const entry = await this.sessionStorage.popUndoEntry(session.id)
if (!entry) {
return {
success: false,
error: "No changes to undo",
}
}
try {
const currentContent = await this.readCurrentContent(entry.filePath)
if (!canUndo(entry, currentContent)) {
await this.sessionStorage.pushUndoEntry(session.id, entry)
return {
success: false,
entry,
error: "File has been modified since the change was made",
}
}
await this.restoreContent(entry.filePath, entry.previousContent)
session.popUndoEntry()
session.stats.editsApplied--
return {
success: true,
entry,
}
} catch (error) {
await this.sessionStorage.pushUndoEntry(session.id, entry)
const message = error instanceof Error ? error.message : "Unknown error"
return {
success: false,
entry,
error: `Failed to undo: ${message}`,
}
}
}
/**
* Check if undo is available.
*/
async canUndo(session: Session): Promise<boolean> {
const stack = await this.sessionStorage.getUndoStack(session.id)
return stack.length > 0
}
/**
* Get the next undo entry without removing it.
*/
async peekUndoEntry(session: Session): Promise<UndoEntry | null> {
const stack = await this.sessionStorage.getUndoStack(session.id)
if (stack.length === 0) {
return null
}
return stack[stack.length - 1]
}
private async readCurrentContent(filePath: string): Promise<string[]> {
try {
const content = await fs.readFile(filePath, "utf-8")
return content.split("\n")
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return []
}
throw error
}
}
private async restoreContent(filePath: string, content: string[]): Promise<void> {
const fileContent = content.join("\n")
await fs.writeFile(filePath, fileContent, "utf-8")
const hash = md5(fileContent)
const stats = await fs.stat(filePath)
await this.storage.setFile(filePath, {
lines: content,
hash,
size: stats.size,
lastModified: stats.mtimeMs,
})
}
}

View File

@@ -1,4 +1,6 @@
/* // Application Use Cases
* Application Use Cases
* Will be implemented in version 0.10.0+ export * from "./StartSession.js"
*/ export * from "./HandleMessage.js"
export * from "./UndoChange.js"
export * from "./ContextManager.js"

View File

@@ -0,0 +1,250 @@
/**
* Index command implementation.
* Indexes project without starting TUI.
*/
import * as fs from "node:fs/promises"
import * as path from "node:path"
import { RedisClient } from "../../infrastructure/storage/RedisClient.js"
import { RedisStorage } from "../../infrastructure/storage/RedisStorage.js"
import { generateProjectName } from "../../infrastructure/storage/schema.js"
import { FileScanner } from "../../infrastructure/indexer/FileScanner.js"
import { ASTParser } from "../../infrastructure/indexer/ASTParser.js"
import { MetaAnalyzer } from "../../infrastructure/indexer/MetaAnalyzer.js"
import { IndexBuilder } from "../../infrastructure/indexer/IndexBuilder.js"
import { createFileData } from "../../domain/value-objects/FileData.js"
import type { FileAST } from "../../domain/value-objects/FileAST.js"
import { type Config, DEFAULT_CONFIG } from "../../shared/constants/config.js"
import { md5 } from "../../shared/utils/hash.js"
import { checkRedis } from "./onboarding.js"
type Language = "ts" | "tsx" | "js" | "jsx"
/**
* Result of index command.
*/
export interface IndexResult {
success: boolean
filesIndexed: number
filesSkipped: number
errors: string[]
duration: number
}
/**
* Progress callback for indexing.
*/
export type IndexProgressCallback = (
phase: "scanning" | "parsing" | "analyzing" | "storing",
current: number,
total: number,
currentFile?: string,
) => void
/**
* Execute the index command.
*/
export async function executeIndex(
projectPath: string,
config: Config = DEFAULT_CONFIG,
onProgress?: IndexProgressCallback,
): Promise<IndexResult> {
const startTime = Date.now()
const resolvedPath = path.resolve(projectPath)
const projectName = generateProjectName(resolvedPath)
const errors: string[] = []
console.warn(`📁 Indexing project: ${resolvedPath}`)
console.warn(` Project name: ${projectName}\n`)
const redisResult = await checkRedis(config.redis)
if (!redisResult.ok) {
console.error(`${redisResult.error ?? "Redis unavailable"}`)
return {
success: false,
filesIndexed: 0,
filesSkipped: 0,
errors: [redisResult.error ?? "Redis unavailable"],
duration: Date.now() - startTime,
}
}
let redisClient: RedisClient | null = null
try {
redisClient = new RedisClient(config.redis)
await redisClient.connect()
const storage = new RedisStorage(redisClient, projectName)
const scanner = new FileScanner({
onProgress: (progress): void => {
onProgress?.("scanning", progress.current, progress.total, progress.currentFile)
},
})
const astParser = new ASTParser()
const metaAnalyzer = new MetaAnalyzer(resolvedPath)
const indexBuilder = new IndexBuilder(resolvedPath)
console.warn("🔍 Scanning files...")
const files = await scanner.scanAll(resolvedPath)
console.warn(` Found ${String(files.length)} files\n`)
if (files.length === 0) {
console.warn("⚠️ No files found to index.")
return {
success: true,
filesIndexed: 0,
filesSkipped: 0,
errors: [],
duration: Date.now() - startTime,
}
}
console.warn("📝 Parsing files...")
const allASTs = new Map<string, FileAST>()
const fileContents = new Map<string, string>()
let parsed = 0
let skipped = 0
for (const file of files) {
const fullPath = path.join(resolvedPath, file.path)
const language = getLanguage(file.path)
if (!language) {
skipped++
continue
}
try {
const content = await fs.readFile(fullPath, "utf-8")
const ast = astParser.parse(content, language)
if (ast.parseError) {
errors.push(
`Parse error in ${file.path}: ${ast.parseErrorMessage ?? "unknown"}`,
)
skipped++
continue
}
allASTs.set(file.path, ast)
fileContents.set(file.path, content)
parsed++
onProgress?.("parsing", parsed + skipped, files.length, file.path)
if ((parsed + skipped) % 50 === 0) {
process.stdout.write(
`\r Parsed ${String(parsed)} files (${String(skipped)} skipped)...`,
)
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
errors.push(`Error reading ${file.path}: ${message}`)
skipped++
}
}
console.warn(`\r Parsed ${String(parsed)} files (${String(skipped)} skipped) \n`)
console.warn("📊 Analyzing metadata...")
let analyzed = 0
for (const [filePath, ast] of allASTs) {
const content = fileContents.get(filePath) ?? ""
const meta = metaAnalyzer.analyze(
path.join(resolvedPath, filePath),
ast,
content,
allASTs,
)
const fileData = createFileData({
lines: content.split("\n"),
hash: md5(content),
size: content.length,
lastModified: Date.now(),
})
await storage.setFile(filePath, fileData)
await storage.setAST(filePath, ast)
await storage.setMeta(filePath, meta)
analyzed++
onProgress?.("analyzing", analyzed, allASTs.size, filePath)
if (analyzed % 50 === 0) {
process.stdout.write(
`\r Analyzed ${String(analyzed)}/${String(allASTs.size)} files...`,
)
}
}
console.warn(`\r Analyzed ${String(analyzed)} files \n`)
console.warn("🏗️ Building indexes...")
onProgress?.("storing", 0, 2)
const symbolIndex = indexBuilder.buildSymbolIndex(allASTs)
const depsGraph = indexBuilder.buildDepsGraph(allASTs)
await storage.setSymbolIndex(symbolIndex)
await storage.setDepsGraph(depsGraph)
onProgress?.("storing", 2, 2)
const duration = Date.now() - startTime
const durationSec = (duration / 1000).toFixed(2)
console.warn(`✅ Indexing complete in ${durationSec}s`)
console.warn(` Files indexed: ${String(parsed)}`)
console.warn(` Files skipped: ${String(skipped)}`)
console.warn(` Symbols: ${String(symbolIndex.size)}`)
if (errors.length > 0) {
console.warn(`\n⚠ ${String(errors.length)} errors occurred:`)
for (const error of errors.slice(0, 5)) {
console.warn(` - ${error}`)
}
if (errors.length > 5) {
console.warn(` ... and ${String(errors.length - 5)} more`)
}
}
return {
success: true,
filesIndexed: parsed,
filesSkipped: skipped,
errors,
duration,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`❌ Indexing failed: ${message}`)
return {
success: false,
filesIndexed: 0,
filesSkipped: 0,
errors: [message],
duration: Date.now() - startTime,
}
} finally {
if (redisClient) {
await redisClient.disconnect()
}
}
}
/**
* Get language from file extension.
*/
function getLanguage(filePath: string): Language | null {
const ext = path.extname(filePath).toLowerCase()
switch (ext) {
case ".ts":
return "ts"
case ".tsx":
return "tsx"
case ".js":
return "js"
case ".jsx":
return "jsx"
default:
return null
}
}

View File

@@ -0,0 +1,18 @@
/**
* CLI commands module.
*/
export { executeStart, type StartOptions, type StartResult } from "./start.js"
export { executeInit, type InitOptions, type InitResult } from "./init.js"
export { executeIndex, type IndexResult, type IndexProgressCallback } from "./index-cmd.js"
export {
runOnboarding,
checkRedis,
checkOllama,
checkModel,
checkProjectSize,
pullModel,
type OnboardingResult,
type OnboardingOptions,
} from "./onboarding.js"
export { registerAllTools } from "./tools-setup.js"

View File

@@ -0,0 +1,114 @@
/**
* Init command implementation.
* Creates .ipuaro.json configuration file.
*/
import * as fs from "node:fs/promises"
import * as path from "node:path"
/**
* Default configuration template for .ipuaro.json
*/
const CONFIG_TEMPLATE = {
$schema: "https://raw.githubusercontent.com/samiyev/puaros/main/packages/ipuaro/schema.json",
redis: {
host: "localhost",
port: 6379,
db: 0,
},
llm: {
model: "qwen2.5-coder:7b-instruct",
temperature: 0.1,
host: "http://localhost:11434",
},
project: {
ignorePatterns: [],
},
edit: {
autoApply: false,
},
}
/**
* Options for init command.
*/
export interface InitOptions {
force?: boolean
}
/**
* Result of init command.
*/
export interface InitResult {
success: boolean
filePath?: string
error?: string
skipped?: boolean
}
/**
* Execute the init command.
* Creates a .ipuaro.json file in the specified directory.
*/
export async function executeInit(
projectPath = ".",
options: InitOptions = {},
): Promise<InitResult> {
const resolvedPath = path.resolve(projectPath)
const configPath = path.join(resolvedPath, ".ipuaro.json")
try {
const exists = await fileExists(configPath)
if (exists && !options.force) {
console.warn(`⚠️ Configuration file already exists: ${configPath}`)
console.warn(" Use --force to overwrite.")
return {
success: true,
skipped: true,
filePath: configPath,
}
}
const dirExists = await fileExists(resolvedPath)
if (!dirExists) {
await fs.mkdir(resolvedPath, { recursive: true })
}
const content = JSON.stringify(CONFIG_TEMPLATE, null, 4)
await fs.writeFile(configPath, content, "utf-8")
console.warn(`✅ Created ${configPath}`)
console.warn("\nConfiguration options:")
console.warn(" redis.host - Redis server host (default: localhost)")
console.warn(" redis.port - Redis server port (default: 6379)")
console.warn(" llm.model - Ollama model name (default: qwen2.5-coder:7b-instruct)")
console.warn(" llm.temperature - LLM temperature (default: 0.1)")
console.warn(" edit.autoApply - Auto-apply edits without confirmation (default: false)")
console.warn("\nRun `ipuaro` to start the AI agent.")
return {
success: true,
filePath: configPath,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`❌ Failed to create configuration: ${message}`)
return {
success: false,
error: message,
}
}
}
/**
* Check if a file or directory exists.
*/
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath)
return true
} catch {
return false
}
}

View File

@@ -0,0 +1,290 @@
/**
* Onboarding checks for CLI.
* Validates environment before starting ipuaro.
*/
import { RedisClient } from "../../infrastructure/storage/RedisClient.js"
import { OllamaClient } from "../../infrastructure/llm/OllamaClient.js"
import { FileScanner } from "../../infrastructure/indexer/FileScanner.js"
import type { LLMConfig, RedisConfig } from "../../shared/constants/config.js"
/**
* Result of onboarding checks.
*/
export interface OnboardingResult {
success: boolean
redisOk: boolean
ollamaOk: boolean
modelOk: boolean
projectOk: boolean
fileCount: number
errors: string[]
warnings: string[]
}
/**
* Options for onboarding checks.
*/
export interface OnboardingOptions {
redisConfig: RedisConfig
llmConfig: LLMConfig
projectPath: string
maxFiles?: number
skipRedis?: boolean
skipOllama?: boolean
skipModel?: boolean
skipProject?: boolean
}
const DEFAULT_MAX_FILES = 10_000
/**
* Check Redis availability.
*/
export async function checkRedis(config: RedisConfig): Promise<{
ok: boolean
error?: string
}> {
const client = new RedisClient(config)
try {
await client.connect()
const pingOk = await client.ping()
await client.disconnect()
if (!pingOk) {
return {
ok: false,
error: "Redis ping failed. Server may be overloaded.",
}
}
return { ok: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return {
ok: false,
error: `Cannot connect to Redis: ${message}
Redis is required for ipuaro to store project indexes and session data.
Install Redis:
macOS: brew install redis && brew services start redis
Ubuntu: sudo apt install redis-server && sudo systemctl start redis
Docker: docker run -d -p 6379:6379 redis`,
}
}
}
/**
* Check Ollama availability.
*/
export async function checkOllama(config: LLMConfig): Promise<{
ok: boolean
error?: string
}> {
const client = new OllamaClient(config)
try {
const available = await client.isAvailable()
if (!available) {
return {
ok: false,
error: `Cannot connect to Ollama at ${config.host}
Ollama is required for ipuaro to process your requests using local LLMs.
Install Ollama:
macOS: brew install ollama && ollama serve
Linux: curl -fsSL https://ollama.com/install.sh | sh && ollama serve
Manual: https://ollama.com/download
After installing, ensure Ollama is running with: ollama serve`,
}
}
return { ok: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return {
ok: false,
error: `Ollama check failed: ${message}`,
}
}
}
/**
* Check model availability.
*/
export async function checkModel(config: LLMConfig): Promise<{
ok: boolean
needsPull: boolean
error?: string
}> {
const client = new OllamaClient(config)
try {
const hasModel = await client.hasModel(config.model)
if (!hasModel) {
return {
ok: false,
needsPull: true,
error: `Model "${config.model}" is not installed.
Would you like to pull it? This may take a few minutes.
Run: ollama pull ${config.model}`,
}
}
return { ok: true, needsPull: false }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return {
ok: false,
needsPull: false,
error: `Model check failed: ${message}`,
}
}
}
/**
* Pull model from Ollama.
*/
export async function pullModel(
config: LLMConfig,
onProgress?: (status: string) => void,
): Promise<{ ok: boolean; error?: string }> {
const client = new OllamaClient(config)
try {
onProgress?.(`Pulling model "${config.model}"...`)
await client.pullModel(config.model)
onProgress?.(`Model "${config.model}" pulled successfully.`)
return { ok: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return {
ok: false,
error: `Failed to pull model: ${message}`,
}
}
}
/**
* Check project size.
*/
export async function checkProjectSize(
projectPath: string,
maxFiles: number = DEFAULT_MAX_FILES,
): Promise<{
ok: boolean
fileCount: number
warning?: string
}> {
const scanner = new FileScanner()
try {
const files = await scanner.scanAll(projectPath)
const fileCount = files.length
if (fileCount > maxFiles) {
return {
ok: true,
fileCount,
warning: `Project has ${fileCount.toLocaleString()} files (>${maxFiles.toLocaleString()}).
This may take a while to index and use more memory.
Consider:
1. Running ipuaro in a subdirectory: ipuaro ./src
2. Adding patterns to .gitignore to exclude unnecessary files
3. Using a smaller project for better performance`,
}
}
if (fileCount === 0) {
return {
ok: false,
fileCount: 0,
warning: `No supported files found in "${projectPath}".
ipuaro supports: .ts, .tsx, .js, .jsx, .json, .yaml, .yml
Ensure you're running ipuaro in a project directory with source files.`,
}
}
return { ok: true, fileCount }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return {
ok: false,
fileCount: 0,
warning: `Failed to scan project: ${message}`,
}
}
}
/**
* Run all onboarding checks.
*/
export async function runOnboarding(options: OnboardingOptions): Promise<OnboardingResult> {
const errors: string[] = []
const warnings: string[] = []
const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES
let redisOk = true
let ollamaOk = true
let modelOk = true
let projectOk = true
let fileCount = 0
if (!options.skipRedis) {
const redisResult = await checkRedis(options.redisConfig)
redisOk = redisResult.ok
if (!redisOk && redisResult.error) {
errors.push(redisResult.error)
}
}
if (!options.skipOllama) {
const ollamaResult = await checkOllama(options.llmConfig)
ollamaOk = ollamaResult.ok
if (!ollamaOk && ollamaResult.error) {
errors.push(ollamaResult.error)
}
}
if (!options.skipModel && ollamaOk) {
const modelResult = await checkModel(options.llmConfig)
modelOk = modelResult.ok
if (!modelOk && modelResult.error) {
errors.push(modelResult.error)
}
}
if (!options.skipProject) {
const projectResult = await checkProjectSize(options.projectPath, maxFiles)
projectOk = projectResult.ok
fileCount = projectResult.fileCount
if (projectResult.warning) {
if (projectResult.ok) {
warnings.push(projectResult.warning)
} else {
errors.push(projectResult.warning)
}
}
}
return {
success: redisOk && ollamaOk && modelOk && projectOk && errors.length === 0,
redisOk,
ollamaOk,
modelOk,
projectOk,
fileCount,
errors,
warnings,
}
}

View File

@@ -0,0 +1,162 @@
/**
* Start command implementation.
* Launches the ipuaro TUI.
*/
import * as path from "node:path"
import * as readline from "node:readline"
import { render } from "ink"
import React from "react"
import { App, type AppDependencies } from "../../tui/App.js"
import { RedisClient } from "../../infrastructure/storage/RedisClient.js"
import { RedisStorage } from "../../infrastructure/storage/RedisStorage.js"
import { RedisSessionStorage } from "../../infrastructure/storage/RedisSessionStorage.js"
import { OllamaClient } from "../../infrastructure/llm/OllamaClient.js"
import { ToolRegistry } from "../../infrastructure/tools/registry.js"
import { generateProjectName } from "../../infrastructure/storage/schema.js"
import { type Config, DEFAULT_CONFIG } from "../../shared/constants/config.js"
import { checkModel, pullModel, runOnboarding } from "./onboarding.js"
import { registerAllTools } from "./tools-setup.js"
/**
* Options for start command.
*/
export interface StartOptions {
autoApply?: boolean
model?: string
}
/**
* Result of start command.
*/
export interface StartResult {
success: boolean
error?: string
}
/**
* Execute the start command.
*/
export async function executeStart(
projectPath: string,
options: StartOptions,
config: Config = DEFAULT_CONFIG,
): Promise<StartResult> {
const resolvedPath = path.resolve(projectPath)
const projectName = generateProjectName(resolvedPath)
const llmConfig = {
...config.llm,
model: options.model ?? config.llm.model,
}
console.warn("🔍 Running pre-flight checks...\n")
const onboardingResult = await runOnboarding({
redisConfig: config.redis,
llmConfig,
projectPath: resolvedPath,
})
for (const warning of onboardingResult.warnings) {
console.warn(`⚠️ ${warning}\n`)
}
if (!onboardingResult.success) {
for (const error of onboardingResult.errors) {
console.error(`${error}\n`)
}
if (!onboardingResult.modelOk && onboardingResult.ollamaOk) {
const shouldPull = await promptYesNo(
`Would you like to pull "${llmConfig.model}"? (y/n): `,
)
if (shouldPull) {
const pullResult = await pullModel(llmConfig, console.warn)
if (!pullResult.ok) {
console.error(`${pullResult.error ?? "Unknown error"}`)
return { success: false, error: pullResult.error }
}
const recheckModel = await checkModel(llmConfig)
if (!recheckModel.ok) {
console.error("❌ Model still not available after pull.")
return { success: false, error: "Model pull failed" }
}
} else {
return { success: false, error: "Model not available" }
}
} else {
return {
success: false,
error: onboardingResult.errors.join("\n"),
}
}
}
console.warn(`✅ All checks passed. Found ${String(onboardingResult.fileCount)} files.\n`)
console.warn("🚀 Starting ipuaro...\n")
const redisClient = new RedisClient(config.redis)
try {
await redisClient.connect()
const storage = new RedisStorage(redisClient, projectName)
const sessionStorage = new RedisSessionStorage(redisClient)
const llm = new OllamaClient(llmConfig)
const tools = new ToolRegistry()
registerAllTools(tools)
const deps: AppDependencies = {
storage,
sessionStorage,
llm,
tools,
}
const handleExit = (): void => {
void redisClient.disconnect()
}
const { waitUntilExit } = render(
React.createElement(App, {
projectPath: resolvedPath,
autoApply: options.autoApply ?? config.edit.autoApply,
deps,
onExit: handleExit,
}),
)
await waitUntilExit()
await redisClient.disconnect()
return { success: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`❌ Failed to start ipuaro: ${message}`)
await redisClient.disconnect()
return { success: false, error: message }
}
}
/**
* Simple yes/no prompt for CLI.
*/
async function promptYesNo(question: string): Promise<boolean> {
return new Promise((resolve) => {
process.stdout.write(question)
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
rl.once("line", (answer: string) => {
rl.close()
resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes")
})
})
}

View File

@@ -0,0 +1,59 @@
/**
* Tool registration helper for CLI.
* Registers all 18 tools with the tool registry.
*/
import type { IToolRegistry } from "../../application/interfaces/IToolRegistry.js"
import { GetLinesTool } from "../../infrastructure/tools/read/GetLinesTool.js"
import { GetFunctionTool } from "../../infrastructure/tools/read/GetFunctionTool.js"
import { GetClassTool } from "../../infrastructure/tools/read/GetClassTool.js"
import { GetStructureTool } from "../../infrastructure/tools/read/GetStructureTool.js"
import { EditLinesTool } from "../../infrastructure/tools/edit/EditLinesTool.js"
import { CreateFileTool } from "../../infrastructure/tools/edit/CreateFileTool.js"
import { DeleteFileTool } from "../../infrastructure/tools/edit/DeleteFileTool.js"
import { FindReferencesTool } from "../../infrastructure/tools/search/FindReferencesTool.js"
import { FindDefinitionTool } from "../../infrastructure/tools/search/FindDefinitionTool.js"
import { GetDependenciesTool } from "../../infrastructure/tools/analysis/GetDependenciesTool.js"
import { GetDependentsTool } from "../../infrastructure/tools/analysis/GetDependentsTool.js"
import { GetComplexityTool } from "../../infrastructure/tools/analysis/GetComplexityTool.js"
import { GetTodosTool } from "../../infrastructure/tools/analysis/GetTodosTool.js"
import { GitStatusTool } from "../../infrastructure/tools/git/GitStatusTool.js"
import { GitDiffTool } from "../../infrastructure/tools/git/GitDiffTool.js"
import { GitCommitTool } from "../../infrastructure/tools/git/GitCommitTool.js"
import { RunCommandTool } from "../../infrastructure/tools/run/RunCommandTool.js"
import { RunTestsTool } from "../../infrastructure/tools/run/RunTestsTool.js"
/**
* Register all 18 tools with the tool registry.
*/
export function registerAllTools(registry: IToolRegistry): void {
registry.register(new GetLinesTool())
registry.register(new GetFunctionTool())
registry.register(new GetClassTool())
registry.register(new GetStructureTool())
registry.register(new EditLinesTool())
registry.register(new CreateFileTool())
registry.register(new DeleteFileTool())
registry.register(new FindReferencesTool())
registry.register(new FindDefinitionTool())
registry.register(new GetDependenciesTool())
registry.register(new GetDependentsTool())
registry.register(new GetComplexityTool())
registry.register(new GetTodosTool())
registry.register(new GitStatusTool())
registry.register(new GitDiffTool())
registry.register(new GitCommitTool())
registry.register(new RunCommandTool())
registry.register(new RunTestsTool())
}

View File

@@ -1,44 +1,63 @@
#!/usr/bin/env node #!/usr/bin/env node
/**
* ipuaro CLI entry point.
* Local AI agent for codebase operations with infinite context feeling.
*/
import { createRequire } from "node:module"
import { Command } from "commander" import { Command } from "commander"
import { executeStart } from "./commands/start.js"
import { executeInit } from "./commands/init.js"
import { executeIndex } from "./commands/index-cmd.js"
import { loadConfig } from "../shared/config/loader.js"
const require = createRequire(import.meta.url)
const pkg = require("../../package.json") as { version: string }
const program = new Command() const program = new Command()
program program
.name("ipuaro") .name("ipuaro")
.description("Local AI agent for codebase operations with infinite context feeling") .description("Local AI agent for codebase operations with infinite context feeling")
.version("0.1.0") .version(pkg.version)
program program
.command("start") .command("start", { isDefault: true })
.description("Start ipuaro TUI in the current directory") .description("Start ipuaro TUI in the current directory")
.argument("[path]", "Project path", ".") .argument("[path]", "Project path", ".")
.option("--auto-apply", "Enable auto-apply mode for edits") .option("--auto-apply", "Enable auto-apply mode for edits")
.option("--model <name>", "Override LLM model", "qwen2.5-coder:7b-instruct") .option("--model <name>", "Override LLM model")
.action((path: string, options: { autoApply?: boolean; model?: string }) => { .action(async (projectPath: string, options: { autoApply?: boolean; model?: string }) => {
const model = options.model ?? "default" const config = loadConfig(projectPath)
const autoApply = options.autoApply ?? false const result = await executeStart(projectPath, options, config)
console.warn(`Starting ipuaro in ${path}...`) if (!result.success) {
console.warn(`Model: ${model}`) process.exit(1)
console.warn(`Auto-apply: ${autoApply ? "enabled" : "disabled"}`) }
console.warn("\nNot implemented yet. Coming in version 0.11.0!")
}) })
program program
.command("init") .command("init")
.description("Create .ipuaro.json config file") .description("Create .ipuaro.json config file")
.action(() => { .argument("[path]", "Project path", ".")
console.warn("Creating .ipuaro.json...") .option("--force", "Overwrite existing config file")
console.warn("\nNot implemented yet. Coming in version 0.17.0!") .action(async (projectPath: string, options: { force?: boolean }) => {
const result = await executeInit(projectPath, options)
if (!result.success) {
process.exit(1)
}
}) })
program program
.command("index") .command("index")
.description("Index project without starting TUI") .description("Index project without starting TUI")
.argument("[path]", "Project path", ".") .argument("[path]", "Project path", ".")
.action((path: string) => { .action(async (projectPath: string) => {
console.warn(`Indexing ${path}...`) const config = loadConfig(projectPath)
console.warn("\nNot implemented yet. Coming in version 0.3.0!") const result = await executeIndex(projectPath, config)
if (!result.success) {
process.exit(1)
}
}) })
program.parse() program.parse()

View File

@@ -0,0 +1,88 @@
import type { ContextState, Session, SessionStats } from "../entities/Session.js"
import type { ChatMessage } from "../value-objects/ChatMessage.js"
import type { UndoEntry } from "../value-objects/UndoEntry.js"
/**
* Session data stored in persistence layer.
*/
export interface SessionData {
id: string
projectName: string
createdAt: number
lastActivityAt: number
history: ChatMessage[]
context: ContextState
stats: SessionStats
inputHistory: string[]
}
/**
* Session list item (minimal info for listing).
*/
export interface SessionListItem {
id: string
projectName: string
createdAt: number
lastActivityAt: number
messageCount: number
}
/**
* Storage service interface for session persistence.
*/
export interface ISessionStorage {
/**
* Save a session to storage.
*/
saveSession(session: Session): Promise<void>
/**
* Load a session by ID.
*/
loadSession(sessionId: string): Promise<Session | null>
/**
* Delete a session.
*/
deleteSession(sessionId: string): Promise<void>
/**
* Get list of all sessions for a project.
*/
listSessions(projectName?: string): Promise<SessionListItem[]>
/**
* Get the latest session for a project.
*/
getLatestSession(projectName: string): Promise<Session | null>
/**
* Check if a session exists.
*/
sessionExists(sessionId: string): Promise<boolean>
/**
* Add undo entry to session's undo stack.
*/
pushUndoEntry(sessionId: string, entry: UndoEntry): Promise<void>
/**
* Pop undo entry from session's undo stack.
*/
popUndoEntry(sessionId: string): Promise<UndoEntry | null>
/**
* Get undo stack for a session.
*/
getUndoStack(sessionId: string): Promise<UndoEntry[]>
/**
* Update session's last activity timestamp.
*/
touchSession(sessionId: string): Promise<void>
/**
* Clear all sessions.
*/
clearAllSessions(): Promise<void>
}

View File

@@ -1,5 +1,6 @@
// Domain Service Interfaces (Ports) // Domain Service Interfaces (Ports)
export * from "./IStorage.js" export * from "./IStorage.js"
export * from "./ISessionStorage.js"
export * from "./ILLMClient.js" export * from "./ILLMClient.js"
export * from "./ITool.js" export * from "./ITool.js"
export * from "./IIndexer.js" export * from "./IIndexer.js"

View File

@@ -21,5 +21,8 @@ export * from "./shared/index.js"
// Infrastructure exports // Infrastructure exports
export * from "./infrastructure/index.js" export * from "./infrastructure/index.js"
// TUI exports
export * from "./tui/index.js"
// Version // Version
export const VERSION = pkg.version export const VERSION = pkg.version

View File

@@ -3,3 +3,4 @@ export * from "./storage/index.js"
export * from "./indexer/index.js" export * from "./indexer/index.js"
export * from "./llm/index.js" export * from "./llm/index.js"
export * from "./tools/index.js" export * from "./tools/index.js"
export * from "./security/index.js"

View File

@@ -0,0 +1,293 @@
import * as path from "node:path"
import { promises as fs } from "node:fs"
/**
* Path validation result classification.
*/
export type PathValidationStatus = "valid" | "invalid" | "outside_project"
/**
* Result of path validation.
*/
export interface PathValidationResult {
/** Validation status */
status: PathValidationStatus
/** Reason for the status */
reason: string
/** Normalized absolute path (only if valid) */
absolutePath?: string
/** Normalized relative path (only if valid) */
relativePath?: string
}
/**
* Options for path validation.
*/
export interface PathValidatorOptions {
/** Allow paths that don't exist yet (for create operations) */
allowNonExistent?: boolean
/** Check if path is a directory */
requireDirectory?: boolean
/** Check if path is a file */
requireFile?: boolean
/** Follow symlinks when checking existence */
followSymlinks?: boolean
}
/**
* Path validator for ensuring file operations stay within project boundaries.
* Prevents path traversal attacks and unauthorized file access.
*/
export class PathValidator {
private readonly projectRoot: string
constructor(projectRoot: string) {
this.projectRoot = path.resolve(projectRoot)
}
/**
* Validate a path and return detailed result.
* @param inputPath - Path to validate (relative or absolute)
* @param options - Validation options
*/
async validate(
inputPath: string,
options: PathValidatorOptions = {},
): Promise<PathValidationResult> {
if (!inputPath || inputPath.trim() === "") {
return {
status: "invalid",
reason: "Path is empty",
}
}
const normalizedInput = inputPath.trim()
if (this.containsTraversalPatterns(normalizedInput)) {
return {
status: "invalid",
reason: "Path contains traversal patterns",
}
}
const absolutePath = path.resolve(this.projectRoot, normalizedInput)
if (!this.isWithinProject(absolutePath)) {
return {
status: "outside_project",
reason: "Path is outside project root",
}
}
const relativePath = path.relative(this.projectRoot, absolutePath)
if (!options.allowNonExistent) {
const existsResult = await this.checkExists(absolutePath, options)
if (existsResult) {
return existsResult
}
}
return {
status: "valid",
reason: "Path is valid",
absolutePath,
relativePath,
}
}
/**
* Synchronous validation for simple checks.
* Does not check file existence or type.
* @param inputPath - Path to validate (relative or absolute)
*/
validateSync(inputPath: string): PathValidationResult {
if (!inputPath || inputPath.trim() === "") {
return {
status: "invalid",
reason: "Path is empty",
}
}
const normalizedInput = inputPath.trim()
if (this.containsTraversalPatterns(normalizedInput)) {
return {
status: "invalid",
reason: "Path contains traversal patterns",
}
}
const absolutePath = path.resolve(this.projectRoot, normalizedInput)
if (!this.isWithinProject(absolutePath)) {
return {
status: "outside_project",
reason: "Path is outside project root",
}
}
const relativePath = path.relative(this.projectRoot, absolutePath)
return {
status: "valid",
reason: "Path is valid",
absolutePath,
relativePath,
}
}
/**
* Quick check if path is within project.
* @param inputPath - Path to check (relative or absolute)
*/
isWithin(inputPath: string): boolean {
if (!inputPath || inputPath.trim() === "") {
return false
}
const normalizedInput = inputPath.trim()
if (this.containsTraversalPatterns(normalizedInput)) {
return false
}
const absolutePath = path.resolve(this.projectRoot, normalizedInput)
return this.isWithinProject(absolutePath)
}
/**
* Resolve a path relative to project root.
* Returns null if path would be outside project.
* @param inputPath - Path to resolve
*/
resolve(inputPath: string): string | null {
const result = this.validateSync(inputPath)
return result.status === "valid" ? (result.absolutePath ?? null) : null
}
/**
* Resolve a path or throw an error if invalid.
* @param inputPath - Path to resolve
* @returns Tuple of [absolutePath, relativePath]
* @throws Error if path is invalid
*/
resolveOrThrow(inputPath: string): [absolutePath: string, relativePath: string] {
const result = this.validateSync(inputPath)
if (result.status !== "valid" || result.absolutePath === undefined) {
throw new Error(result.reason)
}
return [result.absolutePath, result.relativePath ?? ""]
}
/**
* Get relative path from project root.
* Returns null if path would be outside project.
* @param inputPath - Path to make relative
*/
relativize(inputPath: string): string | null {
const result = this.validateSync(inputPath)
return result.status === "valid" ? (result.relativePath ?? null) : null
}
/**
* Get the project root path.
*/
getProjectRoot(): string {
return this.projectRoot
}
/**
* Check if path contains directory traversal patterns.
*/
private containsTraversalPatterns(inputPath: string): boolean {
const normalized = inputPath.replace(/\\/g, "/")
if (normalized.includes("..")) {
return true
}
if (normalized.startsWith("~")) {
return true
}
return false
}
/**
* Check if absolute path is within project root.
*/
private isWithinProject(absolutePath: string): boolean {
const normalizedProject = this.projectRoot.replace(/\\/g, "/")
const normalizedPath = absolutePath.replace(/\\/g, "/")
if (normalizedPath === normalizedProject) {
return true
}
const projectWithSep = normalizedProject.endsWith("/")
? normalizedProject
: `${normalizedProject}/`
return normalizedPath.startsWith(projectWithSep)
}
/**
* Check file existence and type.
*/
private async checkExists(
absolutePath: string,
options: PathValidatorOptions,
): Promise<PathValidationResult | null> {
try {
const statFn = options.followSymlinks ? fs.stat : fs.lstat
const stats = await statFn(absolutePath)
if (options.requireDirectory && !stats.isDirectory()) {
return {
status: "invalid",
reason: "Path is not a directory",
}
}
if (options.requireFile && !stats.isFile()) {
return {
status: "invalid",
reason: "Path is not a file",
}
}
return null
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return {
status: "invalid",
reason: "Path does not exist",
}
}
return {
status: "invalid",
reason: `Cannot access path: ${(error as Error).message}`,
}
}
}
}
/**
* Create a path validator for a project.
* @param projectRoot - Root directory of the project
*/
export function createPathValidator(projectRoot: string): PathValidator {
return new PathValidator(projectRoot)
}
/**
* Standalone function for quick path validation.
* @param inputPath - Path to validate
* @param projectRoot - Project root directory
*/
export function validatePath(inputPath: string, projectRoot: string): boolean {
const validator = new PathValidator(projectRoot)
return validator.isWithin(inputPath)
}

View File

@@ -0,0 +1,9 @@
// Security module exports
export {
PathValidator,
createPathValidator,
validatePath,
type PathValidationResult,
type PathValidationStatus,
type PathValidatorOptions,
} from "./PathValidator.js"

View File

@@ -0,0 +1,225 @@
import type { ISessionStorage, SessionListItem } from "../../domain/services/ISessionStorage.js"
import { type ContextState, Session, type SessionStats } from "../../domain/entities/Session.js"
import type { ChatMessage } from "../../domain/value-objects/ChatMessage.js"
import type { UndoEntry } from "../../domain/value-objects/UndoEntry.js"
import { MAX_UNDO_STACK_SIZE } from "../../domain/constants/index.js"
import { IpuaroError } from "../../shared/errors/IpuaroError.js"
import { RedisClient } from "./RedisClient.js"
import { SessionFields, SessionKeys } from "./schema.js"
/**
* Redis implementation of ISessionStorage.
* Stores session data in Redis hashes and lists.
*/
export class RedisSessionStorage implements ISessionStorage {
private readonly client: RedisClient
constructor(client: RedisClient) {
this.client = client
}
async saveSession(session: Session): Promise<void> {
const redis = this.getRedis()
const dataKey = SessionKeys.data(session.id)
const pipeline = redis.pipeline()
pipeline.hset(dataKey, SessionFields.projectName, session.projectName)
pipeline.hset(dataKey, SessionFields.createdAt, String(session.createdAt))
pipeline.hset(dataKey, SessionFields.lastActivityAt, String(session.lastActivityAt))
pipeline.hset(dataKey, SessionFields.history, JSON.stringify(session.history))
pipeline.hset(dataKey, SessionFields.context, JSON.stringify(session.context))
pipeline.hset(dataKey, SessionFields.stats, JSON.stringify(session.stats))
pipeline.hset(dataKey, SessionFields.inputHistory, JSON.stringify(session.inputHistory))
await this.addToSessionsList(session.id)
await pipeline.exec()
}
async loadSession(sessionId: string): Promise<Session | null> {
const redis = this.getRedis()
const dataKey = SessionKeys.data(sessionId)
const data = await redis.hgetall(dataKey)
if (!data || Object.keys(data).length === 0) {
return null
}
const session = new Session(
sessionId,
data[SessionFields.projectName],
Number(data[SessionFields.createdAt]),
)
session.lastActivityAt = Number(data[SessionFields.lastActivityAt])
session.history = this.parseJSON(data[SessionFields.history], "history") as ChatMessage[]
session.context = this.parseJSON(data[SessionFields.context], "context") as ContextState
session.stats = this.parseJSON(data[SessionFields.stats], "stats") as SessionStats
session.inputHistory = this.parseJSON(
data[SessionFields.inputHistory],
"inputHistory",
) as string[]
const undoStack = await this.getUndoStack(sessionId)
for (const entry of undoStack) {
session.undoStack.push(entry)
}
return session
}
async deleteSession(sessionId: string): Promise<void> {
const redis = this.getRedis()
await Promise.all([
redis.del(SessionKeys.data(sessionId)),
redis.del(SessionKeys.undo(sessionId)),
redis.lrem(SessionKeys.list, 0, sessionId),
])
}
async listSessions(projectName?: string): Promise<SessionListItem[]> {
const redis = this.getRedis()
const sessionIds = await redis.lrange(SessionKeys.list, 0, -1)
const sessions: SessionListItem[] = []
for (const id of sessionIds) {
const data = await redis.hgetall(SessionKeys.data(id))
if (!data || Object.keys(data).length === 0) {
continue
}
const sessionProjectName = data[SessionFields.projectName]
if (projectName && sessionProjectName !== projectName) {
continue
}
const history = this.parseJSON(data[SessionFields.history], "history") as ChatMessage[]
sessions.push({
id,
projectName: sessionProjectName,
createdAt: Number(data[SessionFields.createdAt]),
lastActivityAt: Number(data[SessionFields.lastActivityAt]),
messageCount: history.length,
})
}
sessions.sort((a, b) => b.lastActivityAt - a.lastActivityAt)
return sessions
}
async getLatestSession(projectName: string): Promise<Session | null> {
const sessions = await this.listSessions(projectName)
if (sessions.length === 0) {
return null
}
return this.loadSession(sessions[0].id)
}
async sessionExists(sessionId: string): Promise<boolean> {
const redis = this.getRedis()
const exists = await redis.exists(SessionKeys.data(sessionId))
return exists === 1
}
async pushUndoEntry(sessionId: string, entry: UndoEntry): Promise<void> {
const redis = this.getRedis()
const undoKey = SessionKeys.undo(sessionId)
await redis.rpush(undoKey, JSON.stringify(entry))
const length = await redis.llen(undoKey)
if (length > MAX_UNDO_STACK_SIZE) {
await redis.lpop(undoKey)
}
}
async popUndoEntry(sessionId: string): Promise<UndoEntry | null> {
const redis = this.getRedis()
const undoKey = SessionKeys.undo(sessionId)
const data = await redis.rpop(undoKey)
if (!data) {
return null
}
return this.parseJSON(data, "UndoEntry") as UndoEntry
}
async getUndoStack(sessionId: string): Promise<UndoEntry[]> {
const redis = this.getRedis()
const undoKey = SessionKeys.undo(sessionId)
const entries = await redis.lrange(undoKey, 0, -1)
return entries.map((entry) => this.parseJSON(entry, "UndoEntry") as UndoEntry)
}
async touchSession(sessionId: string): Promise<void> {
const redis = this.getRedis()
await redis.hset(
SessionKeys.data(sessionId),
SessionFields.lastActivityAt,
String(Date.now()),
)
}
async clearAllSessions(): Promise<void> {
const redis = this.getRedis()
const sessionIds = await redis.lrange(SessionKeys.list, 0, -1)
const pipeline = redis.pipeline()
for (const id of sessionIds) {
pipeline.del(SessionKeys.data(id))
pipeline.del(SessionKeys.undo(id))
}
pipeline.del(SessionKeys.list)
await pipeline.exec()
}
private async addToSessionsList(sessionId: string): Promise<void> {
const redis = this.getRedis()
const exists = await redis.lpos(SessionKeys.list, sessionId)
if (exists === null) {
await redis.lpush(SessionKeys.list, sessionId)
}
}
private getRedis(): ReturnType<RedisClient["getClient"]> {
return this.client.getClient()
}
private parseJSON(data: string | undefined, type: string): unknown {
if (!data) {
if (type === "history" || type === "inputHistory") {
return []
}
if (type === "context") {
return { filesInContext: [], tokenUsage: 0, needsCompression: false }
}
if (type === "stats") {
return {
totalTokens: 0,
totalTimeMs: 0,
toolCalls: 0,
editsApplied: 0,
editsRejected: 0,
}
}
return null
}
try {
return JSON.parse(data) as unknown
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"
throw IpuaroError.parse(`Failed to parse ${type}: ${message}`)
}
}
}

View File

@@ -1,6 +1,7 @@
// Storage module exports // Storage module exports
export { RedisClient } from "./RedisClient.js" export { RedisClient } from "./RedisClient.js"
export { RedisStorage } from "./RedisStorage.js" export { RedisStorage } from "./RedisStorage.js"
export { RedisSessionStorage } from "./RedisSessionStorage.js"
export { export {
ProjectKeys, ProjectKeys,
SessionKeys, SessionKeys,

View File

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

View File

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

View File

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

View File

@@ -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: [],
}
}
}

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

View File

@@ -0,0 +1,142 @@
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"
import { PathValidator } from "../../security/PathValidator.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 inputPath = params.path as string
const content = params.content as string
const pathValidator = new PathValidator(ctx.projectRoot)
let absolutePath: string
let relativePath: string
try {
;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return createErrorResult(callId, message, 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
}
}
}

View File

@@ -0,0 +1,137 @@
import { promises as fs } from "node:fs"
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
import {
createErrorResult,
createSuccessResult,
type ToolResult,
} from "../../../domain/value-objects/ToolResult.js"
import { PathValidator } from "../../security/PathValidator.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 inputPath = params.path as string
const pathValidator = new PathValidator(ctx.projectRoot)
let absolutePath: string
let relativePath: string
try {
;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return createErrorResult(callId, message, 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")
}
}

View File

@@ -0,0 +1,227 @@
import { promises as fs } from "node:fs"
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"
import { PathValidator } from "../../security/PathValidator.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 inputPath = params.path as string
const startLine = params.start as number
const endLine = params.end as number
const newContent = params.content as string
const pathValidator = new PathValidator(ctx.projectRoot)
let absolutePath: string
let relativePath: string
try {
;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return createErrorResult(callId, message, 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)
}
}

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

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

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

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

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

View File

@@ -10,3 +10,66 @@ export {
type GetStructureResult, type GetStructureResult,
type TreeNode, type TreeNode,
} from "./read/GetStructureTool.js" } 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"

View File

@@ -1,5 +1,4 @@
import { promises as fs } from "node:fs" 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 { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
import type { ClassInfo } from "../../../domain/value-objects/FileAST.js" import type { ClassInfo } from "../../../domain/value-objects/FileAST.js"
import { import {
@@ -7,6 +6,7 @@ import {
createSuccessResult, createSuccessResult,
type ToolResult, type ToolResult,
} from "../../../domain/value-objects/ToolResult.js" } from "../../../domain/value-objects/ToolResult.js"
import { PathValidator } from "../../security/PathValidator.js"
/** /**
* Result data from get_class tool. * Result data from get_class tool.
@@ -67,16 +67,17 @@ export class GetClassTool implements ITool {
const startTime = Date.now() const startTime = Date.now()
const callId = `${this.name}-${String(startTime)}` const callId = `${this.name}-${String(startTime)}`
const relativePath = params.path as string const inputPath = params.path as string
const className = params.name as string const className = params.name as string
const absolutePath = path.resolve(ctx.projectRoot, relativePath) const pathValidator = new PathValidator(ctx.projectRoot)
if (!absolutePath.startsWith(ctx.projectRoot)) { let absolutePath: string
return createErrorResult( let relativePath: string
callId, try {
"Path must be within project root", ;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath)
Date.now() - startTime, } catch (error) {
) const message = error instanceof Error ? error.message : String(error)
return createErrorResult(callId, message, Date.now() - startTime)
} }
try { try {

View File

@@ -1,5 +1,4 @@
import { promises as fs } from "node:fs" 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 { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
import type { FunctionInfo } from "../../../domain/value-objects/FileAST.js" import type { FunctionInfo } from "../../../domain/value-objects/FileAST.js"
import { import {
@@ -7,6 +6,7 @@ import {
createSuccessResult, createSuccessResult,
type ToolResult, type ToolResult,
} from "../../../domain/value-objects/ToolResult.js" } from "../../../domain/value-objects/ToolResult.js"
import { PathValidator } from "../../security/PathValidator.js"
/** /**
* Result data from get_function tool. * Result data from get_function tool.
@@ -65,16 +65,17 @@ export class GetFunctionTool implements ITool {
const startTime = Date.now() const startTime = Date.now()
const callId = `${this.name}-${String(startTime)}` const callId = `${this.name}-${String(startTime)}`
const relativePath = params.path as string const inputPath = params.path as string
const functionName = params.name as string const functionName = params.name as string
const absolutePath = path.resolve(ctx.projectRoot, relativePath) const pathValidator = new PathValidator(ctx.projectRoot)
if (!absolutePath.startsWith(ctx.projectRoot)) { let absolutePath: string
return createErrorResult( let relativePath: string
callId, try {
"Path must be within project root", ;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath)
Date.now() - startTime, } catch (error) {
) const message = error instanceof Error ? error.message : String(error)
return createErrorResult(callId, message, Date.now() - startTime)
} }
try { try {

View File

@@ -1,11 +1,11 @@
import { promises as fs } from "node:fs" 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 { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
import { import {
createErrorResult, createErrorResult,
createSuccessResult, createSuccessResult,
type ToolResult, type ToolResult,
} from "../../../domain/value-objects/ToolResult.js" } from "../../../domain/value-objects/ToolResult.js"
import { PathValidator } from "../../security/PathValidator.js"
/** /**
* Result data from get_lines tool. * Result data from get_lines tool.
@@ -84,15 +84,16 @@ export class GetLinesTool implements ITool {
const startTime = Date.now() const startTime = Date.now()
const callId = `${this.name}-${String(startTime)}` const callId = `${this.name}-${String(startTime)}`
const relativePath = params.path as string const inputPath = params.path as string
const absolutePath = path.resolve(ctx.projectRoot, relativePath) const pathValidator = new PathValidator(ctx.projectRoot)
if (!absolutePath.startsWith(ctx.projectRoot)) { let absolutePath: string
return createErrorResult( let relativePath: string
callId, try {
"Path must be within project root", ;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath)
Date.now() - startTime, } catch (error) {
) const message = error instanceof Error ? error.message : String(error)
return createErrorResult(callId, message, Date.now() - startTime)
} }
try { try {

View File

@@ -7,6 +7,7 @@ import {
type ToolResult, type ToolResult,
} from "../../../domain/value-objects/ToolResult.js" } from "../../../domain/value-objects/ToolResult.js"
import { DEFAULT_IGNORE_PATTERNS } from "../../../domain/constants/index.js" import { DEFAULT_IGNORE_PATTERNS } from "../../../domain/constants/index.js"
import { PathValidator } from "../../security/PathValidator.js"
/** /**
* Tree node representing a file or directory. * Tree node representing a file or directory.
@@ -89,16 +90,17 @@ export class GetStructureTool implements ITool {
const startTime = Date.now() const startTime = Date.now()
const callId = `${this.name}-${String(startTime)}` const callId = `${this.name}-${String(startTime)}`
const relativePath = (params.path as string | undefined) ?? "" const inputPath = (params.path as string | undefined) ?? "."
const maxDepth = params.depth as number | undefined const maxDepth = params.depth as number | undefined
const absolutePath = path.resolve(ctx.projectRoot, relativePath) const pathValidator = new PathValidator(ctx.projectRoot)
if (!absolutePath.startsWith(ctx.projectRoot)) { let absolutePath: string
return createErrorResult( let relativePath: string
callId, try {
"Path must be within project root", ;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath)
Date.now() - startTime, } catch (error) {
) const message = error instanceof Error ? error.message : String(error)
return createErrorResult(callId, message, Date.now() - startTime)
} }
try { try {

View File

@@ -16,12 +16,7 @@ export class ToolRegistry implements IToolRegistry {
*/ */
register(tool: ITool): void { register(tool: ITool): void {
if (this.tools.has(tool.name)) { if (this.tools.has(tool.name)) {
throw new IpuaroError( throw IpuaroError.validation(`Tool "${tool.name}" is already registered`)
"validation",
`Tool "${tool.name}" is already registered`,
true,
"Use a different tool name or unregister the existing tool first",
)
} }
this.tools.set(tool.name, tool) this.tools.set(tool.name, tool)
} }

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

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

View File

@@ -0,0 +1,365 @@
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, startTime },
error,
execStartTime,
)
}
} 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> {
const configRunner = await this.detectByConfigFile(projectRoot)
if (configRunner) {
return configRunner
}
return this.detectByPackageJson(projectRoot)
}
private async detectByConfigFile(projectRoot: string): Promise<TestRunner | null> {
const configFiles: { files: string[]; runner: TestRunner }[] = [
{
files: ["vitest.config.ts", "vitest.config.js", "vitest.config.mts"],
runner: "vitest",
},
{
files: ["jest.config.js", "jest.config.ts", "jest.config.json"],
runner: "jest",
},
]
for (const { files, runner } of configFiles) {
for (const file of files) {
if (await this.hasFile(projectRoot, file)) {
return runner
}
}
}
return null
}
private async detectByPackageJson(projectRoot: string): Promise<TestRunner | null> {
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>
}
const deps = { ...pkg.devDependencies, ...pkg.dependencies }
if (deps.vitest) {
return "vitest"
}
if (deps.jest) {
return "jest"
}
if (deps.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 builders: Record<TestRunner, () => string[]> = {
vitest: () => this.buildVitestCommand(testPath, filter, watch),
jest: () => this.buildJestCommand(testPath, filter, watch),
mocha: () => this.buildMochaCommand(testPath, filter, watch),
npm: () => this.buildNpmCommand(testPath, filter),
}
return builders[runner]().join(" ")
}
private buildVitestCommand(testPath?: string, filter?: string, watch?: boolean): string[] {
const parts = ["npx vitest"]
if (!watch) {
parts.push("run")
}
if (testPath) {
parts.push(testPath)
}
if (filter) {
parts.push("-t", `"${filter}"`)
}
return parts
}
private buildJestCommand(testPath?: string, filter?: string, watch?: boolean): string[] {
const parts = ["npx jest"]
if (testPath) {
parts.push(testPath)
}
if (filter) {
parts.push("-t", `"${filter}"`)
}
if (watch) {
parts.push("--watch")
}
return parts
}
private buildMochaCommand(testPath?: string, filter?: string, watch?: boolean): string[] {
const parts = ["npx mocha"]
if (testPath) {
parts.push(testPath)
}
if (filter) {
parts.push("--grep", `"${filter}"`)
}
if (watch) {
parts.push("--watch")
}
return parts
}
private buildNpmCommand(testPath?: string, filter?: string): string[] {
const parts = ["npm test"]
if (testPath || filter) {
parts.push("--")
if (testPath) {
parts.push(testPath)
}
if (filter) {
parts.push(`"${filter}"`)
}
}
return parts
}
/**
* 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(
ctx: { callId: string; runner: TestRunner; command: string; startTime: number },
error: unknown,
execStartTime: number,
): ToolResult {
const { callId, runner, command, startTime } = ctx
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)`
}
}

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,295 @@
/**
* ErrorHandler service for handling errors with user interaction.
* Implements the error handling matrix from ROADMAP.md.
*/
import { ERROR_MATRIX, type ErrorOption, type ErrorType, IpuaroError } from "./IpuaroError.js"
/**
* Result of error handling.
*/
export interface ErrorHandlingResult {
action: ErrorOption
shouldContinue: boolean
retryCount?: number
}
/**
* Callback for requesting user choice on error.
*/
export type ErrorChoiceCallback = (
error: IpuaroError,
availableOptions: ErrorOption[],
defaultOption: ErrorOption,
) => Promise<ErrorOption>
/**
* Options for ErrorHandler.
*/
export interface ErrorHandlerOptions {
maxRetries?: number
autoSkipParseErrors?: boolean
autoRetryLLMErrors?: boolean
onError?: ErrorChoiceCallback
}
const DEFAULT_MAX_RETRIES = 3
/**
* Error handler service with matrix-based logic.
*/
export class ErrorHandler {
private readonly maxRetries: number
private readonly autoSkipParseErrors: boolean
private readonly autoRetryLLMErrors: boolean
private readonly onError?: ErrorChoiceCallback
private readonly retryCounters = new Map<string, number>()
constructor(options: ErrorHandlerOptions = {}) {
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES
this.autoSkipParseErrors = options.autoSkipParseErrors ?? true
this.autoRetryLLMErrors = options.autoRetryLLMErrors ?? false
this.onError = options.onError
}
/**
* Handle an error and determine the action to take.
*/
async handle(error: IpuaroError, contextKey?: string): Promise<ErrorHandlingResult> {
const key = contextKey ?? error.message
const currentRetries = this.retryCounters.get(key) ?? 0
if (this.shouldAutoHandle(error)) {
const autoAction = this.getAutoAction(error, currentRetries)
if (autoAction) {
return this.createResult(autoAction, key, currentRetries)
}
}
if (!error.recoverable) {
return {
action: "abort",
shouldContinue: false,
}
}
if (this.onError) {
const choice = await this.onError(error, error.options, error.defaultOption)
return this.createResult(choice, key, currentRetries)
}
return this.createResult(error.defaultOption, key, currentRetries)
}
/**
* Handle an error synchronously with default behavior.
*/
handleSync(error: IpuaroError, contextKey?: string): ErrorHandlingResult {
const key = contextKey ?? error.message
const currentRetries = this.retryCounters.get(key) ?? 0
if (this.shouldAutoHandle(error)) {
const autoAction = this.getAutoAction(error, currentRetries)
if (autoAction) {
return this.createResult(autoAction, key, currentRetries)
}
}
if (!error.recoverable) {
return {
action: "abort",
shouldContinue: false,
}
}
return this.createResult(error.defaultOption, key, currentRetries)
}
/**
* Reset retry counters.
*/
resetRetries(contextKey?: string): void {
if (contextKey) {
this.retryCounters.delete(contextKey)
} else {
this.retryCounters.clear()
}
}
/**
* Get retry count for a context.
*/
getRetryCount(contextKey: string): number {
return this.retryCounters.get(contextKey) ?? 0
}
/**
* Check if max retries exceeded for a context.
*/
isMaxRetriesExceeded(contextKey: string): boolean {
return this.getRetryCount(contextKey) >= this.maxRetries
}
/**
* Wrap a function with error handling.
*/
async wrap<T>(
fn: () => Promise<T>,
errorType: ErrorType,
contextKey?: string,
): Promise<{ success: true; data: T } | { success: false; result: ErrorHandlingResult }> {
try {
const data = await fn()
if (contextKey) {
this.resetRetries(contextKey)
}
return { success: true, data }
} catch (err) {
const error =
err instanceof IpuaroError
? err
: new IpuaroError(errorType, err instanceof Error ? err.message : String(err))
const result = await this.handle(error, contextKey)
return { success: false, result }
}
}
/**
* Wrap a function with retry logic.
*/
async withRetry<T>(fn: () => Promise<T>, errorType: ErrorType, contextKey: string): Promise<T> {
const key = contextKey
while (!this.isMaxRetriesExceeded(key)) {
try {
const result = await fn()
this.resetRetries(key)
return result
} catch (err) {
const error =
err instanceof IpuaroError
? err
: new IpuaroError(
errorType,
err instanceof Error ? err.message : String(err),
)
const handlingResult = await this.handle(error, key)
if (handlingResult.action !== "retry" || !handlingResult.shouldContinue) {
throw error
}
}
}
throw new IpuaroError(
errorType,
`Max retries (${String(this.maxRetries)}) exceeded for: ${key}`,
)
}
private shouldAutoHandle(error: IpuaroError): boolean {
if (error.type === "parse" && this.autoSkipParseErrors) {
return true
}
if ((error.type === "llm" || error.type === "timeout") && this.autoRetryLLMErrors) {
return true
}
return false
}
private getAutoAction(error: IpuaroError, currentRetries: number): ErrorOption | null {
if (error.type === "parse" && this.autoSkipParseErrors) {
return "skip"
}
if ((error.type === "llm" || error.type === "timeout") && this.autoRetryLLMErrors) {
if (currentRetries < this.maxRetries) {
return "retry"
}
return "abort"
}
return null
}
private createResult(
action: ErrorOption,
key: string,
currentRetries: number,
): ErrorHandlingResult {
if (action === "retry") {
this.retryCounters.set(key, currentRetries + 1)
const newRetryCount = currentRetries + 1
if (newRetryCount > this.maxRetries) {
return {
action: "abort",
shouldContinue: false,
retryCount: newRetryCount,
}
}
return {
action: "retry",
shouldContinue: true,
retryCount: newRetryCount,
}
}
this.retryCounters.delete(key)
return {
action,
shouldContinue: action === "skip" || action === "confirm" || action === "regenerate",
retryCount: currentRetries,
}
}
}
/**
* Get available options for an error type.
*/
export function getErrorOptions(errorType: ErrorType): ErrorOption[] {
return ERROR_MATRIX[errorType].options
}
/**
* Get default option for an error type.
*/
export function getDefaultErrorOption(errorType: ErrorType): ErrorOption {
return ERROR_MATRIX[errorType].defaultOption
}
/**
* Check if an error type is recoverable by default.
*/
export function isRecoverableError(errorType: ErrorType): boolean {
return ERROR_MATRIX[errorType].recoverable
}
/**
* Convert any error to IpuaroError.
*/
export function toIpuaroError(error: unknown, defaultType: ErrorType = "unknown"): IpuaroError {
if (error instanceof IpuaroError) {
return error
}
if (error instanceof Error) {
return new IpuaroError(defaultType, error.message, {
context: { originalError: error.name },
})
}
return new IpuaroError(defaultType, String(error))
}
/**
* Create a default ErrorHandler instance.
*/
export function createErrorHandler(options?: ErrorHandlerOptions): ErrorHandler {
return new ErrorHandler(options)
}

View File

@@ -12,6 +12,72 @@ export type ErrorType =
| "timeout" | "timeout"
| "unknown" | "unknown"
/**
* Available options for error recovery.
*/
export type ErrorOption = "retry" | "skip" | "abort" | "confirm" | "regenerate"
/**
* Error metadata with available options.
*/
export interface ErrorMeta {
type: ErrorType
recoverable: boolean
options: ErrorOption[]
defaultOption: ErrorOption
}
/**
* Error handling matrix - defines behavior for each error type.
*/
export const ERROR_MATRIX: Record<ErrorType, Omit<ErrorMeta, "type">> = {
redis: {
recoverable: false,
options: ["retry", "abort"],
defaultOption: "abort",
},
parse: {
recoverable: true,
options: ["skip", "abort"],
defaultOption: "skip",
},
llm: {
recoverable: true,
options: ["retry", "skip", "abort"],
defaultOption: "retry",
},
file: {
recoverable: true,
options: ["skip", "abort"],
defaultOption: "skip",
},
command: {
recoverable: true,
options: ["confirm", "skip", "abort"],
defaultOption: "confirm",
},
conflict: {
recoverable: true,
options: ["skip", "regenerate", "abort"],
defaultOption: "skip",
},
validation: {
recoverable: true,
options: ["skip", "abort"],
defaultOption: "skip",
},
timeout: {
recoverable: true,
options: ["retry", "skip", "abort"],
defaultOption: "retry",
},
unknown: {
recoverable: false,
options: ["abort"],
defaultOption: "abort",
},
}
/** /**
* Base error class for ipuaro. * Base error class for ipuaro.
*/ */
@@ -19,60 +85,142 @@ export class IpuaroError extends Error {
readonly type: ErrorType readonly type: ErrorType
readonly recoverable: boolean readonly recoverable: boolean
readonly suggestion?: string readonly suggestion?: string
readonly options: ErrorOption[]
readonly defaultOption: ErrorOption
readonly context?: Record<string, unknown>
constructor(type: ErrorType, message: string, recoverable = true, suggestion?: string) { constructor(
type: ErrorType,
message: string,
options?: {
recoverable?: boolean
suggestion?: string
context?: Record<string, unknown>
},
) {
super(message) super(message)
this.name = "IpuaroError" this.name = "IpuaroError"
this.type = type this.type = type
this.recoverable = recoverable
this.suggestion = suggestion const meta = ERROR_MATRIX[type]
this.recoverable = options?.recoverable ?? meta.recoverable
this.options = meta.options
this.defaultOption = meta.defaultOption
this.suggestion = options?.suggestion
this.context = options?.context
} }
static redis(message: string): IpuaroError { /**
return new IpuaroError( * Get error metadata.
"redis", */
message, getMeta(): ErrorMeta {
false, return {
"Please ensure Redis is running: redis-server", type: this.type,
) recoverable: this.recoverable,
options: this.options,
defaultOption: this.defaultOption,
}
}
/**
* Check if an option is available for this error.
*/
hasOption(option: ErrorOption): boolean {
return this.options.includes(option)
}
/**
* Create a formatted error message with suggestion.
*/
toDisplayString(): string {
let result = `[${this.type}] ${this.message}`
if (this.suggestion) {
result += `\n Suggestion: ${this.suggestion}`
}
return result
}
static redis(message: string, context?: Record<string, unknown>): IpuaroError {
return new IpuaroError("redis", message, {
suggestion: "Please ensure Redis is running: redis-server",
context,
})
} }
static parse(message: string, filePath?: string): IpuaroError { static parse(message: string, filePath?: string): IpuaroError {
const msg = filePath ? `${message} in ${filePath}` : message const msg = filePath ? `${message} in ${filePath}` : message
return new IpuaroError("parse", msg, true, "File will be skipped") return new IpuaroError("parse", msg, {
suggestion: "File will be skipped during indexing",
context: filePath ? { filePath } : undefined,
})
} }
static llm(message: string): IpuaroError { static llm(message: string, context?: Record<string, unknown>): IpuaroError {
return new IpuaroError( return new IpuaroError("llm", message, {
"llm", suggestion: "Please ensure Ollama is running and model is available",
message, context,
true, })
"Please ensure Ollama is running and model is available",
)
} }
static file(message: string): IpuaroError { static llmTimeout(message: string): IpuaroError {
return new IpuaroError("file", message, true) return new IpuaroError("timeout", message, {
suggestion: "The LLM request timed out. Try again or check Ollama status.",
})
} }
static command(message: string): IpuaroError { static file(message: string, filePath?: string): IpuaroError {
return new IpuaroError("command", message, true) return new IpuaroError("file", message, {
suggestion: "Check if the file exists and you have permission to access it",
context: filePath ? { filePath } : undefined,
})
} }
static conflict(message: string): IpuaroError { static fileNotFound(filePath: string): IpuaroError {
return new IpuaroError( return new IpuaroError("file", `File not found: ${filePath}`, {
"conflict", suggestion: "Check the file path and try again",
message, context: { filePath },
true, })
"File was modified externally. Regenerate or skip.",
)
} }
static validation(message: string): IpuaroError { static command(message: string, command?: string): IpuaroError {
return new IpuaroError("validation", message, true) return new IpuaroError("command", message, {
suggestion: "Command requires confirmation or is not in whitelist",
context: command ? { command } : undefined,
})
} }
static timeout(message: string): IpuaroError { static commandBlacklisted(command: string): IpuaroError {
return new IpuaroError("timeout", message, true, "Try again or increase timeout") return new IpuaroError("command", `Command is blacklisted: ${command}`, {
recoverable: false,
suggestion: "This command is not allowed for security reasons",
context: { command },
})
}
static conflict(message: string, filePath?: string): IpuaroError {
return new IpuaroError("conflict", message, {
suggestion: "File was modified externally. Regenerate or skip the change.",
context: filePath ? { filePath } : undefined,
})
}
static validation(message: string, field?: string): IpuaroError {
return new IpuaroError("validation", message, {
suggestion: "Please check the input and try again",
context: field ? { field } : undefined,
})
}
static timeout(message: string, timeoutMs?: number): IpuaroError {
return new IpuaroError("timeout", message, {
suggestion: "Try again or increase the timeout value",
context: timeoutMs ? { timeoutMs } : undefined,
})
}
static unknown(message: string, originalError?: Error): IpuaroError {
return new IpuaroError("unknown", message, {
context: originalError ? { originalError: originalError.message } : undefined,
})
} }
} }

View File

@@ -1,2 +1,3 @@
// Shared errors // Shared errors
export * from "./IpuaroError.js" export * from "./IpuaroError.js"
export * from "./ErrorHandler.js"

View File

@@ -19,9 +19,13 @@ export type ConfirmChoice = "apply" | "cancel" | "edit"
/** /**
* User choice for errors. * User choice for errors.
* @deprecated Use ErrorOption from shared/errors instead
*/ */
export type ErrorChoice = "retry" | "skip" | "abort" export type ErrorChoice = "retry" | "skip" | "abort"
// Re-export ErrorOption for convenience
export type { ErrorOption } from "../errors/IpuaroError.js"
/** /**
* Project structure node. * Project structure node.
*/ */

View File

@@ -0,0 +1,216 @@
/**
* Main TUI App component.
* Orchestrates the terminal user interface.
*/
import { Box, Text, useApp } from "ink"
import React, { useCallback, useEffect, useState } from "react"
import type { ILLMClient } from "../domain/services/ILLMClient.js"
import type { ISessionStorage } from "../domain/services/ISessionStorage.js"
import type { IStorage } from "../domain/services/IStorage.js"
import type { DiffInfo } from "../domain/services/ITool.js"
import type { ErrorChoice } from "../shared/types/index.js"
import type { IToolRegistry } from "../application/interfaces/IToolRegistry.js"
import type { ProjectStructure } from "../infrastructure/llm/prompts.js"
import { Chat, Input, StatusBar } from "./components/index.js"
import { type CommandResult, useCommands, useHotkeys, useSession } from "./hooks/index.js"
import type { AppProps, BranchInfo } from "./types.js"
export interface AppDependencies {
storage: IStorage
sessionStorage: ISessionStorage
llm: ILLMClient
tools: IToolRegistry
projectStructure?: ProjectStructure
}
export interface ExtendedAppProps extends AppProps {
deps: AppDependencies
onExit?: () => void
}
function LoadingScreen(): React.JSX.Element {
return (
<Box flexDirection="column" padding={1}>
<Text color="cyan">Loading session...</Text>
</Box>
)
}
function ErrorScreen({ error }: { error: Error }): React.JSX.Element {
return (
<Box flexDirection="column" padding={1}>
<Text color="red" bold>
Error
</Text>
<Text color="red">{error.message}</Text>
</Box>
)
}
async function handleConfirmationDefault(_message: string, _diff?: DiffInfo): Promise<boolean> {
return Promise.resolve(true)
}
async function handleErrorDefault(_error: Error): Promise<ErrorChoice> {
return Promise.resolve("skip")
}
export function App({
projectPath,
autoApply: initialAutoApply = false,
deps,
onExit,
}: ExtendedAppProps): React.JSX.Element {
const { exit } = useApp()
const [branch] = useState<BranchInfo>({ name: "main", isDetached: false })
const [sessionTime, setSessionTime] = useState("0m")
const [autoApply, setAutoApply] = useState(initialAutoApply)
const [commandResult, setCommandResult] = useState<CommandResult | null>(null)
const projectName = projectPath.split("/").pop() ?? "unknown"
const { session, messages, status, isLoading, error, sendMessage, undo, clearHistory, abort } =
useSession(
{
storage: deps.storage,
sessionStorage: deps.sessionStorage,
llm: deps.llm,
tools: deps.tools,
projectRoot: projectPath,
projectName,
projectStructure: deps.projectStructure,
},
{
autoApply,
onConfirmation: handleConfirmationDefault,
onError: handleErrorDefault,
},
)
const reindex = useCallback(async (): Promise<void> => {
/*
* TODO: Implement full reindex via IndexProject use case
* For now, this is a placeholder
*/
await Promise.resolve()
}, [])
const { executeCommand, isCommand } = useCommands(
{
session,
sessionStorage: deps.sessionStorage,
storage: deps.storage,
llm: deps.llm,
tools: deps.tools,
projectRoot: projectPath,
projectName,
},
{
clearHistory,
undo,
setAutoApply,
reindex,
},
{ autoApply },
)
const handleExit = useCallback((): void => {
onExit?.()
exit()
}, [exit, onExit])
const handleInterrupt = useCallback((): void => {
if (status === "thinking" || status === "tool_call") {
abort()
}
}, [status, abort])
const handleUndo = useCallback((): void => {
void undo()
}, [undo])
useHotkeys(
{
onInterrupt: handleInterrupt,
onExit: handleExit,
onUndo: handleUndo,
},
{ enabled: !isLoading },
)
useEffect(() => {
if (!session) {
return
}
const interval = setInterval(() => {
setSessionTime(session.getSessionDurationFormatted())
}, 60_000)
setSessionTime(session.getSessionDurationFormatted())
return (): void => {
clearInterval(interval)
}
}, [session])
const handleSubmit = useCallback(
(text: string): void => {
if (isCommand(text)) {
void executeCommand(text).then((result) => {
setCommandResult(result)
// Auto-clear command result after 5 seconds
setTimeout(() => {
setCommandResult(null)
}, 5000)
})
return
}
void sendMessage(text)
},
[sendMessage, isCommand, executeCommand],
)
if (isLoading) {
return <LoadingScreen />
}
if (error) {
return <ErrorScreen error={error} />
}
const isInputDisabled = status === "thinking" || status === "tool_call"
return (
<Box flexDirection="column" height="100%">
<StatusBar
contextUsage={session?.context.tokenUsage ?? 0}
projectName={projectName}
branch={branch}
sessionTime={sessionTime}
status={status}
/>
<Chat messages={messages} isThinking={status === "thinking"} />
{commandResult && (
<Box
borderStyle="round"
borderColor={commandResult.success ? "green" : "red"}
paddingX={1}
marginY={1}
>
<Text color={commandResult.success ? "green" : "red"} wrap="wrap">
{commandResult.message}
</Text>
</Box>
)}
<Input
onSubmit={handleSubmit}
history={session?.inputHistory ?? []}
disabled={isInputDisabled}
placeholder={isInputDisabled ? "Processing..." : "Type a message..."}
/>
</Box>
)
}

View File

@@ -0,0 +1,170 @@
/**
* Chat component for TUI.
* Displays message history with tool calls and stats.
*/
import { Box, Text } from "ink"
import type React from "react"
import type { ChatMessage } from "../../domain/value-objects/ChatMessage.js"
import type { ToolCall } from "../../domain/value-objects/ToolCall.js"
export interface ChatProps {
messages: ChatMessage[]
isThinking: boolean
}
function formatTimestamp(timestamp: number): string {
const date = new Date(timestamp)
const hours = String(date.getHours()).padStart(2, "0")
const minutes = String(date.getMinutes()).padStart(2, "0")
return `${hours}:${minutes}`
}
function formatStats(stats: ChatMessage["stats"]): string {
if (!stats) {
return ""
}
const time = (stats.timeMs / 1000).toFixed(1)
const tokens = stats.tokens.toLocaleString()
const tools = stats.toolCalls
const parts = [`${time}s`, `${tokens} tokens`]
if (tools > 0) {
parts.push(`${String(tools)} tool${tools > 1 ? "s" : ""}`)
}
return parts.join(" | ")
}
function formatToolCall(call: ToolCall): string {
const params = Object.entries(call.params)
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
.join(" ")
return `[${call.name} ${params}]`
}
function UserMessage({ message }: { message: ChatMessage }): React.JSX.Element {
return (
<Box flexDirection="column" marginBottom={1}>
<Box gap={1}>
<Text color="green" bold>
You
</Text>
<Text color="gray" dimColor>
{formatTimestamp(message.timestamp)}
</Text>
</Box>
<Box marginLeft={2}>
<Text>{message.content}</Text>
</Box>
</Box>
)
}
function AssistantMessage({ message }: { message: ChatMessage }): React.JSX.Element {
const stats = formatStats(message.stats)
return (
<Box flexDirection="column" marginBottom={1}>
<Box gap={1}>
<Text color="cyan" bold>
Assistant
</Text>
<Text color="gray" dimColor>
{formatTimestamp(message.timestamp)}
</Text>
</Box>
{message.toolCalls && message.toolCalls.length > 0 && (
<Box flexDirection="column" marginLeft={2} marginBottom={1}>
{message.toolCalls.map((call) => (
<Text key={call.id} color="yellow">
{formatToolCall(call)}
</Text>
))}
</Box>
)}
{message.content && (
<Box marginLeft={2}>
<Text>{message.content}</Text>
</Box>
)}
{stats && (
<Box marginLeft={2} marginTop={1}>
<Text color="gray" dimColor>
{stats}
</Text>
</Box>
)}
</Box>
)
}
function ToolMessage({ message }: { message: ChatMessage }): React.JSX.Element {
return (
<Box flexDirection="column" marginBottom={1} marginLeft={2}>
{message.toolResults?.map((result) => (
<Box key={result.callId} flexDirection="column">
<Text color={result.success ? "green" : "red"}>
{result.success ? "+" : "x"} {result.callId.slice(0, 8)}
</Text>
</Box>
))}
</Box>
)
}
function SystemMessage({ message }: { message: ChatMessage }): React.JSX.Element {
const isError = message.content.toLowerCase().startsWith("error")
return (
<Box marginBottom={1} marginLeft={2}>
<Text color={isError ? "red" : "gray"} dimColor={!isError}>
{message.content}
</Text>
</Box>
)
}
function MessageComponent({ message }: { message: ChatMessage }): React.JSX.Element {
switch (message.role) {
case "user": {
return <UserMessage message={message} />
}
case "assistant": {
return <AssistantMessage message={message} />
}
case "tool": {
return <ToolMessage message={message} />
}
case "system": {
return <SystemMessage message={message} />
}
default: {
return <></>
}
}
}
function ThinkingIndicator(): React.JSX.Element {
return (
<Box marginBottom={1}>
<Text color="yellow">Thinking...</Text>
</Box>
)
}
export function Chat({ messages, isThinking }: ChatProps): React.JSX.Element {
return (
<Box flexDirection="column" flexGrow={1} paddingX={1}>
{messages.map((message, index) => (
<MessageComponent
key={`${String(message.timestamp)}-${String(index)}`}
message={message}
/>
))}
{isThinking && <ThinkingIndicator />}
</Box>
)
}

View File

@@ -0,0 +1,83 @@
/**
* ConfirmDialog component for TUI.
* Displays a confirmation dialog with [Y] Apply / [N] Cancel / [E] Edit options.
*/
import { Box, Text, useInput } from "ink"
import React, { useState } from "react"
import type { ConfirmChoice } from "../../shared/types/index.js"
import { DiffView, type DiffViewProps } from "./DiffView.js"
export interface ConfirmDialogProps {
message: string
diff?: DiffViewProps
onSelect: (choice: ConfirmChoice) => void
}
function ChoiceButton({
hotkey,
label,
isSelected,
}: {
hotkey: string
label: string
isSelected: boolean
}): React.JSX.Element {
return (
<Box>
<Text color={isSelected ? "cyan" : "gray"}>
[<Text bold>{hotkey}</Text>] {label}
</Text>
</Box>
)
}
export function ConfirmDialog({ message, diff, onSelect }: ConfirmDialogProps): React.JSX.Element {
const [selected, setSelected] = useState<ConfirmChoice | null>(null)
useInput((input, key) => {
const lowerInput = input.toLowerCase()
if (lowerInput === "y") {
setSelected("apply")
onSelect("apply")
} else if (lowerInput === "n") {
setSelected("cancel")
onSelect("cancel")
} else if (lowerInput === "e") {
setSelected("edit")
onSelect("edit")
} else if (key.escape) {
setSelected("cancel")
onSelect("cancel")
}
})
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="yellow"
paddingX={1}
paddingY={1}
>
<Box marginBottom={1}>
<Text color="yellow" bold>
{message}
</Text>
</Box>
{diff && (
<Box marginBottom={1}>
<DiffView {...diff} />
</Box>
)}
<Box gap={2}>
<ChoiceButton hotkey="Y" label="Apply" isSelected={selected === "apply"} />
<ChoiceButton hotkey="N" label="Cancel" isSelected={selected === "cancel"} />
<ChoiceButton hotkey="E" label="Edit" isSelected={selected === "edit"} />
</Box>
</Box>
)
}

View File

@@ -0,0 +1,193 @@
/**
* DiffView component for TUI.
* Displays inline diff with green (added) and red (removed) highlighting.
*/
import { Box, Text } from "ink"
import type React from "react"
export interface DiffViewProps {
filePath: string
oldLines: string[]
newLines: string[]
startLine: number
}
interface DiffLine {
type: "add" | "remove" | "context"
content: string
lineNumber?: number
}
function computeDiff(oldLines: string[], newLines: string[], startLine: number): DiffLine[] {
const result: DiffLine[] = []
let oldIdx = 0
let newIdx = 0
while (oldIdx < oldLines.length || newIdx < newLines.length) {
const oldLine = oldIdx < oldLines.length ? oldLines[oldIdx] : undefined
const newLine = newIdx < newLines.length ? newLines[newIdx] : undefined
if (oldLine === newLine) {
result.push({
type: "context",
content: oldLine ?? "",
lineNumber: startLine + newIdx,
})
oldIdx++
newIdx++
} else {
if (oldLine !== undefined) {
result.push({
type: "remove",
content: oldLine,
})
oldIdx++
}
if (newLine !== undefined) {
result.push({
type: "add",
content: newLine,
lineNumber: startLine + newIdx,
})
newIdx++
}
}
}
return result
}
function getLinePrefix(line: DiffLine): string {
switch (line.type) {
case "add": {
return "+"
}
case "remove": {
return "-"
}
case "context": {
return " "
}
}
}
function getLineColor(line: DiffLine): string {
switch (line.type) {
case "add": {
return "green"
}
case "remove": {
return "red"
}
case "context": {
return "gray"
}
}
}
function formatLineNumber(num: number | undefined, width: number): string {
if (num === undefined) {
return " ".repeat(width)
}
return String(num).padStart(width, " ")
}
function DiffLine({
line,
lineNumberWidth,
}: {
line: DiffLine
lineNumberWidth: number
}): React.JSX.Element {
const prefix = getLinePrefix(line)
const color = getLineColor(line)
const lineNum = formatLineNumber(line.lineNumber, lineNumberWidth)
return (
<Box>
<Text color="gray">{lineNum} </Text>
<Text color={color}>
{prefix} {line.content}
</Text>
</Box>
)
}
function DiffHeader({
filePath,
startLine,
endLine,
}: {
filePath: string
startLine: number
endLine: number
}): React.JSX.Element {
const lineRange =
startLine === endLine
? `line ${String(startLine)}`
: `lines ${String(startLine)}-${String(endLine)}`
return (
<Box>
<Text color="gray"> </Text>
<Text color="cyan">{filePath}</Text>
<Text color="gray"> ({lineRange}) </Text>
</Box>
)
}
function DiffFooter(): React.JSX.Element {
return (
<Box>
<Text color="gray"></Text>
</Box>
)
}
function DiffStats({
additions,
deletions,
}: {
additions: number
deletions: number
}): React.JSX.Element {
return (
<Box gap={1} marginTop={1}>
<Text color="green">+{String(additions)}</Text>
<Text color="red">-{String(deletions)}</Text>
</Box>
)
}
export function DiffView({
filePath,
oldLines,
newLines,
startLine,
}: DiffViewProps): React.JSX.Element {
const diffLines = computeDiff(oldLines, newLines, startLine)
const endLine = startLine + newLines.length - 1
const lineNumberWidth = String(endLine).length
const additions = diffLines.filter((l) => l.type === "add").length
const deletions = diffLines.filter((l) => l.type === "remove").length
return (
<Box flexDirection="column" paddingX={1}>
<DiffHeader filePath={filePath} startLine={startLine} endLine={endLine} />
<Box flexDirection="column" paddingX={1}>
{diffLines.map((line, index) => (
<DiffLine
key={`${line.type}-${String(index)}`}
line={line}
lineNumberWidth={lineNumberWidth}
/>
))}
</Box>
<DiffFooter />
<DiffStats additions={additions} deletions={deletions} />
</Box>
)
}

View File

@@ -0,0 +1,105 @@
/**
* ErrorDialog component for TUI.
* Displays an error with [R] Retry / [S] Skip / [A] Abort options.
*/
import { Box, Text, useInput } from "ink"
import React, { useState } from "react"
import type { ErrorChoice } from "../../shared/types/index.js"
export interface ErrorInfo {
type: string
message: string
recoverable: boolean
}
export interface ErrorDialogProps {
error: ErrorInfo
onChoice: (choice: ErrorChoice) => void
}
function ChoiceButton({
hotkey,
label,
isSelected,
disabled,
}: {
hotkey: string
label: string
isSelected: boolean
disabled?: boolean
}): React.JSX.Element {
if (disabled) {
return (
<Box>
<Text color="gray" dimColor>
[{hotkey}] {label}
</Text>
</Box>
)
}
return (
<Box>
<Text color={isSelected ? "cyan" : "gray"}>
[<Text bold>{hotkey}</Text>] {label}
</Text>
</Box>
)
}
export function ErrorDialog({ error, onChoice }: ErrorDialogProps): React.JSX.Element {
const [selected, setSelected] = useState<ErrorChoice | null>(null)
useInput((input, key) => {
const lowerInput = input.toLowerCase()
if (lowerInput === "r" && error.recoverable) {
setSelected("retry")
onChoice("retry")
} else if (lowerInput === "s" && error.recoverable) {
setSelected("skip")
onChoice("skip")
} else if (lowerInput === "a") {
setSelected("abort")
onChoice("abort")
} else if (key.escape) {
setSelected("abort")
onChoice("abort")
}
})
return (
<Box flexDirection="column" borderStyle="round" borderColor="red" paddingX={1} paddingY={1}>
<Box marginBottom={1}>
<Text color="red" bold>
x {error.type}: {error.message}
</Text>
</Box>
<Box gap={2}>
<ChoiceButton
hotkey="R"
label="Retry"
isSelected={selected === "retry"}
disabled={!error.recoverable}
/>
<ChoiceButton
hotkey="S"
label="Skip"
isSelected={selected === "skip"}
disabled={!error.recoverable}
/>
<ChoiceButton hotkey="A" label="Abort" isSelected={selected === "abort"} />
</Box>
{!error.recoverable && (
<Box marginTop={1}>
<Text color="gray" dimColor>
This error is not recoverable. Press [A] to abort.
</Text>
</Box>
)}
</Box>
)
}

View File

@@ -0,0 +1,99 @@
/**
* Input component for TUI.
* Prompt with history navigation (up/down) and path autocomplete (tab).
*/
import { Box, Text, useInput } from "ink"
import TextInput from "ink-text-input"
import React, { useCallback, useState } from "react"
export interface InputProps {
onSubmit: (text: string) => void
history: string[]
disabled: boolean
placeholder?: string
}
export function Input({
onSubmit,
history,
disabled,
placeholder = "Type a message...",
}: InputProps): React.JSX.Element {
const [value, setValue] = useState("")
const [historyIndex, setHistoryIndex] = useState(-1)
const [savedInput, setSavedInput] = useState("")
const handleChange = useCallback((newValue: string) => {
setValue(newValue)
setHistoryIndex(-1)
}, [])
const handleSubmit = useCallback(
(text: string) => {
if (disabled || !text.trim()) {
return
}
onSubmit(text)
setValue("")
setHistoryIndex(-1)
setSavedInput("")
},
[disabled, onSubmit],
)
useInput(
(input, key) => {
if (disabled) {
return
}
if (key.upArrow && history.length > 0) {
if (historyIndex === -1) {
setSavedInput(value)
}
const newIndex =
historyIndex === -1 ? history.length - 1 : Math.max(0, historyIndex - 1)
setHistoryIndex(newIndex)
setValue(history[newIndex] ?? "")
}
if (key.downArrow) {
if (historyIndex === -1) {
return
}
if (historyIndex >= history.length - 1) {
setHistoryIndex(-1)
setValue(savedInput)
} else {
const newIndex = historyIndex + 1
setHistoryIndex(newIndex)
setValue(history[newIndex] ?? "")
}
}
},
{ isActive: !disabled },
)
return (
<Box borderStyle="single" borderColor={disabled ? "gray" : "cyan"} paddingX={1}>
<Text color={disabled ? "gray" : "green"} bold>
{">"}{" "}
</Text>
{disabled ? (
<Text color="gray" dimColor>
{placeholder}
</Text>
) : (
<TextInput
value={value}
onChange={handleChange}
onSubmit={handleSubmit}
placeholder={placeholder}
/>
)}
</Box>
)
}

View File

@@ -0,0 +1,62 @@
/**
* Progress component for TUI.
* Displays a progress bar: [=====> ] 45% (120/267 files)
*/
import { Box, Text } from "ink"
import type React from "react"
export interface ProgressProps {
current: number
total: number
label: string
width?: number
}
function calculatePercentage(current: number, total: number): number {
if (total === 0) {
return 0
}
return Math.min(100, Math.round((current / total) * 100))
}
function createProgressBar(percentage: number, width: number): { filled: string; empty: string } {
const filledWidth = Math.round((percentage / 100) * width)
const emptyWidth = width - filledWidth
const filled = "=".repeat(Math.max(0, filledWidth - 1)) + (filledWidth > 0 ? ">" : "")
const empty = " ".repeat(Math.max(0, emptyWidth))
return { filled, empty }
}
function getProgressColor(percentage: number): string {
if (percentage >= 100) {
return "green"
}
if (percentage >= 50) {
return "yellow"
}
return "cyan"
}
export function Progress({ current, total, label, width = 30 }: ProgressProps): React.JSX.Element {
const percentage = calculatePercentage(current, total)
const { filled, empty } = createProgressBar(percentage, width)
const color = getProgressColor(percentage)
return (
<Box gap={1}>
<Text color="gray">[</Text>
<Text color={color}>{filled}</Text>
<Text color="gray">{empty}</Text>
<Text color="gray">]</Text>
<Text color={color} bold>
{String(percentage)}%
</Text>
<Text color="gray">
({String(current)}/{String(total)} {label})
</Text>
</Box>
)
}

View File

@@ -0,0 +1,81 @@
/**
* StatusBar component for TUI.
* Displays: [ipuaro] [ctx: 12%] [project: myapp] [main] [47m] status
*/
import { Box, Text } from "ink"
import type React from "react"
import type { BranchInfo, TuiStatus } from "../types.js"
export interface StatusBarProps {
contextUsage: number
projectName: string
branch: BranchInfo
sessionTime: string
status: TuiStatus
}
function getStatusIndicator(status: TuiStatus): { text: string; color: string } {
switch (status) {
case "ready": {
return { text: "ready", color: "green" }
}
case "thinking": {
return { text: "thinking...", color: "yellow" }
}
case "tool_call": {
return { text: "executing...", color: "cyan" }
}
case "awaiting_confirmation": {
return { text: "confirm?", color: "magenta" }
}
case "error": {
return { text: "error", color: "red" }
}
default: {
return { text: "ready", color: "green" }
}
}
}
function formatContextUsage(usage: number): string {
return `${String(Math.round(usage * 100))}%`
}
export function StatusBar({
contextUsage,
projectName,
branch,
sessionTime,
status,
}: StatusBarProps): React.JSX.Element {
const statusIndicator = getStatusIndicator(status)
const branchDisplay = branch.isDetached ? `HEAD@${branch.name.slice(0, 7)}` : branch.name
return (
<Box borderStyle="single" borderColor="gray" paddingX={1} justifyContent="space-between">
<Box gap={1}>
<Text color="cyan" bold>
[ipuaro]
</Text>
<Text color="gray">
[ctx:{" "}
<Text color={contextUsage > 0.8 ? "red" : "white"}>
{formatContextUsage(contextUsage)}
</Text>
]
</Text>
<Text color="gray">
[<Text color="blue">{projectName}</Text>]
</Text>
<Text color="gray">
[<Text color="green">{branchDisplay}</Text>]
</Text>
<Text color="gray">
[<Text color="white">{sessionTime}</Text>]
</Text>
</Box>
<Text color={statusIndicator.color}>{statusIndicator.text}</Text>
</Box>
)
}

View File

@@ -0,0 +1,11 @@
/**
* TUI components.
*/
export { StatusBar, type StatusBarProps } from "./StatusBar.js"
export { Chat, type ChatProps } from "./Chat.js"
export { Input, type InputProps } from "./Input.js"
export { DiffView, type DiffViewProps } from "./DiffView.js"
export { ConfirmDialog, type ConfirmDialogProps } from "./ConfirmDialog.js"
export { ErrorDialog, type ErrorDialogProps, type ErrorInfo } from "./ErrorDialog.js"
export { Progress, type ProgressProps } from "./Progress.js"

View File

@@ -0,0 +1,21 @@
/**
* TUI hooks.
*/
export {
useSession,
type UseSessionDependencies,
type UseSessionOptions,
type UseSessionReturn,
} from "./useSession.js"
export { useHotkeys, type HotkeyHandlers, type UseHotkeysOptions } from "./useHotkeys.js"
export {
useCommands,
parseCommand,
type UseCommandsDependencies,
type UseCommandsActions,
type UseCommandsOptions,
type UseCommandsReturn,
type CommandResult,
type CommandDefinition,
} from "./useCommands.js"

View File

@@ -0,0 +1,444 @@
/**
* useCommands hook for TUI.
* Handles slash commands (/help, /clear, /undo, etc.)
*/
import { useCallback, useMemo } from "react"
import type { Session } from "../../domain/entities/Session.js"
import type { ILLMClient } from "../../domain/services/ILLMClient.js"
import type { ISessionStorage } from "../../domain/services/ISessionStorage.js"
import type { IStorage } from "../../domain/services/IStorage.js"
import type { IToolRegistry } from "../../application/interfaces/IToolRegistry.js"
/**
* Command result returned after execution.
*/
export interface CommandResult {
success: boolean
message: string
data?: unknown
}
/**
* Command definition.
*/
export interface CommandDefinition {
name: string
description: string
usage: string
execute: (args: string[]) => Promise<CommandResult>
}
/**
* Dependencies for useCommands hook.
*/
export interface UseCommandsDependencies {
session: Session | null
sessionStorage: ISessionStorage
storage: IStorage
llm: ILLMClient
tools: IToolRegistry
projectRoot: string
projectName: string
}
/**
* Actions provided by the parent component.
*/
export interface UseCommandsActions {
clearHistory: () => void
undo: () => Promise<boolean>
setAutoApply: (value: boolean) => void
reindex: () => Promise<void>
}
/**
* Options for useCommands hook.
*/
export interface UseCommandsOptions {
autoApply: boolean
}
/**
* Return type for useCommands hook.
*/
export interface UseCommandsReturn {
executeCommand: (input: string) => Promise<CommandResult | null>
isCommand: (input: string) => boolean
getCommands: () => CommandDefinition[]
}
/**
* Parses command input into command name and arguments.
*/
export function parseCommand(input: string): { command: string; args: string[] } | null {
const trimmed = input.trim()
if (!trimmed.startsWith("/")) {
return null
}
const parts = trimmed.slice(1).split(/\s+/)
const command = parts[0]?.toLowerCase() ?? ""
const args = parts.slice(1)
return { command, args }
}
// Command factory functions to keep the hook clean and under line limits
function createHelpCommand(map: Map<string, CommandDefinition>): CommandDefinition {
return {
name: "help",
description: "Shows all commands and hotkeys",
usage: "/help",
execute: async (): Promise<CommandResult> => {
const commandList = Array.from(map.values())
.map((cmd) => ` ${cmd.usage.padEnd(25)} ${cmd.description}`)
.join("\n")
const hotkeys = [
" Ctrl+C (1x) Interrupt current operation",
" Ctrl+C (2x) Exit ipuaro",
" Ctrl+D Exit with session save",
" Ctrl+Z Undo last change",
" ↑/↓ Navigate input history",
].join("\n")
const message = ["Available commands:", commandList, "", "Hotkeys:", hotkeys].join("\n")
return Promise.resolve({ success: true, message })
},
}
}
function createClearCommand(actions: UseCommandsActions): CommandDefinition {
return {
name: "clear",
description: "Clears chat history (keeps session)",
usage: "/clear",
execute: async (): Promise<CommandResult> => {
actions.clearHistory()
return Promise.resolve({ success: true, message: "Chat history cleared." })
},
}
}
function createUndoCommand(
deps: UseCommandsDependencies,
actions: UseCommandsActions,
): CommandDefinition {
return {
name: "undo",
description: "Reverts last file change",
usage: "/undo",
execute: async (): Promise<CommandResult> => {
if (!deps.session) {
return { success: false, message: "No active session." }
}
const undoStack = deps.session.undoStack
if (undoStack.length === 0) {
return { success: false, message: "Nothing to undo." }
}
const result = await actions.undo()
if (result) {
return { success: true, message: "Last change reverted." }
}
return { success: false, message: "Failed to undo. File may have been modified." }
},
}
}
function createSessionsCommand(deps: UseCommandsDependencies): CommandDefinition {
return {
name: "sessions",
description: "Manage sessions (list, load <id>, delete <id>)",
usage: "/sessions [list|load|delete] [id]",
execute: async (args: string[]): Promise<CommandResult> => {
const subCommand = args[0]?.toLowerCase() ?? "list"
if (subCommand === "list") {
return handleSessionsList(deps)
}
if (subCommand === "load") {
return handleSessionsLoad(deps, args[1])
}
if (subCommand === "delete") {
return handleSessionsDelete(deps, args[1])
}
return { success: false, message: "Usage: /sessions [list|load|delete] [id]" }
},
}
}
async function handleSessionsList(deps: UseCommandsDependencies): Promise<CommandResult> {
const sessions = await deps.sessionStorage.listSessions(deps.projectName)
if (sessions.length === 0) {
return { success: true, message: "No sessions found." }
}
const currentId = deps.session?.id
const sessionList = sessions
.map((s) => {
const current = s.id === currentId ? " (current)" : ""
const date = new Date(s.createdAt).toLocaleString()
return ` ${s.id.slice(0, 8)}${current} - ${date} - ${String(s.messageCount)} messages`
})
.join("\n")
return {
success: true,
message: `Sessions for ${deps.projectName}:\n${sessionList}`,
data: sessions,
}
}
async function handleSessionsLoad(
deps: UseCommandsDependencies,
sessionId: string | undefined,
): Promise<CommandResult> {
if (!sessionId) {
return { success: false, message: "Usage: /sessions load <id>" }
}
const exists = await deps.sessionStorage.sessionExists(sessionId)
if (!exists) {
return { success: false, message: `Session ${sessionId} not found.` }
}
return {
success: true,
message: `To load session ${sessionId}, restart ipuaro with --session ${sessionId}`,
data: { sessionId },
}
}
async function handleSessionsDelete(
deps: UseCommandsDependencies,
sessionId: string | undefined,
): Promise<CommandResult> {
if (!sessionId) {
return { success: false, message: "Usage: /sessions delete <id>" }
}
if (deps.session?.id === sessionId) {
return { success: false, message: "Cannot delete current session." }
}
const exists = await deps.sessionStorage.sessionExists(sessionId)
if (!exists) {
return { success: false, message: `Session ${sessionId} not found.` }
}
await deps.sessionStorage.deleteSession(sessionId)
return { success: true, message: `Session ${sessionId} deleted.` }
}
function createStatusCommand(
deps: UseCommandsDependencies,
options: UseCommandsOptions,
): CommandDefinition {
return {
name: "status",
description: "Shows system and session status",
usage: "/status",
execute: async (): Promise<CommandResult> => {
const llmAvailable = await deps.llm.isAvailable()
const llmStatus = llmAvailable ? "connected" : "unavailable"
const contextUsage = deps.session?.context.tokenUsage ?? 0
const contextPercent = Math.round(contextUsage * 100)
const sessionStats = deps.session?.stats ?? {
totalTokens: 0,
totalTime: 0,
toolCalls: 0,
editsApplied: 0,
editsRejected: 0,
}
const undoCount = deps.session?.undoStack.length ?? 0
const message = [
"System Status:",
` LLM: ${llmStatus}`,
` Context: ${String(contextPercent)}% used`,
` Auto-apply: ${options.autoApply ? "on" : "off"}`,
"",
"Session Stats:",
` Tokens: ${sessionStats.totalTokens.toLocaleString()}`,
` Tool calls: ${String(sessionStats.toolCalls)}`,
` Edits: ${String(sessionStats.editsApplied)} applied, ${String(sessionStats.editsRejected)} rejected`,
` Undo stack: ${String(undoCount)} entries`,
"",
"Project:",
` Name: ${deps.projectName}`,
` Root: ${deps.projectRoot}`,
].join("\n")
return { success: true, message }
},
}
}
function createReindexCommand(actions: UseCommandsActions): CommandDefinition {
return {
name: "reindex",
description: "Forces full project reindexation",
usage: "/reindex",
execute: async (): Promise<CommandResult> => {
try {
await actions.reindex()
return { success: true, message: "Project reindexed successfully." }
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err)
return { success: false, message: `Reindex failed: ${errorMessage}` }
}
},
}
}
function createEvalCommand(deps: UseCommandsDependencies): CommandDefinition {
return {
name: "eval",
description: "LLM self-check for hallucinations",
usage: "/eval",
execute: async (): Promise<CommandResult> => {
if (!deps.session || deps.session.history.length === 0) {
return { success: false, message: "No conversation to evaluate." }
}
const lastAssistantMessage = [...deps.session.history]
.reverse()
.find((m) => m.role === "assistant")
if (!lastAssistantMessage) {
return { success: false, message: "No assistant response to evaluate." }
}
const evalPrompt = [
"Review your last response for potential issues:",
"1. Are there any factual errors or hallucinations?",
"2. Did you reference files or code that might not exist?",
"3. Are there any assumptions that should be verified?",
"",
"Last response to evaluate:",
lastAssistantMessage.content.slice(0, 2000),
].join("\n")
try {
const response = await deps.llm.chat([
{ role: "user", content: evalPrompt, timestamp: Date.now() },
])
return {
success: true,
message: `Self-evaluation:\n${response.content}`,
data: { evaluated: lastAssistantMessage.content.slice(0, 100) },
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err)
return { success: false, message: `Evaluation failed: ${errorMessage}` }
}
},
}
}
function createAutoApplyCommand(
actions: UseCommandsActions,
options: UseCommandsOptions,
): CommandDefinition {
return {
name: "auto-apply",
description: "Toggle auto-apply mode (on/off)",
usage: "/auto-apply [on|off]",
execute: async (args: string[]): Promise<CommandResult> => {
const arg = args[0]?.toLowerCase()
if (arg === "on") {
actions.setAutoApply(true)
return Promise.resolve({ success: true, message: "Auto-apply enabled." })
}
if (arg === "off") {
actions.setAutoApply(false)
return Promise.resolve({ success: true, message: "Auto-apply disabled." })
}
if (!arg) {
const current = options.autoApply ? "on" : "off"
return Promise.resolve({
success: true,
message: `Auto-apply is currently: ${current}`,
})
}
return Promise.resolve({ success: false, message: "Usage: /auto-apply [on|off]" })
},
}
}
/**
* Hook for handling slash commands in TUI.
*/
export function useCommands(
deps: UseCommandsDependencies,
actions: UseCommandsActions,
options: UseCommandsOptions,
): UseCommandsReturn {
const commands = useMemo((): Map<string, CommandDefinition> => {
const map = new Map<string, CommandDefinition>()
// Register all commands
const helpCmd = createHelpCommand(map)
map.set("help", helpCmd)
map.set("clear", createClearCommand(actions))
map.set("undo", createUndoCommand(deps, actions))
map.set("sessions", createSessionsCommand(deps))
map.set("status", createStatusCommand(deps, options))
map.set("reindex", createReindexCommand(actions))
map.set("eval", createEvalCommand(deps))
map.set("auto-apply", createAutoApplyCommand(actions, options))
return map
}, [deps, actions, options])
const isCommand = useCallback((input: string): boolean => {
return input.trim().startsWith("/")
}, [])
const executeCommand = useCallback(
async (input: string): Promise<CommandResult | null> => {
const parsed = parseCommand(input)
if (!parsed) {
return null
}
const command = commands.get(parsed.command)
if (!command) {
const available = Array.from(commands.keys()).join(", ")
return {
success: false,
message: `Unknown command: /${parsed.command}\nAvailable: ${available}`,
}
}
return command.execute(parsed.args)
},
[commands],
)
const getCommands = useCallback((): CommandDefinition[] => {
return Array.from(commands.values())
}, [commands])
return {
executeCommand,
isCommand,
getCommands,
}
}

View File

@@ -0,0 +1,59 @@
/**
* useHotkeys hook for TUI.
* Handles global keyboard shortcuts.
*/
import { useInput } from "ink"
import { useCallback, useRef } from "react"
export interface HotkeyHandlers {
onInterrupt?: () => void
onExit?: () => void
onUndo?: () => void
}
export interface UseHotkeysOptions {
enabled?: boolean
}
export function useHotkeys(handlers: HotkeyHandlers, options: UseHotkeysOptions = {}): void {
const { enabled = true } = options
const interruptCount = useRef(0)
const interruptTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const resetInterruptCount = useCallback((): void => {
interruptCount.current = 0
if (interruptTimer.current) {
clearTimeout(interruptTimer.current)
interruptTimer.current = null
}
}, [])
useInput(
(_input, key) => {
if (key.ctrl && _input === "c") {
interruptCount.current++
if (interruptCount.current === 1) {
handlers.onInterrupt?.()
interruptTimer.current = setTimeout(() => {
resetInterruptCount()
}, 1000)
} else if (interruptCount.current >= 2) {
resetInterruptCount()
handlers.onExit?.()
}
}
if (key.ctrl && _input === "d") {
handlers.onExit?.()
}
if (key.ctrl && _input === "z") {
handlers.onUndo?.()
}
},
{ isActive: enabled },
)
}

View File

@@ -0,0 +1,205 @@
/**
* useSession hook for TUI.
* Manages session state and message handling.
*/
import { useCallback, useEffect, useRef, useState } from "react"
import type { Session } from "../../domain/entities/Session.js"
import type { ILLMClient } from "../../domain/services/ILLMClient.js"
import type { ISessionStorage } from "../../domain/services/ISessionStorage.js"
import type { IStorage } from "../../domain/services/IStorage.js"
import type { DiffInfo } from "../../domain/services/ITool.js"
import type { ChatMessage } from "../../domain/value-objects/ChatMessage.js"
import type { ErrorOption } from "../../shared/errors/IpuaroError.js"
import type { IToolRegistry } from "../../application/interfaces/IToolRegistry.js"
import {
HandleMessage,
type HandleMessageStatus,
} from "../../application/use-cases/HandleMessage.js"
import { StartSession } from "../../application/use-cases/StartSession.js"
import { UndoChange } from "../../application/use-cases/UndoChange.js"
import type { ProjectStructure } from "../../infrastructure/llm/prompts.js"
import type { TuiStatus } from "../types.js"
export interface UseSessionDependencies {
storage: IStorage
sessionStorage: ISessionStorage
llm: ILLMClient
tools: IToolRegistry
projectRoot: string
projectName: string
projectStructure?: ProjectStructure
}
export interface UseSessionOptions {
autoApply?: boolean
onConfirmation?: (message: string, diff?: DiffInfo) => Promise<boolean>
onError?: (error: Error) => Promise<ErrorOption>
}
export interface UseSessionReturn {
session: Session | null
messages: ChatMessage[]
status: TuiStatus
isLoading: boolean
error: Error | null
sendMessage: (message: string) => Promise<void>
undo: () => Promise<boolean>
clearHistory: () => void
abort: () => void
}
interface SessionRefs {
session: Session | null
handleMessage: HandleMessage | null
undoChange: UndoChange | null
}
type SetStatus = React.Dispatch<React.SetStateAction<TuiStatus>>
type SetMessages = React.Dispatch<React.SetStateAction<ChatMessage[]>>
interface StateSetters {
setMessages: SetMessages
setStatus: SetStatus
forceUpdate: () => void
}
function createEventHandlers(
setters: StateSetters,
options: UseSessionOptions,
): Parameters<HandleMessage["setEvents"]>[0] {
return {
onMessage: (msg) => {
setters.setMessages((prev) => [...prev, msg])
},
onToolCall: () => {
setters.setStatus("tool_call")
},
onToolResult: () => {
setters.setStatus("thinking")
},
onConfirmation: options.onConfirmation,
onError: options.onError,
onStatusChange: (s: HandleMessageStatus) => {
setters.setStatus(s)
},
onUndoEntry: () => {
setters.forceUpdate()
},
}
}
async function initializeSession(
deps: UseSessionDependencies,
options: UseSessionOptions,
refs: React.MutableRefObject<SessionRefs>,
setters: StateSetters,
): Promise<void> {
const startSession = new StartSession(deps.sessionStorage)
const result = await startSession.execute(deps.projectName)
refs.current.session = result.session
setters.setMessages([...result.session.history])
const handleMessage = new HandleMessage(
deps.storage,
deps.sessionStorage,
deps.llm,
deps.tools,
deps.projectRoot,
)
if (deps.projectStructure) {
handleMessage.setProjectStructure(deps.projectStructure)
}
handleMessage.setOptions({ autoApply: options.autoApply })
handleMessage.setEvents(createEventHandlers(setters, options))
refs.current.handleMessage = handleMessage
refs.current.undoChange = new UndoChange(deps.sessionStorage, deps.storage)
setters.forceUpdate()
}
export function useSession(
deps: UseSessionDependencies,
options: UseSessionOptions = {},
): UseSessionReturn {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [status, setStatus] = useState<TuiStatus>("ready")
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const [, setTrigger] = useState(0)
const refs = useRef<SessionRefs>({ session: null, handleMessage: null, undoChange: null })
const forceUpdate = useCallback(() => {
setTrigger((v) => v + 1)
}, [])
useEffect(() => {
setIsLoading(true)
const setters: StateSetters = { setMessages, setStatus, forceUpdate }
initializeSession(deps, options, refs, setters)
.then(() => {
setError(null)
})
.catch((err: unknown) => {
setError(err instanceof Error ? err : new Error(String(err)))
})
.finally(() => {
setIsLoading(false)
})
}, [deps.projectName, forceUpdate])
const sendMessage = useCallback(async (message: string): Promise<void> => {
const { session, handleMessage } = refs.current
if (!session || !handleMessage) {
return
}
try {
setStatus("thinking")
await handleMessage.execute(session, message)
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)))
setStatus("error")
}
}, [])
const undo = useCallback(async (): Promise<boolean> => {
const { session, undoChange } = refs.current
if (!session || !undoChange) {
return false
}
try {
const result = await undoChange.execute(session)
if (result.success) {
forceUpdate()
return true
}
return false
} catch {
return false
}
}, [forceUpdate])
const clearHistory = useCallback(() => {
if (!refs.current.session) {
return
}
refs.current.session.clearHistory()
setMessages([])
forceUpdate()
}, [forceUpdate])
const abort = useCallback(() => {
refs.current.handleMessage?.abort()
setStatus("ready")
}, [])
return {
session: refs.current.session,
messages,
status,
isLoading,
error,
sendMessage,
undo,
clearHistory,
abort,
}
}

View File

@@ -0,0 +1,8 @@
/**
* TUI module - Terminal User Interface.
*/
export { App, type AppDependencies, type ExtendedAppProps } from "./App.js"
export * from "./components/index.js"
export * from "./hooks/index.js"
export * from "./types.js"

View File

@@ -0,0 +1,38 @@
/**
* TUI types and interfaces.
*/
import type { HandleMessageStatus } from "../application/use-cases/HandleMessage.js"
/**
* TUI status - maps to HandleMessageStatus.
*/
export type TuiStatus = HandleMessageStatus
/**
* Git branch information.
*/
export interface BranchInfo {
name: string
isDetached: boolean
}
/**
* Props for the main App component.
*/
export interface AppProps {
projectPath: string
autoApply?: boolean
model?: string
}
/**
* Status bar display data.
*/
export interface StatusBarData {
contextUsage: number
projectName: string
branch: BranchInfo
sessionTime: string
status: TuiStatus
}

View File

@@ -0,0 +1,248 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { ContextManager } from "../../../../src/application/use-cases/ContextManager.js"
import { Session } from "../../../../src/domain/entities/Session.js"
import type { ILLMClient, LLMResponse } from "../../../../src/domain/services/ILLMClient.js"
import {
createUserMessage,
createAssistantMessage,
} from "../../../../src/domain/value-objects/ChatMessage.js"
describe("ContextManager", () => {
let manager: ContextManager
const CONTEXT_SIZE = 128_000
beforeEach(() => {
manager = new ContextManager(CONTEXT_SIZE)
})
describe("addToContext", () => {
it("should add file to context", () => {
manager.addToContext("test.ts", 100)
expect(manager.getFilesInContext()).toContain("test.ts")
expect(manager.getTokenCount()).toBe(100)
})
it("should update token count when same file added", () => {
manager.addToContext("test.ts", 100)
manager.addToContext("test.ts", 200)
expect(manager.getFilesInContext()).toHaveLength(1)
expect(manager.getTokenCount()).toBe(200)
})
it("should accumulate tokens for different files", () => {
manager.addToContext("a.ts", 100)
manager.addToContext("b.ts", 200)
expect(manager.getFilesInContext()).toHaveLength(2)
expect(manager.getTokenCount()).toBe(300)
})
})
describe("removeFromContext", () => {
it("should remove file from context", () => {
manager.addToContext("test.ts", 100)
manager.removeFromContext("test.ts")
expect(manager.getFilesInContext()).not.toContain("test.ts")
expect(manager.getTokenCount()).toBe(0)
})
it("should handle removing non-existent file", () => {
manager.removeFromContext("non-existent.ts")
expect(manager.getTokenCount()).toBe(0)
})
})
describe("getUsage", () => {
it("should return 0 for empty context", () => {
expect(manager.getUsage()).toBe(0)
})
it("should calculate usage ratio correctly", () => {
manager.addToContext("test.ts", CONTEXT_SIZE / 2)
expect(manager.getUsage()).toBe(0.5)
})
})
describe("getAvailableTokens", () => {
it("should return full context when empty", () => {
expect(manager.getAvailableTokens()).toBe(CONTEXT_SIZE)
})
it("should calculate available tokens correctly", () => {
manager.addToContext("test.ts", 1000)
expect(manager.getAvailableTokens()).toBe(CONTEXT_SIZE - 1000)
})
})
describe("needsCompression", () => {
it("should return false when under threshold", () => {
manager.addToContext("test.ts", CONTEXT_SIZE * 0.5)
expect(manager.needsCompression()).toBe(false)
})
it("should return true when over threshold", () => {
manager.addToContext("test.ts", CONTEXT_SIZE * 0.85)
expect(manager.needsCompression()).toBe(true)
})
it("should return false at exactly threshold", () => {
manager.addToContext("test.ts", CONTEXT_SIZE * 0.8)
expect(manager.needsCompression()).toBe(false)
})
})
describe("addTokens", () => {
it("should add tokens to current count", () => {
manager.addTokens(500)
expect(manager.getTokenCount()).toBe(500)
})
it("should accumulate tokens", () => {
manager.addTokens(100)
manager.addTokens(200)
expect(manager.getTokenCount()).toBe(300)
})
})
describe("syncFromSession", () => {
it("should sync files from session context", () => {
const session = new Session("test", "project")
session.context.filesInContext = ["a.ts", "b.ts"]
session.context.tokenUsage = 0.5
manager.syncFromSession(session)
expect(manager.getFilesInContext()).toContain("a.ts")
expect(manager.getFilesInContext()).toContain("b.ts")
expect(manager.getTokenCount()).toBe(Math.floor(0.5 * CONTEXT_SIZE))
})
it("should clear previous state on sync", () => {
manager.addToContext("old.ts", 1000)
const session = new Session("test", "project")
session.context.filesInContext = ["new.ts"]
session.context.tokenUsage = 0.1
manager.syncFromSession(session)
expect(manager.getFilesInContext()).not.toContain("old.ts")
expect(manager.getFilesInContext()).toContain("new.ts")
})
})
describe("updateSession", () => {
it("should update session with current context state", () => {
const session = new Session("test", "project")
manager.addToContext("test.ts", 1000)
manager.updateSession(session)
expect(session.context.filesInContext).toContain("test.ts")
expect(session.context.tokenUsage).toBeCloseTo(1000 / CONTEXT_SIZE)
})
it("should set needsCompression flag", () => {
const session = new Session("test", "project")
manager.addToContext("large.ts", CONTEXT_SIZE * 0.9)
manager.updateSession(session)
expect(session.context.needsCompression).toBe(true)
})
})
describe("compress", () => {
let mockLLM: ILLMClient
let session: Session
beforeEach(() => {
mockLLM = {
chat: vi.fn().mockResolvedValue({
content: "Summary of previous conversation",
toolCalls: [],
tokens: 50,
timeMs: 100,
truncated: false,
stopReason: "end",
} as LLMResponse),
countTokens: vi.fn().mockResolvedValue(10),
isAvailable: vi.fn().mockResolvedValue(true),
getModelName: vi.fn().mockReturnValue("test-model"),
getContextWindowSize: vi.fn().mockReturnValue(CONTEXT_SIZE),
pullModel: vi.fn().mockResolvedValue(undefined),
abort: vi.fn(),
}
session = new Session("test", "project")
})
it("should not compress when history is short", async () => {
for (let i = 0; i < 5; i++) {
session.addMessage(createUserMessage(`Message ${String(i)}`))
}
const result = await manager.compress(session, mockLLM)
expect(result.compressed).toBe(false)
expect(result.removedMessages).toBe(0)
})
it("should compress when history is long enough", async () => {
for (let i = 0; i < 15; i++) {
session.addMessage(createUserMessage(`Message ${String(i)}`))
session.addMessage(createAssistantMessage(`Response ${String(i)}`))
}
manager.addToContext("test.ts", 10000)
const result = await manager.compress(session, mockLLM)
expect(result.compressed).toBe(true)
expect(result.removedMessages).toBeGreaterThan(0)
expect(result.summary).toBeDefined()
})
it("should keep recent messages after compression", async () => {
for (let i = 0; i < 15; i++) {
session.addMessage(createUserMessage(`Message ${String(i)}`))
}
await manager.compress(session, mockLLM)
expect(session.history.length).toBeLessThan(15)
expect(session.history[session.history.length - 1].content).toContain("Message 14")
})
it("should add summary as system message", async () => {
for (let i = 0; i < 15; i++) {
session.addMessage(createUserMessage(`Message ${String(i)}`))
}
await manager.compress(session, mockLLM)
expect(session.history[0].role).toBe("system")
expect(session.history[0].content).toContain("Summary")
})
})
describe("createInitialState", () => {
it("should create empty initial state", () => {
const state = ContextManager.createInitialState()
expect(state.filesInContext).toEqual([])
expect(state.tokenUsage).toBe(0)
expect(state.needsCompression).toBe(false)
})
})
})

View File

@@ -0,0 +1,421 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { HandleMessage } from "../../../../src/application/use-cases/HandleMessage.js"
import type { IStorage } from "../../../../src/domain/services/IStorage.js"
import type { ISessionStorage } from "../../../../src/domain/services/ISessionStorage.js"
import type { ILLMClient, LLMResponse } from "../../../../src/domain/services/ILLMClient.js"
import type { IToolRegistry } from "../../../../src/application/interfaces/IToolRegistry.js"
import type { ITool, ToolContext } from "../../../../src/domain/services/ITool.js"
import { Session } from "../../../../src/domain/entities/Session.js"
import { createSuccessResult } from "../../../../src/domain/value-objects/ToolResult.js"
describe("HandleMessage", () => {
let useCase: HandleMessage
let mockStorage: IStorage
let mockSessionStorage: ISessionStorage
let mockLLM: ILLMClient
let mockTools: IToolRegistry
let session: Session
const createMockLLMResponse = (content: string, toolCalls = false): LLMResponse => ({
content,
toolCalls: [],
tokens: 100,
timeMs: 50,
truncated: false,
stopReason: toolCalls ? "tool_use" : "end",
})
beforeEach(() => {
mockStorage = {
getFile: vi.fn().mockResolvedValue(null),
setFile: vi.fn().mockResolvedValue(undefined),
deleteFile: vi.fn().mockResolvedValue(undefined),
getAllFiles: vi.fn().mockResolvedValue(new Map()),
getFileCount: vi.fn().mockResolvedValue(0),
getAST: vi.fn().mockResolvedValue(null),
setAST: vi.fn().mockResolvedValue(undefined),
deleteAST: vi.fn().mockResolvedValue(undefined),
getAllASTs: vi.fn().mockResolvedValue(new Map()),
getMeta: vi.fn().mockResolvedValue(null),
setMeta: vi.fn().mockResolvedValue(undefined),
deleteMeta: vi.fn().mockResolvedValue(undefined),
getAllMetas: vi.fn().mockResolvedValue(new Map()),
getSymbolIndex: vi.fn().mockResolvedValue(new Map()),
setSymbolIndex: vi.fn().mockResolvedValue(undefined),
getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }),
setDepsGraph: vi.fn().mockResolvedValue(undefined),
getProjectConfig: vi.fn().mockResolvedValue(null),
setProjectConfig: vi.fn().mockResolvedValue(undefined),
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn().mockReturnValue(true),
clear: vi.fn().mockResolvedValue(undefined),
}
mockSessionStorage = {
saveSession: vi.fn().mockResolvedValue(undefined),
loadSession: vi.fn().mockResolvedValue(null),
deleteSession: vi.fn().mockResolvedValue(undefined),
listSessions: vi.fn().mockResolvedValue([]),
getLatestSession: vi.fn().mockResolvedValue(null),
sessionExists: vi.fn().mockResolvedValue(false),
pushUndoEntry: vi.fn().mockResolvedValue(undefined),
popUndoEntry: vi.fn().mockResolvedValue(null),
getUndoStack: vi.fn().mockResolvedValue([]),
touchSession: vi.fn().mockResolvedValue(undefined),
clearAllSessions: vi.fn().mockResolvedValue(undefined),
}
mockLLM = {
chat: vi.fn().mockResolvedValue(createMockLLMResponse("Hello!")),
countTokens: vi.fn().mockResolvedValue(10),
isAvailable: vi.fn().mockResolvedValue(true),
getModelName: vi.fn().mockReturnValue("test-model"),
getContextWindowSize: vi.fn().mockReturnValue(128_000),
pullModel: vi.fn().mockResolvedValue(undefined),
abort: vi.fn(),
}
mockTools = {
register: vi.fn(),
get: vi.fn().mockReturnValue(undefined),
getAll: vi.fn().mockReturnValue([]),
getByCategory: vi.fn().mockReturnValue([]),
has: vi.fn().mockReturnValue(false),
execute: vi.fn(),
getToolDefinitions: vi.fn().mockReturnValue([]),
}
session = new Session("test-session", "test-project")
useCase = new HandleMessage(mockStorage, mockSessionStorage, mockLLM, mockTools, "/project")
})
describe("execute", () => {
it("should add user message to session history", async () => {
await useCase.execute(session, "Hello, assistant!")
expect(session.history.length).toBeGreaterThan(0)
expect(session.history[0].role).toBe("user")
expect(session.history[0].content).toBe("Hello, assistant!")
})
it("should add user input to input history", async () => {
await useCase.execute(session, "Test command")
expect(session.inputHistory).toContain("Test command")
})
it("should save session after user message", async () => {
await useCase.execute(session, "Hello")
expect(mockSessionStorage.saveSession).toHaveBeenCalled()
})
it("should send messages to LLM", async () => {
await useCase.execute(session, "What is 2+2?")
expect(mockLLM.chat).toHaveBeenCalled()
})
it("should add assistant response to history", async () => {
vi.mocked(mockLLM.chat).mockResolvedValue(createMockLLMResponse("The answer is 4!"))
await useCase.execute(session, "What is 2+2?")
const assistantMessages = session.history.filter((m) => m.role === "assistant")
expect(assistantMessages.length).toBeGreaterThan(0)
expect(assistantMessages[0].content).toBe("The answer is 4!")
})
it("should not add empty user messages", async () => {
await useCase.execute(session, " ")
const userMessages = session.history.filter((m) => m.role === "user")
expect(userMessages.length).toBe(0)
})
it("should track token usage in message stats", async () => {
vi.mocked(mockLLM.chat).mockResolvedValue({
content: "Response",
toolCalls: [],
tokens: 150,
timeMs: 200,
truncated: false,
stopReason: "end",
})
await useCase.execute(session, "Hello")
const assistantMessage = session.history.find((m) => m.role === "assistant")
expect(assistantMessage?.stats?.tokens).toBe(150)
expect(assistantMessage?.stats?.timeMs).toBeGreaterThanOrEqual(0)
})
})
describe("tool execution", () => {
const mockTool: ITool = {
name: "get_lines",
description: "Get lines from file",
parameters: [],
requiresConfirmation: false,
category: "read",
validateParams: vi.fn().mockReturnValue(null),
execute: vi.fn().mockResolvedValue(createSuccessResult("test", { lines: [] }, 10)),
}
beforeEach(() => {
vi.mocked(mockTools.get).mockReturnValue(mockTool)
})
it("should execute tools when LLM returns tool calls", async () => {
vi.mocked(mockLLM.chat)
.mockResolvedValueOnce(
createMockLLMResponse(
'<tool_call name="get_lines"><path>test.ts</path></tool_call>',
true,
),
)
.mockResolvedValueOnce(createMockLLMResponse("Done!"))
await useCase.execute(session, "Show me test.ts")
expect(mockTool.execute).toHaveBeenCalled()
})
it("should add tool results to session", async () => {
vi.mocked(mockLLM.chat)
.mockResolvedValueOnce(
createMockLLMResponse(
'<tool_call name="get_lines"><path>test.ts</path></tool_call>',
true,
),
)
.mockResolvedValueOnce(createMockLLMResponse("Done!"))
await useCase.execute(session, "Show me test.ts")
const toolMessages = session.history.filter((m) => m.role === "tool")
expect(toolMessages.length).toBeGreaterThan(0)
})
it("should return error for unknown tools", async () => {
vi.mocked(mockTools.get).mockReturnValue(undefined)
vi.mocked(mockLLM.chat)
.mockResolvedValueOnce(
createMockLLMResponse(
'<tool_call name="unknown_tool"><param>value</param></tool_call>',
true,
),
)
.mockResolvedValueOnce(createMockLLMResponse("Sorry, that didn't work"))
await useCase.execute(session, "Do something")
const toolMessages = session.history.filter((m) => m.role === "tool")
expect(toolMessages[0].content).toContain("Unknown tool")
})
it("should stop after max tool calls exceeded", async () => {
useCase.setOptions({ maxToolCalls: 2 })
vi.mocked(mockLLM.chat).mockResolvedValue(
createMockLLMResponse(
'<tool_call name="get_lines"><path>a.ts</path></tool_call>' +
'<tool_call name="get_lines"><path>b.ts</path></tool_call>' +
'<tool_call name="get_lines"><path>c.ts</path></tool_call>',
true,
),
)
await useCase.execute(session, "Show many files")
const systemMessages = session.history.filter((m) => m.role === "system")
const maxExceeded = systemMessages.some((m) => m.content.includes("Maximum tool calls"))
expect(maxExceeded).toBe(true)
})
})
describe("events", () => {
it("should emit message events", async () => {
const onMessage = vi.fn()
useCase.setEvents({ onMessage })
await useCase.execute(session, "Hello")
expect(onMessage).toHaveBeenCalled()
})
it("should emit status changes", async () => {
const onStatusChange = vi.fn()
useCase.setEvents({ onStatusChange })
await useCase.execute(session, "Hello")
expect(onStatusChange).toHaveBeenCalledWith("thinking")
expect(onStatusChange).toHaveBeenCalledWith("ready")
})
it("should emit tool call events", async () => {
const onToolCall = vi.fn()
useCase.setEvents({ onToolCall })
const mockTool: ITool = {
name: "get_lines",
description: "Test",
parameters: [],
requiresConfirmation: false,
category: "read",
validateParams: vi.fn().mockReturnValue(null),
execute: vi.fn().mockResolvedValue(createSuccessResult("test", {}, 10)),
}
vi.mocked(mockTools.get).mockReturnValue(mockTool)
vi.mocked(mockLLM.chat)
.mockResolvedValueOnce(
createMockLLMResponse(
'<tool_call name="get_lines"><path>test.ts</path></tool_call>',
),
)
.mockResolvedValueOnce(createMockLLMResponse("Done"))
await useCase.execute(session, "Show file")
expect(onToolCall).toHaveBeenCalled()
})
})
describe("confirmation handling", () => {
const mockEditTool: ITool = {
name: "edit_lines",
description: "Edit lines",
parameters: [],
requiresConfirmation: true,
category: "edit",
validateParams: vi.fn().mockReturnValue(null),
execute: vi
.fn()
.mockImplementation(async (_params: Record<string, unknown>, ctx: ToolContext) => {
const confirmed = await ctx.requestConfirmation("Apply edit?", {
filePath: "test.ts",
oldLines: ["old"],
newLines: ["new"],
startLine: 1,
})
if (!confirmed) {
return createSuccessResult("test", { cancelled: true }, 10)
}
return createSuccessResult("test", { applied: true }, 10)
}),
}
beforeEach(() => {
vi.mocked(mockTools.get).mockReturnValue(mockEditTool)
})
it("should auto-apply when autoApply option is true", async () => {
useCase.setOptions({ autoApply: true })
vi.mocked(mockLLM.chat)
.mockResolvedValueOnce(
createMockLLMResponse(
'<tool_call name="edit_lines"><path>test.ts</path></tool_call>',
),
)
.mockResolvedValueOnce(createMockLLMResponse("Done"))
await useCase.execute(session, "Edit file")
expect(mockEditTool.execute).toHaveBeenCalled()
})
it("should ask for confirmation via callback", async () => {
const onConfirmation = vi.fn().mockResolvedValue(true)
useCase.setEvents({ onConfirmation })
vi.mocked(mockLLM.chat)
.mockResolvedValueOnce(
createMockLLMResponse(
'<tool_call name="edit_lines"><path>test.ts</path></tool_call>',
),
)
.mockResolvedValueOnce(createMockLLMResponse("Done"))
await useCase.execute(session, "Edit file")
expect(onConfirmation).toHaveBeenCalled()
})
it("should create undo entry on confirmation", async () => {
const onUndoEntry = vi.fn()
useCase.setEvents({
onConfirmation: vi.fn().mockResolvedValue(true),
onUndoEntry,
})
vi.mocked(mockLLM.chat)
.mockResolvedValueOnce(
createMockLLMResponse(
'<tool_call name="edit_lines"><path>test.ts</path></tool_call>',
),
)
.mockResolvedValueOnce(createMockLLMResponse("Done"))
await useCase.execute(session, "Edit file")
expect(onUndoEntry).toHaveBeenCalled()
expect(mockSessionStorage.pushUndoEntry).toHaveBeenCalled()
})
})
describe("abort", () => {
it("should stop processing when aborted", async () => {
vi.mocked(mockLLM.chat).mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 100))
return createMockLLMResponse("Response")
})
const promise = useCase.execute(session, "Hello")
setTimeout(() => useCase.abort(), 10)
await promise
expect(mockLLM.abort).toHaveBeenCalled()
})
})
describe("error handling", () => {
it("should handle LLM errors gracefully", async () => {
vi.mocked(mockLLM.chat).mockRejectedValue(new Error("LLM unavailable"))
await useCase.execute(session, "Hello")
const systemMessages = session.history.filter((m) => m.role === "system")
expect(systemMessages.some((m) => m.content.includes("Error"))).toBe(true)
})
it("should emit error status on LLM failure", async () => {
const onStatusChange = vi.fn()
useCase.setEvents({ onStatusChange })
vi.mocked(mockLLM.chat).mockRejectedValue(new Error("LLM error"))
await useCase.execute(session, "Hello")
expect(onStatusChange).toHaveBeenCalledWith("error")
})
it("should allow retry on error", async () => {
const onError = vi.fn().mockResolvedValue("retry")
useCase.setEvents({ onError })
vi.mocked(mockLLM.chat)
.mockRejectedValueOnce(new Error("Temporary error"))
.mockResolvedValueOnce(createMockLLMResponse("Success!"))
await useCase.execute(session, "Hello")
expect(onError).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { StartSession } from "../../../../src/application/use-cases/StartSession.js"
import type { ISessionStorage } from "../../../../src/domain/services/ISessionStorage.js"
import { Session } from "../../../../src/domain/entities/Session.js"
describe("StartSession", () => {
let useCase: StartSession
let mockSessionStorage: ISessionStorage
beforeEach(() => {
mockSessionStorage = {
saveSession: vi.fn().mockResolvedValue(undefined),
loadSession: vi.fn().mockResolvedValue(null),
deleteSession: vi.fn().mockResolvedValue(undefined),
listSessions: vi.fn().mockResolvedValue([]),
getLatestSession: vi.fn().mockResolvedValue(null),
sessionExists: vi.fn().mockResolvedValue(false),
pushUndoEntry: vi.fn().mockResolvedValue(undefined),
popUndoEntry: vi.fn().mockResolvedValue(null),
getUndoStack: vi.fn().mockResolvedValue([]),
touchSession: vi.fn().mockResolvedValue(undefined),
clearAllSessions: vi.fn().mockResolvedValue(undefined),
}
useCase = new StartSession(mockSessionStorage)
})
describe("execute", () => {
it("should create new session when no existing session", async () => {
const result = await useCase.execute("test-project")
expect(result.isNew).toBe(true)
expect(result.session.projectName).toBe("test-project")
expect(mockSessionStorage.saveSession).toHaveBeenCalled()
})
it("should return latest session when one exists", async () => {
const existingSession = new Session("existing-id", "test-project")
vi.mocked(mockSessionStorage.getLatestSession).mockResolvedValue(existingSession)
const result = await useCase.execute("test-project")
expect(result.isNew).toBe(false)
expect(result.session.id).toBe("existing-id")
expect(mockSessionStorage.touchSession).toHaveBeenCalledWith("existing-id")
})
it("should load specific session by ID", async () => {
const specificSession = new Session("specific-id", "test-project")
vi.mocked(mockSessionStorage.loadSession).mockResolvedValue(specificSession)
const result = await useCase.execute("test-project", { sessionId: "specific-id" })
expect(result.isNew).toBe(false)
expect(result.session.id).toBe("specific-id")
expect(mockSessionStorage.loadSession).toHaveBeenCalledWith("specific-id")
})
it("should create new session when specified session not found", async () => {
vi.mocked(mockSessionStorage.loadSession).mockResolvedValue(null)
const result = await useCase.execute("test-project", { sessionId: "non-existent" })
expect(result.isNew).toBe(true)
expect(mockSessionStorage.saveSession).toHaveBeenCalled()
})
it("should force new session when forceNew is true", async () => {
const existingSession = new Session("existing-id", "test-project")
vi.mocked(mockSessionStorage.getLatestSession).mockResolvedValue(existingSession)
const result = await useCase.execute("test-project", { forceNew: true })
expect(result.isNew).toBe(true)
expect(result.session.id).not.toBe("existing-id")
expect(mockSessionStorage.saveSession).toHaveBeenCalled()
})
it("should generate unique session IDs", async () => {
const result1 = await useCase.execute("test-project", { forceNew: true })
const result2 = await useCase.execute("test-project", { forceNew: true })
expect(result1.session.id).not.toBe(result2.session.id)
})
it("should set correct project name on new session", async () => {
const result = await useCase.execute("my-special-project")
expect(result.session.projectName).toBe("my-special-project")
})
it("should initialize new session with empty history", async () => {
const result = await useCase.execute("test-project")
expect(result.session.history).toEqual([])
})
it("should initialize new session with empty undo stack", async () => {
const result = await useCase.execute("test-project")
expect(result.session.undoStack).toEqual([])
})
it("should initialize new session with zero stats", async () => {
const result = await useCase.execute("test-project")
expect(result.session.stats.totalTokens).toBe(0)
expect(result.session.stats.toolCalls).toBe(0)
expect(result.session.stats.editsApplied).toBe(0)
})
})
})

View File

@@ -0,0 +1,234 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { promises as fs } from "node:fs"
import { UndoChange } from "../../../../src/application/use-cases/UndoChange.js"
import type { ISessionStorage } from "../../../../src/domain/services/ISessionStorage.js"
import type { IStorage } from "../../../../src/domain/services/IStorage.js"
import { Session } from "../../../../src/domain/entities/Session.js"
import type { UndoEntry } from "../../../../src/domain/value-objects/UndoEntry.js"
vi.mock("node:fs", () => ({
promises: {
readFile: vi.fn(),
writeFile: vi.fn(),
stat: vi.fn(),
},
}))
describe("UndoChange", () => {
let useCase: UndoChange
let mockSessionStorage: ISessionStorage
let mockStorage: IStorage
let session: Session
const createUndoEntry = (overrides: Partial<UndoEntry> = {}): UndoEntry => ({
id: "undo-1",
timestamp: Date.now(),
filePath: "/project/test.ts",
previousContent: ["const a = 1"],
newContent: ["const a = 2"],
description: "Edit test.ts",
...overrides,
})
beforeEach(() => {
mockSessionStorage = {
saveSession: vi.fn().mockResolvedValue(undefined),
loadSession: vi.fn().mockResolvedValue(null),
deleteSession: vi.fn().mockResolvedValue(undefined),
listSessions: vi.fn().mockResolvedValue([]),
getLatestSession: vi.fn().mockResolvedValue(null),
sessionExists: vi.fn().mockResolvedValue(false),
pushUndoEntry: vi.fn().mockResolvedValue(undefined),
popUndoEntry: vi.fn().mockResolvedValue(null),
getUndoStack: vi.fn().mockResolvedValue([]),
touchSession: vi.fn().mockResolvedValue(undefined),
clearAllSessions: vi.fn().mockResolvedValue(undefined),
}
mockStorage = {
getFile: vi.fn().mockResolvedValue(null),
setFile: vi.fn().mockResolvedValue(undefined),
deleteFile: vi.fn().mockResolvedValue(undefined),
getAllFiles: vi.fn().mockResolvedValue(new Map()),
getFileCount: vi.fn().mockResolvedValue(0),
getAST: vi.fn().mockResolvedValue(null),
setAST: vi.fn().mockResolvedValue(undefined),
deleteAST: vi.fn().mockResolvedValue(undefined),
getAllASTs: vi.fn().mockResolvedValue(new Map()),
getMeta: vi.fn().mockResolvedValue(null),
setMeta: vi.fn().mockResolvedValue(undefined),
deleteMeta: vi.fn().mockResolvedValue(undefined),
getAllMetas: vi.fn().mockResolvedValue(new Map()),
getSymbolIndex: vi.fn().mockResolvedValue(new Map()),
setSymbolIndex: vi.fn().mockResolvedValue(undefined),
getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }),
setDepsGraph: vi.fn().mockResolvedValue(undefined),
getProjectConfig: vi.fn().mockResolvedValue(null),
setProjectConfig: vi.fn().mockResolvedValue(undefined),
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn().mockReturnValue(true),
clear: vi.fn().mockResolvedValue(undefined),
}
session = new Session("test-session", "test-project")
session.stats.editsApplied = 1
useCase = new UndoChange(mockSessionStorage, mockStorage)
vi.mocked(fs.stat).mockResolvedValue({
size: 100,
mtimeMs: Date.now(),
} as unknown as Awaited<ReturnType<typeof fs.stat>>)
})
afterEach(() => {
vi.clearAllMocks()
})
describe("execute", () => {
it("should return error when no undo entries", async () => {
vi.mocked(mockSessionStorage.popUndoEntry).mockResolvedValue(null)
const result = await useCase.execute(session)
expect(result.success).toBe(false)
expect(result.error).toBe("No changes to undo")
})
it("should restore previous content when file matches", async () => {
const entry = createUndoEntry()
vi.mocked(mockSessionStorage.popUndoEntry).mockResolvedValue(entry)
vi.mocked(fs.readFile).mockResolvedValue("const a = 2")
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
session.addUndoEntry(entry)
const result = await useCase.execute(session)
expect(result.success).toBe(true)
expect(result.entry).toBe(entry)
expect(fs.writeFile).toHaveBeenCalledWith(entry.filePath, "const a = 1", "utf-8")
})
it("should update storage after undo", async () => {
const entry = createUndoEntry()
vi.mocked(mockSessionStorage.popUndoEntry).mockResolvedValue(entry)
vi.mocked(fs.readFile).mockResolvedValue("const a = 2")
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
session.addUndoEntry(entry)
await useCase.execute(session)
expect(mockStorage.setFile).toHaveBeenCalledWith(
entry.filePath,
expect.objectContaining({
lines: entry.previousContent,
}),
)
})
it("should decrement editsApplied counter", async () => {
const entry = createUndoEntry()
vi.mocked(mockSessionStorage.popUndoEntry).mockResolvedValue(entry)
vi.mocked(fs.readFile).mockResolvedValue("const a = 2")
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
session.addUndoEntry(entry)
const initialEdits = session.stats.editsApplied
await useCase.execute(session)
expect(session.stats.editsApplied).toBe(initialEdits - 1)
})
it("should fail when file has been modified externally", async () => {
const entry = createUndoEntry()
vi.mocked(mockSessionStorage.popUndoEntry).mockResolvedValue(entry)
vi.mocked(fs.readFile).mockResolvedValue("const a = 999")
const result = await useCase.execute(session)
expect(result.success).toBe(false)
expect(result.error).toContain("modified since the change")
})
it("should re-push undo entry on conflict", async () => {
const entry = createUndoEntry()
vi.mocked(mockSessionStorage.popUndoEntry).mockResolvedValue(entry)
vi.mocked(fs.readFile).mockResolvedValue("const a = 999")
await useCase.execute(session)
expect(mockSessionStorage.pushUndoEntry).toHaveBeenCalledWith(session.id, entry)
})
it("should handle empty file for undo", async () => {
const entry = createUndoEntry({
previousContent: [],
newContent: ["new content"],
})
vi.mocked(mockSessionStorage.popUndoEntry).mockResolvedValue(entry)
vi.mocked(fs.readFile).mockResolvedValue("new content")
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
session.addUndoEntry(entry)
const result = await useCase.execute(session)
expect(result.success).toBe(true)
expect(fs.writeFile).toHaveBeenCalledWith(entry.filePath, "", "utf-8")
})
it("should handle file not found during undo", async () => {
const entry = createUndoEntry()
vi.mocked(mockSessionStorage.popUndoEntry).mockResolvedValue(entry)
const error = new Error("ENOENT") as NodeJS.ErrnoException
error.code = "ENOENT"
vi.mocked(fs.readFile).mockRejectedValue(error)
const result = await useCase.execute(session)
expect(result.success).toBe(false)
})
})
describe("canUndo", () => {
it("should return false when stack is empty", async () => {
vi.mocked(mockSessionStorage.getUndoStack).mockResolvedValue([])
const result = await useCase.canUndo(session)
expect(result).toBe(false)
})
it("should return true when stack has entries", async () => {
vi.mocked(mockSessionStorage.getUndoStack).mockResolvedValue([createUndoEntry()])
const result = await useCase.canUndo(session)
expect(result).toBe(true)
})
})
describe("peekUndoEntry", () => {
it("should return null when stack is empty", async () => {
vi.mocked(mockSessionStorage.getUndoStack).mockResolvedValue([])
const result = await useCase.peekUndoEntry(session)
expect(result).toBeNull()
})
it("should return last entry without removing", async () => {
const entry = createUndoEntry()
vi.mocked(mockSessionStorage.getUndoStack).mockResolvedValue([entry])
const result = await useCase.peekUndoEntry(session)
expect(result).toBe(entry)
expect(mockSessionStorage.popUndoEntry).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import * as fs from "node:fs/promises"
import * as path from "node:path"
import { executeInit } from "../../../../src/cli/commands/init.js"
vi.mock("node:fs/promises")
describe("executeInit", () => {
const testPath = "/test/project"
const configPath = path.join(testPath, ".ipuaro.json")
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, "warn").mockImplementation(() => {})
vi.spyOn(console, "error").mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
it("should create .ipuaro.json file successfully", async () => {
vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"))
vi.mocked(fs.mkdir).mockResolvedValue(undefined)
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
const result = await executeInit(testPath)
expect(result.success).toBe(true)
expect(result.filePath).toBe(configPath)
expect(fs.writeFile).toHaveBeenCalledWith(
configPath,
expect.stringContaining('"redis"'),
"utf-8",
)
})
it("should skip existing file without force option", async () => {
vi.mocked(fs.access).mockResolvedValue(undefined)
const result = await executeInit(testPath)
expect(result.success).toBe(true)
expect(result.skipped).toBe(true)
expect(fs.writeFile).not.toHaveBeenCalled()
})
it("should overwrite existing file with force option", async () => {
vi.mocked(fs.access).mockResolvedValue(undefined)
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
const result = await executeInit(testPath, { force: true })
expect(result.success).toBe(true)
expect(result.skipped).toBeUndefined()
expect(fs.writeFile).toHaveBeenCalled()
})
it("should handle write errors", async () => {
vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"))
vi.mocked(fs.mkdir).mockResolvedValue(undefined)
vi.mocked(fs.writeFile).mockRejectedValue(new Error("Permission denied"))
const result = await executeInit(testPath)
expect(result.success).toBe(false)
expect(result.error).toContain("Permission denied")
})
it("should create parent directories if needed", async () => {
vi.mocked(fs.access)
.mockRejectedValueOnce(new Error("ENOENT"))
.mockRejectedValueOnce(new Error("ENOENT"))
vi.mocked(fs.mkdir).mockResolvedValue(undefined)
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
const result = await executeInit(testPath)
expect(result.success).toBe(true)
expect(fs.mkdir).toHaveBeenCalledWith(expect.any(String), { recursive: true })
})
it("should use current directory as default", async () => {
vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"))
vi.mocked(fs.mkdir).mockResolvedValue(undefined)
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
const result = await executeInit()
expect(result.success).toBe(true)
expect(result.filePath).toContain(".ipuaro.json")
})
it("should include expected config sections", async () => {
vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"))
vi.mocked(fs.mkdir).mockResolvedValue(undefined)
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
await executeInit(testPath)
const writeCall = vi.mocked(fs.writeFile).mock.calls[0]
const content = writeCall[1] as string
const config = JSON.parse(content) as {
redis: unknown
llm: unknown
edit: unknown
}
expect(config).toHaveProperty("redis")
expect(config).toHaveProperty("llm")
expect(config).toHaveProperty("edit")
expect(config.redis).toHaveProperty("host", "localhost")
expect(config.redis).toHaveProperty("port", 6379)
expect(config.llm).toHaveProperty("model", "qwen2.5-coder:7b-instruct")
expect(config.edit).toHaveProperty("autoApply", false)
})
})

View File

@@ -0,0 +1,353 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import {
checkRedis,
checkOllama,
checkModel,
checkProjectSize,
runOnboarding,
} from "../../../../src/cli/commands/onboarding.js"
import { RedisClient } from "../../../../src/infrastructure/storage/RedisClient.js"
import { OllamaClient } from "../../../../src/infrastructure/llm/OllamaClient.js"
import { FileScanner } from "../../../../src/infrastructure/indexer/FileScanner.js"
vi.mock("../../../../src/infrastructure/storage/RedisClient.js")
vi.mock("../../../../src/infrastructure/llm/OllamaClient.js")
vi.mock("../../../../src/infrastructure/indexer/FileScanner.js")
describe("onboarding", () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe("checkRedis", () => {
it("should return ok when Redis connects and pings successfully", async () => {
const mockConnect = vi.fn().mockResolvedValue(undefined)
const mockPing = vi.fn().mockResolvedValue(true)
const mockDisconnect = vi.fn().mockResolvedValue(undefined)
vi.mocked(RedisClient).mockImplementation(
() =>
({
connect: mockConnect,
ping: mockPing,
disconnect: mockDisconnect,
}) as unknown as RedisClient,
)
const result = await checkRedis({
host: "localhost",
port: 6379,
db: 0,
keyPrefix: "ipuaro:",
})
expect(result.ok).toBe(true)
expect(result.error).toBeUndefined()
expect(mockConnect).toHaveBeenCalled()
expect(mockPing).toHaveBeenCalled()
expect(mockDisconnect).toHaveBeenCalled()
})
it("should return error when Redis connection fails", async () => {
vi.mocked(RedisClient).mockImplementation(
() =>
({
connect: vi.fn().mockRejectedValue(new Error("Connection refused")),
}) as unknown as RedisClient,
)
const result = await checkRedis({
host: "localhost",
port: 6379,
db: 0,
keyPrefix: "ipuaro:",
})
expect(result.ok).toBe(false)
expect(result.error).toContain("Cannot connect to Redis")
})
it("should return error when ping fails", async () => {
vi.mocked(RedisClient).mockImplementation(
() =>
({
connect: vi.fn().mockResolvedValue(undefined),
ping: vi.fn().mockResolvedValue(false),
disconnect: vi.fn().mockResolvedValue(undefined),
}) as unknown as RedisClient,
)
const result = await checkRedis({
host: "localhost",
port: 6379,
db: 0,
keyPrefix: "ipuaro:",
})
expect(result.ok).toBe(false)
expect(result.error).toContain("Redis ping failed")
})
})
describe("checkOllama", () => {
it("should return ok when Ollama is available", async () => {
vi.mocked(OllamaClient).mockImplementation(
() =>
({
isAvailable: vi.fn().mockResolvedValue(true),
}) as unknown as OllamaClient,
)
const result = await checkOllama({
model: "qwen2.5-coder:7b-instruct",
contextWindow: 128_000,
temperature: 0.1,
host: "http://localhost:11434",
timeout: 120_000,
})
expect(result.ok).toBe(true)
expect(result.error).toBeUndefined()
})
it("should return error when Ollama is not available", async () => {
vi.mocked(OllamaClient).mockImplementation(
() =>
({
isAvailable: vi.fn().mockResolvedValue(false),
}) as unknown as OllamaClient,
)
const result = await checkOllama({
model: "qwen2.5-coder:7b-instruct",
contextWindow: 128_000,
temperature: 0.1,
host: "http://localhost:11434",
timeout: 120_000,
})
expect(result.ok).toBe(false)
expect(result.error).toContain("Cannot connect to Ollama")
})
})
describe("checkModel", () => {
it("should return ok when model is available", async () => {
vi.mocked(OllamaClient).mockImplementation(
() =>
({
hasModel: vi.fn().mockResolvedValue(true),
}) as unknown as OllamaClient,
)
const result = await checkModel({
model: "qwen2.5-coder:7b-instruct",
contextWindow: 128_000,
temperature: 0.1,
host: "http://localhost:11434",
timeout: 120_000,
})
expect(result.ok).toBe(true)
expect(result.needsPull).toBe(false)
})
it("should return needsPull when model is not available", async () => {
vi.mocked(OllamaClient).mockImplementation(
() =>
({
hasModel: vi.fn().mockResolvedValue(false),
}) as unknown as OllamaClient,
)
const result = await checkModel({
model: "qwen2.5-coder:7b-instruct",
contextWindow: 128_000,
temperature: 0.1,
host: "http://localhost:11434",
timeout: 120_000,
})
expect(result.ok).toBe(false)
expect(result.needsPull).toBe(true)
expect(result.error).toContain("not installed")
})
})
describe("checkProjectSize", () => {
it("should return ok when file count is within limits", async () => {
vi.mocked(FileScanner).mockImplementation(
() =>
({
scanAll: vi.fn().mockResolvedValue(
Array.from({ length: 100 }, (_, i) => ({
path: `file${String(i)}.ts`,
type: "file" as const,
size: 1000,
lastModified: Date.now(),
})),
),
}) as unknown as FileScanner,
)
const result = await checkProjectSize("/test/path")
expect(result.ok).toBe(true)
expect(result.fileCount).toBe(100)
expect(result.warning).toBeUndefined()
})
it("should return warning when file count exceeds limit", async () => {
vi.mocked(FileScanner).mockImplementation(
() =>
({
scanAll: vi.fn().mockResolvedValue(
Array.from({ length: 15000 }, (_, i) => ({
path: `file${String(i)}.ts`,
type: "file" as const,
size: 1000,
lastModified: Date.now(),
})),
),
}) as unknown as FileScanner,
)
const result = await checkProjectSize("/test/path", 10_000)
expect(result.ok).toBe(true)
expect(result.fileCount).toBe(15000)
expect(result.warning).toContain("15")
expect(result.warning).toContain("000 files")
})
it("should return error when no files found", async () => {
vi.mocked(FileScanner).mockImplementation(
() =>
({
scanAll: vi.fn().mockResolvedValue([]),
}) as unknown as FileScanner,
)
const result = await checkProjectSize("/test/path")
expect(result.ok).toBe(false)
expect(result.fileCount).toBe(0)
expect(result.warning).toContain("No supported files found")
})
})
describe("runOnboarding", () => {
it("should return success when all checks pass", async () => {
vi.mocked(RedisClient).mockImplementation(
() =>
({
connect: vi.fn().mockResolvedValue(undefined),
ping: vi.fn().mockResolvedValue(true),
disconnect: vi.fn().mockResolvedValue(undefined),
}) as unknown as RedisClient,
)
vi.mocked(OllamaClient).mockImplementation(
() =>
({
isAvailable: vi.fn().mockResolvedValue(true),
hasModel: vi.fn().mockResolvedValue(true),
}) as unknown as OllamaClient,
)
vi.mocked(FileScanner).mockImplementation(
() =>
({
scanAll: vi.fn().mockResolvedValue([{ path: "file.ts" }]),
}) as unknown as FileScanner,
)
const result = await runOnboarding({
redisConfig: { host: "localhost", port: 6379, db: 0, keyPrefix: "ipuaro:" },
llmConfig: {
model: "test",
contextWindow: 128_000,
temperature: 0.1,
host: "http://localhost:11434",
timeout: 120_000,
},
projectPath: "/test/path",
})
expect(result.success).toBe(true)
expect(result.redisOk).toBe(true)
expect(result.ollamaOk).toBe(true)
expect(result.modelOk).toBe(true)
expect(result.projectOk).toBe(true)
expect(result.errors).toHaveLength(0)
})
it("should return failure when Redis fails", async () => {
vi.mocked(RedisClient).mockImplementation(
() =>
({
connect: vi.fn().mockRejectedValue(new Error("Connection refused")),
}) as unknown as RedisClient,
)
vi.mocked(OllamaClient).mockImplementation(
() =>
({
isAvailable: vi.fn().mockResolvedValue(true),
hasModel: vi.fn().mockResolvedValue(true),
}) as unknown as OllamaClient,
)
vi.mocked(FileScanner).mockImplementation(
() =>
({
scanAll: vi.fn().mockResolvedValue([{ path: "file.ts" }]),
}) as unknown as FileScanner,
)
const result = await runOnboarding({
redisConfig: { host: "localhost", port: 6379, db: 0, keyPrefix: "ipuaro:" },
llmConfig: {
model: "test",
contextWindow: 128_000,
temperature: 0.1,
host: "http://localhost:11434",
timeout: 120_000,
},
projectPath: "/test/path",
})
expect(result.success).toBe(false)
expect(result.redisOk).toBe(false)
expect(result.errors.length).toBeGreaterThan(0)
})
it("should skip checks when skip options are set", async () => {
const result = await runOnboarding({
redisConfig: { host: "localhost", port: 6379, db: 0, keyPrefix: "ipuaro:" },
llmConfig: {
model: "test",
contextWindow: 128_000,
temperature: 0.1,
host: "http://localhost:11434",
timeout: 120_000,
},
projectPath: "/test/path",
skipRedis: true,
skipOllama: true,
skipModel: true,
skipProject: true,
})
expect(result.success).toBe(true)
expect(result.redisOk).toBe(true)
expect(result.ollamaOk).toBe(true)
expect(result.modelOk).toBe(true)
expect(result.projectOk).toBe(true)
})
})
})

View File

@@ -0,0 +1,111 @@
import { describe, it, expect } from "vitest"
import { registerAllTools } from "../../../../src/cli/commands/tools-setup.js"
import { ToolRegistry } from "../../../../src/infrastructure/tools/registry.js"
describe("registerAllTools", () => {
it("should register all 18 tools", () => {
const registry = new ToolRegistry()
registerAllTools(registry)
expect(registry.size).toBe(18)
})
it("should register all read tools", () => {
const registry = new ToolRegistry()
registerAllTools(registry)
expect(registry.has("get_lines")).toBe(true)
expect(registry.has("get_function")).toBe(true)
expect(registry.has("get_class")).toBe(true)
expect(registry.has("get_structure")).toBe(true)
})
it("should register all edit tools", () => {
const registry = new ToolRegistry()
registerAllTools(registry)
expect(registry.has("edit_lines")).toBe(true)
expect(registry.has("create_file")).toBe(true)
expect(registry.has("delete_file")).toBe(true)
})
it("should register all search tools", () => {
const registry = new ToolRegistry()
registerAllTools(registry)
expect(registry.has("find_references")).toBe(true)
expect(registry.has("find_definition")).toBe(true)
})
it("should register all analysis tools", () => {
const registry = new ToolRegistry()
registerAllTools(registry)
expect(registry.has("get_dependencies")).toBe(true)
expect(registry.has("get_dependents")).toBe(true)
expect(registry.has("get_complexity")).toBe(true)
expect(registry.has("get_todos")).toBe(true)
})
it("should register all git tools", () => {
const registry = new ToolRegistry()
registerAllTools(registry)
expect(registry.has("git_status")).toBe(true)
expect(registry.has("git_diff")).toBe(true)
expect(registry.has("git_commit")).toBe(true)
})
it("should register all run tools", () => {
const registry = new ToolRegistry()
registerAllTools(registry)
expect(registry.has("run_command")).toBe(true)
expect(registry.has("run_tests")).toBe(true)
})
it("should register tools with correct categories", () => {
const registry = new ToolRegistry()
registerAllTools(registry)
const readTools = registry.getByCategory("read")
const editTools = registry.getByCategory("edit")
const searchTools = registry.getByCategory("search")
const analysisTools = registry.getByCategory("analysis")
const gitTools = registry.getByCategory("git")
const runTools = registry.getByCategory("run")
expect(readTools.length).toBe(4)
expect(editTools.length).toBe(3)
expect(searchTools.length).toBe(2)
expect(analysisTools.length).toBe(4)
expect(gitTools.length).toBe(3)
expect(runTools.length).toBe(2)
})
it("should register tools with requiresConfirmation flag", () => {
const registry = new ToolRegistry()
registerAllTools(registry)
const confirmationTools = registry.getConfirmationTools()
const safeTools = registry.getSafeTools()
expect(confirmationTools.length).toBeGreaterThan(0)
expect(safeTools.length).toBeGreaterThan(0)
const confirmNames = confirmationTools.map((t) => t.name)
expect(confirmNames).toContain("edit_lines")
expect(confirmNames).toContain("create_file")
expect(confirmNames).toContain("delete_file")
expect(confirmNames).toContain("git_commit")
})
})

View File

@@ -63,6 +63,13 @@ describe("ChatMessage", () => {
expect(msg.content).toContain("[2] Error: Not found") expect(msg.content).toContain("[2] Error: Not found")
}) })
it("should handle error result without error message", () => {
const results = [{ callId: "3", success: false, executionTimeMs: 5 }]
const msg = createToolMessage(results)
expect(msg.content).toContain("[3] Error: Unknown error")
})
}) })
describe("createSystemMessage", () => { describe("createSystemMessage", () => {

View File

@@ -301,6 +301,66 @@ describe("ASTParser", () => {
}) })
}) })
describe("import string formats", () => {
it("should handle single-quoted imports", () => {
const code = `import { foo } from './module'`
const ast = parser.parse(code, "ts")
expect(ast.imports).toHaveLength(1)
expect(ast.imports[0].from).toBe("./module")
})
it("should handle double-quoted imports", () => {
const code = `import { bar } from "./other"`
const ast = parser.parse(code, "ts")
expect(ast.imports).toHaveLength(1)
expect(ast.imports[0].from).toBe("./other")
})
})
describe("parameter types", () => {
it("should handle simple identifier parameters", () => {
const code = `const fn = (x) => x * 2`
const ast = parser.parse(code, "ts")
expect(ast.functions.length).toBeGreaterThanOrEqual(0)
})
it("should handle optional parameters with defaults", () => {
const code = `function greet(name: string = "World"): string { return name }`
const ast = parser.parse(code, "ts")
expect(ast.functions).toHaveLength(1)
const fn = ast.functions[0]
expect(fn.params.some((p) => p.hasDefault)).toBe(true)
})
it("should handle arrow function with untyped params", () => {
const code = `const add = (a, b) => a + b`
const ast = parser.parse(code, "ts")
expect(ast.functions.length).toBeGreaterThanOrEqual(0)
})
it("should handle multiple parameter types", () => {
const code = `
function mix(
required: string,
optional?: number,
withDefault: boolean = true
) {}
`
const ast = parser.parse(code, "ts")
expect(ast.functions).toHaveLength(1)
const fn = ast.functions[0]
expect(fn.params).toHaveLength(3)
expect(fn.params.some((p) => p.optional)).toBe(true)
expect(fn.params.some((p) => p.hasDefault)).toBe(true)
})
})
describe("complex file", () => { describe("complex file", () => {
it("should parse complex TypeScript file", () => { it("should parse complex TypeScript file", () => {
const code = ` const code = `

View File

@@ -212,6 +212,32 @@ describe("FileScanner", () => {
}) })
}) })
describe("empty file handling", () => {
it("should consider empty files as text files", async () => {
const emptyFile = path.join(FIXTURES_DIR, "empty-file.ts")
await fs.writeFile(emptyFile, "")
try {
const isText = await FileScanner.isTextFile(emptyFile)
expect(isText).toBe(true)
} finally {
await fs.unlink(emptyFile)
}
})
it("should read empty file content", async () => {
const emptyFile = path.join(FIXTURES_DIR, "empty-content.ts")
await fs.writeFile(emptyFile, "")
try {
const content = await FileScanner.readFileContent(emptyFile)
expect(content).toBe("")
} finally {
await fs.unlink(emptyFile)
}
})
})
describe("empty directory handling", () => { describe("empty directory handling", () => {
let emptyDir: string let emptyDir: string

View File

@@ -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([])
})
})
}) })

View File

@@ -544,6 +544,44 @@ const b = 2`
}) })
}) })
describe("dependency resolution with different extensions", () => {
it("should resolve imports from index files", () => {
const content = `import { utils } from "./utils/index"`
const ast = parser.parse(content, "ts")
const allASTs = new Map<string, FileAST>()
allASTs.set("/project/src/main.ts", ast)
allASTs.set("/project/src/utils/index.ts", createEmptyFileAST())
const meta = analyzer.analyze("/project/src/main.ts", ast, content, allASTs)
expect(meta.dependencies).toContain("/project/src/utils/index.ts")
})
it("should convert .js extension to .ts when resolving", () => {
const content = `import { helper } from "./helper.js"`
const ast = parser.parse(content, "ts")
const allASTs = new Map<string, FileAST>()
allASTs.set("/project/src/main.ts", ast)
allASTs.set("/project/src/helper.ts", createEmptyFileAST())
const meta = analyzer.analyze("/project/src/main.ts", ast, content, allASTs)
expect(meta.dependencies).toContain("/project/src/helper.ts")
})
it("should convert .jsx extension to .tsx when resolving", () => {
const content = `import { Button } from "./Button.jsx"`
const ast = parser.parse(content, "ts")
const allASTs = new Map<string, FileAST>()
allASTs.set("/project/src/App.tsx", ast)
allASTs.set("/project/src/Button.tsx", createEmptyFileAST())
const meta = analyzer.analyze("/project/src/App.tsx", ast, content, allASTs)
expect(meta.dependencies).toContain("/project/src/Button.tsx")
})
})
describe("analyze", () => { describe("analyze", () => {
it("should produce complete FileMeta", () => { it("should produce complete FileMeta", () => {
const content = `import { helper } from "./helper" const content = `import { helper } from "./helper"

View File

@@ -94,12 +94,70 @@ describe("Watchdog", () => {
it("should return empty array when not watching", () => { it("should return empty array when not watching", () => {
expect(watchdog.getWatchedPaths()).toEqual([]) expect(watchdog.getWatchedPaths()).toEqual([])
}) })
it("should return paths when watching", async () => {
const testFile = path.join(tempDir, "exists.ts")
await fs.writeFile(testFile, "const x = 1")
watchdog.start(tempDir)
await new Promise((resolve) => setTimeout(resolve, 200))
const paths = watchdog.getWatchedPaths()
expect(Array.isArray(paths)).toBe(true)
})
}) })
describe("flushAll", () => { describe("flushAll", () => {
it("should not throw when no pending changes", () => { it("should not throw when no pending changes", () => {
expect(() => watchdog.flushAll()).not.toThrow() expect(() => watchdog.flushAll()).not.toThrow()
}) })
it("should flush all pending changes", async () => {
const events: FileChangeEvent[] = []
watchdog.onFileChange((event) => events.push(event))
watchdog.start(tempDir)
await new Promise((resolve) => setTimeout(resolve, 100))
const testFile = path.join(tempDir, "flush-test.ts")
await fs.writeFile(testFile, "const x = 1")
await new Promise((resolve) => setTimeout(resolve, 20))
watchdog.flushAll()
await new Promise((resolve) => setTimeout(resolve, 50))
})
})
describe("ignore patterns", () => {
it("should handle glob patterns with wildcards", async () => {
const customWatchdog = new Watchdog({
debounceMs: 50,
ignorePatterns: ["*.log", "**/*.tmp"],
})
customWatchdog.start(tempDir)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(customWatchdog.isWatching()).toBe(true)
await customWatchdog.stop()
})
it("should handle simple directory patterns", async () => {
const customWatchdog = new Watchdog({
debounceMs: 50,
ignorePatterns: ["node_modules", "dist"],
})
customWatchdog.start(tempDir)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(customWatchdog.isWatching()).toBe(true)
await customWatchdog.stop()
})
}) })
describe("file change detection", () => { describe("file change detection", () => {

View File

@@ -301,4 +301,188 @@ describe("OllamaClient", () => {
expect(() => client.abort()).not.toThrow() 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/)
})
})
}) })

View File

@@ -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", () => { describe("truncateContext", () => {
it("should return original context if within limit", () => { it("should return original context if within limit", () => {
const context = "Short context" const context = "Short context"

View File

@@ -0,0 +1,320 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest"
import * as path from "node:path"
import * as fs from "node:fs/promises"
import * as os from "node:os"
import {
PathValidator,
createPathValidator,
validatePath,
} from "../../../../src/infrastructure/security/PathValidator.js"
describe("PathValidator", () => {
let validator: PathValidator
let tempDir: string
let projectRoot: string
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pathvalidator-test-"))
projectRoot = path.join(tempDir, "project")
await fs.mkdir(projectRoot)
validator = new PathValidator(projectRoot)
})
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true })
})
describe("constructor", () => {
it("should resolve project root to absolute path", () => {
const relativeValidator = new PathValidator("./project")
expect(relativeValidator.getProjectRoot()).toBe(path.resolve("./project"))
})
it("should store project root", () => {
expect(validator.getProjectRoot()).toBe(projectRoot)
})
})
describe("validateSync", () => {
it("should validate relative path within project", () => {
const result = validator.validateSync("src/file.ts")
expect(result.status).toBe("valid")
expect(result.absolutePath).toBe(path.join(projectRoot, "src/file.ts"))
expect(result.relativePath).toBe(path.join("src", "file.ts"))
})
it("should validate nested relative paths", () => {
const result = validator.validateSync("src/components/Button.tsx")
expect(result.status).toBe("valid")
})
it("should validate root level files", () => {
const result = validator.validateSync("package.json")
expect(result.status).toBe("valid")
expect(result.relativePath).toBe("package.json")
})
it("should reject empty path", () => {
const result = validator.validateSync("")
expect(result.status).toBe("invalid")
expect(result.reason).toBe("Path is empty")
})
it("should reject whitespace-only path", () => {
const result = validator.validateSync(" ")
expect(result.status).toBe("invalid")
expect(result.reason).toBe("Path is empty")
})
it("should reject path with .. traversal", () => {
const result = validator.validateSync("../outside")
expect(result.status).toBe("invalid")
expect(result.reason).toBe("Path contains traversal patterns")
})
it("should reject path with embedded .. traversal", () => {
const result = validator.validateSync("src/../../../etc/passwd")
expect(result.status).toBe("invalid")
expect(result.reason).toBe("Path contains traversal patterns")
})
it("should reject path starting with tilde", () => {
const result = validator.validateSync("~/secret/file")
expect(result.status).toBe("invalid")
expect(result.reason).toBe("Path contains traversal patterns")
})
it("should reject absolute path outside project", () => {
const result = validator.validateSync("/etc/passwd")
expect(result.status).toBe("outside_project")
expect(result.reason).toBe("Path is outside project root")
})
it("should accept absolute path inside project", () => {
const absoluteInside = path.join(projectRoot, "src/file.ts")
const result = validator.validateSync(absoluteInside)
expect(result.status).toBe("valid")
})
it("should trim whitespace from path", () => {
const result = validator.validateSync(" src/file.ts ")
expect(result.status).toBe("valid")
})
it("should handle Windows-style backslashes", () => {
const result = validator.validateSync("src\\components\\file.ts")
expect(result.status).toBe("valid")
})
it("should reject path that resolves outside via symlink-like patterns", () => {
const result = validator.validateSync("src/./../../etc")
expect(result.status).toBe("invalid")
expect(result.reason).toBe("Path contains traversal patterns")
})
})
describe("validate (async)", () => {
beforeEach(async () => {
await fs.mkdir(path.join(projectRoot, "src"), { recursive: true })
await fs.writeFile(path.join(projectRoot, "src/file.ts"), "// content")
await fs.mkdir(path.join(projectRoot, "dist"), { recursive: true })
})
it("should validate existing file", async () => {
const result = await validator.validate("src/file.ts")
expect(result.status).toBe("valid")
})
it("should reject non-existent file by default", async () => {
const result = await validator.validate("src/nonexistent.ts")
expect(result.status).toBe("invalid")
expect(result.reason).toBe("Path does not exist")
})
it("should allow non-existent file with allowNonExistent option", async () => {
const result = await validator.validate("src/newfile.ts", { allowNonExistent: true })
expect(result.status).toBe("valid")
})
it("should validate directory when requireDirectory is true", async () => {
const result = await validator.validate("src", { requireDirectory: true })
expect(result.status).toBe("valid")
})
it("should reject file when requireDirectory is true", async () => {
const result = await validator.validate("src/file.ts", { requireDirectory: true })
expect(result.status).toBe("invalid")
expect(result.reason).toBe("Path is not a directory")
})
it("should validate file when requireFile is true", async () => {
const result = await validator.validate("src/file.ts", { requireFile: true })
expect(result.status).toBe("valid")
})
it("should reject directory when requireFile is true", async () => {
const result = await validator.validate("src", { requireFile: true })
expect(result.status).toBe("invalid")
expect(result.reason).toBe("Path is not a file")
})
it("should handle permission errors gracefully", async () => {
const result = await validator.validate("src/../../../root/secret")
expect(result.status).toBe("invalid")
})
it("should still check traversal before existence", async () => {
const result = await validator.validate("../outside", { allowNonExistent: true })
expect(result.status).toBe("invalid")
expect(result.reason).toBe("Path contains traversal patterns")
})
})
describe("isWithin", () => {
it("should return true for path within project", () => {
expect(validator.isWithin("src/file.ts")).toBe(true)
})
it("should return true for project root itself", () => {
expect(validator.isWithin(".")).toBe(true)
expect(validator.isWithin("")).toBe(false)
})
it("should return false for path outside project", () => {
expect(validator.isWithin("/etc/passwd")).toBe(false)
})
it("should return false for traversal path", () => {
expect(validator.isWithin("../outside")).toBe(false)
})
it("should return false for empty path", () => {
expect(validator.isWithin("")).toBe(false)
})
it("should return false for tilde path", () => {
expect(validator.isWithin("~/file")).toBe(false)
})
})
describe("resolve", () => {
it("should resolve valid relative path to absolute", () => {
const result = validator.resolve("src/file.ts")
expect(result).toBe(path.join(projectRoot, "src/file.ts"))
})
it("should return null for invalid path", () => {
expect(validator.resolve("../outside")).toBeNull()
})
it("should return null for empty path", () => {
expect(validator.resolve("")).toBeNull()
})
it("should return null for path outside project", () => {
expect(validator.resolve("/etc/passwd")).toBeNull()
})
})
describe("relativize", () => {
it("should return relative path for valid input", () => {
const result = validator.relativize("src/file.ts")
expect(result).toBe(path.join("src", "file.ts"))
})
it("should handle absolute path within project", () => {
const absolutePath = path.join(projectRoot, "src/file.ts")
const result = validator.relativize(absolutePath)
expect(result).toBe(path.join("src", "file.ts"))
})
it("should return null for path outside project", () => {
expect(validator.relativize("/etc/passwd")).toBeNull()
})
it("should return null for traversal path", () => {
expect(validator.relativize("../outside")).toBeNull()
})
})
describe("edge cases", () => {
it("should handle path with multiple slashes", () => {
const result = validator.validateSync("src///file.ts")
expect(result.status).toBe("valid")
})
it("should handle path with dots in filename", () => {
const result = validator.validateSync("src/file.test.ts")
expect(result.status).toBe("valid")
})
it("should handle hidden files", () => {
const result = validator.validateSync(".gitignore")
expect(result.status).toBe("valid")
})
it("should handle hidden directories", () => {
const result = validator.validateSync(".github/workflows/ci.yml")
expect(result.status).toBe("valid")
})
it("should handle single dot current directory", () => {
const result = validator.validateSync("./src/file.ts")
expect(result.status).toBe("valid")
})
it("should handle project root as path", () => {
const result = validator.validateSync(projectRoot)
expect(result.status).toBe("valid")
})
it("should handle unicode characters in path", () => {
const result = validator.validateSync("src/файл.ts")
expect(result.status).toBe("valid")
})
it("should handle spaces in path", () => {
const result = validator.validateSync("src/my file.ts")
expect(result.status).toBe("valid")
})
})
})
describe("createPathValidator", () => {
it("should create PathValidator instance", () => {
const validator = createPathValidator("/tmp/project")
expect(validator).toBeInstanceOf(PathValidator)
expect(validator.getProjectRoot()).toBe("/tmp/project")
})
})
describe("validatePath", () => {
let tempDir: string
let projectRoot: string
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "validatepath-test-"))
projectRoot = path.join(tempDir, "project")
await fs.mkdir(projectRoot)
})
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true })
})
it("should return true for valid path", () => {
expect(validatePath("src/file.ts", projectRoot)).toBe(true)
})
it("should return false for traversal path", () => {
expect(validatePath("../outside", projectRoot)).toBe(false)
})
it("should return false for path outside project", () => {
expect(validatePath("/etc/passwd", projectRoot)).toBe(false)
})
it("should return false for empty path", () => {
expect(validatePath("", projectRoot)).toBe(false)
})
})

View File

@@ -0,0 +1,390 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { RedisSessionStorage } from "../../../../src/infrastructure/storage/RedisSessionStorage.js"
import { RedisClient } from "../../../../src/infrastructure/storage/RedisClient.js"
import { Session } from "../../../../src/domain/entities/Session.js"
import type { UndoEntry } from "../../../../src/domain/value-objects/UndoEntry.js"
import { SessionKeys, SessionFields } from "../../../../src/infrastructure/storage/schema.js"
describe("RedisSessionStorage", () => {
let storage: RedisSessionStorage
let mockRedis: {
hset: ReturnType<typeof vi.fn>
hget: ReturnType<typeof vi.fn>
hgetall: ReturnType<typeof vi.fn>
del: ReturnType<typeof vi.fn>
lrange: ReturnType<typeof vi.fn>
lpush: ReturnType<typeof vi.fn>
lpos: ReturnType<typeof vi.fn>
lrem: ReturnType<typeof vi.fn>
rpush: ReturnType<typeof vi.fn>
rpop: ReturnType<typeof vi.fn>
llen: ReturnType<typeof vi.fn>
lpop: ReturnType<typeof vi.fn>
exists: ReturnType<typeof vi.fn>
pipeline: ReturnType<typeof vi.fn>
}
let mockClient: RedisClient
beforeEach(() => {
mockRedis = {
hset: vi.fn().mockResolvedValue(1),
hget: vi.fn().mockResolvedValue(null),
hgetall: vi.fn().mockResolvedValue({}),
del: vi.fn().mockResolvedValue(1),
lrange: vi.fn().mockResolvedValue([]),
lpush: vi.fn().mockResolvedValue(1),
lpos: vi.fn().mockResolvedValue(null),
lrem: vi.fn().mockResolvedValue(1),
rpush: vi.fn().mockResolvedValue(1),
rpop: vi.fn().mockResolvedValue(null),
llen: vi.fn().mockResolvedValue(0),
lpop: vi.fn().mockResolvedValue(null),
exists: vi.fn().mockResolvedValue(0),
pipeline: vi.fn().mockReturnValue({
hset: vi.fn().mockReturnThis(),
del: vi.fn().mockReturnThis(),
exec: vi.fn().mockResolvedValue([]),
}),
}
mockClient = {
getClient: () => mockRedis,
connect: vi.fn(),
disconnect: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
} as unknown as RedisClient
storage = new RedisSessionStorage(mockClient)
})
describe("saveSession", () => {
it("should save session data to Redis", async () => {
const session = new Session("test-session-1", "test-project")
session.history = [{ role: "user", content: "Hello", timestamp: Date.now() }]
await storage.saveSession(session)
const pipeline = mockRedis.pipeline()
expect(pipeline.hset).toHaveBeenCalled()
expect(pipeline.exec).toHaveBeenCalled()
})
it("should add session to list if not exists", async () => {
const session = new Session("test-session-2", "test-project")
await storage.saveSession(session)
expect(mockRedis.lpos).toHaveBeenCalledWith(SessionKeys.list, "test-session-2")
expect(mockRedis.lpush).toHaveBeenCalledWith(SessionKeys.list, "test-session-2")
})
it("should not add session to list if already exists", async () => {
const session = new Session("existing-session", "test-project")
mockRedis.lpos.mockResolvedValue(0)
await storage.saveSession(session)
expect(mockRedis.lpush).not.toHaveBeenCalled()
})
})
describe("loadSession", () => {
it("should return null for non-existent session", async () => {
mockRedis.hgetall.mockResolvedValue({})
const result = await storage.loadSession("non-existent")
expect(result).toBeNull()
})
it("should load session from Redis", async () => {
const sessionData = {
[SessionFields.projectName]: "test-project",
[SessionFields.createdAt]: "1700000000000",
[SessionFields.lastActivityAt]: "1700001000000",
[SessionFields.history]: "[]",
[SessionFields.context]: JSON.stringify({
filesInContext: [],
tokenUsage: 0,
needsCompression: false,
}),
[SessionFields.stats]: JSON.stringify({
totalTokens: 0,
totalTimeMs: 0,
toolCalls: 0,
editsApplied: 0,
editsRejected: 0,
}),
[SessionFields.inputHistory]: "[]",
}
mockRedis.hgetall.mockResolvedValue(sessionData)
mockRedis.lrange.mockResolvedValue([])
const result = await storage.loadSession("test-session")
expect(result).not.toBeNull()
expect(result?.id).toBe("test-session")
expect(result?.projectName).toBe("test-project")
expect(result?.createdAt).toBe(1700000000000)
})
it("should load undo stack with session", async () => {
const sessionData = {
[SessionFields.projectName]: "test-project",
[SessionFields.createdAt]: "1700000000000",
[SessionFields.lastActivityAt]: "1700001000000",
[SessionFields.history]: "[]",
[SessionFields.context]: "{}",
[SessionFields.stats]: "{}",
[SessionFields.inputHistory]: "[]",
}
const undoEntry: UndoEntry = {
id: "undo-1",
timestamp: Date.now(),
filePath: "test.ts",
previousContent: ["old"],
newContent: ["new"],
description: "Edit",
}
mockRedis.hgetall.mockResolvedValue(sessionData)
mockRedis.lrange.mockResolvedValue([JSON.stringify(undoEntry)])
const result = await storage.loadSession("test-session")
expect(result?.undoStack).toHaveLength(1)
expect(result?.undoStack[0].id).toBe("undo-1")
})
})
describe("deleteSession", () => {
it("should delete session data and undo stack", async () => {
await storage.deleteSession("test-session")
expect(mockRedis.del).toHaveBeenCalledWith(SessionKeys.data("test-session"))
expect(mockRedis.del).toHaveBeenCalledWith(SessionKeys.undo("test-session"))
expect(mockRedis.lrem).toHaveBeenCalledWith(SessionKeys.list, 0, "test-session")
})
})
describe("listSessions", () => {
it("should return empty array when no sessions", async () => {
mockRedis.lrange.mockResolvedValue([])
const result = await storage.listSessions()
expect(result).toEqual([])
})
it("should list all sessions", async () => {
mockRedis.lrange.mockResolvedValue(["session-1", "session-2"])
mockRedis.hgetall.mockImplementation((key: string) => {
if (key.includes("session-1")) {
return Promise.resolve({
[SessionFields.projectName]: "project-1",
[SessionFields.createdAt]: "1700000000000",
[SessionFields.lastActivityAt]: "1700001000000",
[SessionFields.history]: "[]",
})
}
if (key.includes("session-2")) {
return Promise.resolve({
[SessionFields.projectName]: "project-2",
[SessionFields.createdAt]: "1700002000000",
[SessionFields.lastActivityAt]: "1700003000000",
[SessionFields.history]: '[{"role":"user","content":"Hi"}]',
})
}
return Promise.resolve({})
})
const result = await storage.listSessions()
expect(result).toHaveLength(2)
expect(result[0].id).toBe("session-2")
expect(result[1].id).toBe("session-1")
})
it("should filter by project name", async () => {
mockRedis.lrange.mockResolvedValue(["session-1", "session-2"])
mockRedis.hgetall.mockImplementation((key: string) => {
if (key.includes("session-1")) {
return Promise.resolve({
[SessionFields.projectName]: "project-1",
[SessionFields.createdAt]: "1700000000000",
[SessionFields.lastActivityAt]: "1700001000000",
[SessionFields.history]: "[]",
})
}
if (key.includes("session-2")) {
return Promise.resolve({
[SessionFields.projectName]: "project-2",
[SessionFields.createdAt]: "1700002000000",
[SessionFields.lastActivityAt]: "1700003000000",
[SessionFields.history]: "[]",
})
}
return Promise.resolve({})
})
const result = await storage.listSessions("project-1")
expect(result).toHaveLength(1)
expect(result[0].projectName).toBe("project-1")
})
})
describe("getLatestSession", () => {
it("should return null when no sessions", async () => {
mockRedis.lrange.mockResolvedValue([])
const result = await storage.getLatestSession("test-project")
expect(result).toBeNull()
})
it("should return the most recent session", async () => {
mockRedis.lrange.mockImplementation((key: string) => {
if (key === SessionKeys.list) {
return Promise.resolve(["session-1"])
}
return Promise.resolve([])
})
mockRedis.hgetall.mockResolvedValue({
[SessionFields.projectName]: "test-project",
[SessionFields.createdAt]: "1700000000000",
[SessionFields.lastActivityAt]: "1700001000000",
[SessionFields.history]: "[]",
[SessionFields.context]: "{}",
[SessionFields.stats]: "{}",
[SessionFields.inputHistory]: "[]",
})
const result = await storage.getLatestSession("test-project")
expect(result).not.toBeNull()
expect(result?.id).toBe("session-1")
})
})
describe("sessionExists", () => {
it("should return false for non-existent session", async () => {
mockRedis.exists.mockResolvedValue(0)
const result = await storage.sessionExists("non-existent")
expect(result).toBe(false)
})
it("should return true for existing session", async () => {
mockRedis.exists.mockResolvedValue(1)
const result = await storage.sessionExists("existing")
expect(result).toBe(true)
})
})
describe("undo stack operations", () => {
const undoEntry: UndoEntry = {
id: "undo-1",
timestamp: Date.now(),
filePath: "test.ts",
previousContent: ["old"],
newContent: ["new"],
description: "Edit",
}
describe("pushUndoEntry", () => {
it("should push undo entry to stack", async () => {
mockRedis.llen.mockResolvedValue(1)
await storage.pushUndoEntry("session-1", undoEntry)
expect(mockRedis.rpush).toHaveBeenCalledWith(
SessionKeys.undo("session-1"),
JSON.stringify(undoEntry),
)
})
it("should remove oldest entry when stack exceeds limit", async () => {
mockRedis.llen.mockResolvedValue(11)
await storage.pushUndoEntry("session-1", undoEntry)
expect(mockRedis.lpop).toHaveBeenCalledWith(SessionKeys.undo("session-1"))
})
})
describe("popUndoEntry", () => {
it("should return null for empty stack", async () => {
mockRedis.rpop.mockResolvedValue(null)
const result = await storage.popUndoEntry("session-1")
expect(result).toBeNull()
})
it("should pop and return undo entry", async () => {
mockRedis.rpop.mockResolvedValue(JSON.stringify(undoEntry))
const result = await storage.popUndoEntry("session-1")
expect(result).not.toBeNull()
expect(result?.id).toBe("undo-1")
})
})
describe("getUndoStack", () => {
it("should return empty array for empty stack", async () => {
mockRedis.lrange.mockResolvedValue([])
const result = await storage.getUndoStack("session-1")
expect(result).toEqual([])
})
it("should return all undo entries", async () => {
mockRedis.lrange.mockResolvedValue([
JSON.stringify({ ...undoEntry, id: "undo-1" }),
JSON.stringify({ ...undoEntry, id: "undo-2" }),
])
const result = await storage.getUndoStack("session-1")
expect(result).toHaveLength(2)
expect(result[0].id).toBe("undo-1")
expect(result[1].id).toBe("undo-2")
})
})
})
describe("touchSession", () => {
it("should update last activity timestamp", async () => {
const beforeTouch = Date.now()
await storage.touchSession("session-1")
expect(mockRedis.hset).toHaveBeenCalledWith(
SessionKeys.data("session-1"),
SessionFields.lastActivityAt,
expect.any(String),
)
const callArgs = mockRedis.hset.mock.calls[0]
const timestamp = Number(callArgs[2])
expect(timestamp).toBeGreaterThanOrEqual(beforeTouch)
})
})
describe("clearAllSessions", () => {
it("should clear all session data", async () => {
mockRedis.lrange.mockResolvedValue(["session-1", "session-2"])
await storage.clearAllSessions()
const pipeline = mockRedis.pipeline()
expect(pipeline.del).toHaveBeenCalled()
expect(pipeline.exec).toHaveBeenCalled()
})
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 contains traversal patterns")
})
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()
})
})
})

View File

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

View File

@@ -0,0 +1,390 @@
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+$/)
})
})
})
})

View File

@@ -0,0 +1,393 @@
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+$/)
})
})
})
})

View File

@@ -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+$/)
})
})
})
})

View File

@@ -271,7 +271,7 @@ describe("GetClassTool", () => {
const result = await tool.execute({ path: "../outside/file.ts", name: "MyClass" }, ctx) const result = await tool.execute({ path: "../outside/file.ts", name: "MyClass" }, ctx)
expect(result.success).toBe(false) expect(result.success).toBe(false)
expect(result.error).toBe("Path must be within project root") expect(result.error).toBe("Path contains traversal patterns")
}) })
it("should handle class with no extends", async () => { it("should handle class with no extends", async () => {

View File

@@ -229,7 +229,7 @@ describe("GetFunctionTool", () => {
const result = await tool.execute({ path: "../outside/file.ts", name: "myFunc" }, ctx) const result = await tool.execute({ path: "../outside/file.ts", name: "myFunc" }, ctx)
expect(result.success).toBe(false) expect(result.success).toBe(false)
expect(result.error).toBe("Path must be within project root") expect(result.error).toBe("Path contains traversal patterns")
}) })
it("should pad line numbers correctly for large files", async () => { it("should pad line numbers correctly for large files", async () => {

View File

@@ -214,7 +214,7 @@ describe("GetLinesTool", () => {
const result = await tool.execute({ path: "../outside/file.ts" }, ctx) const result = await tool.execute({ path: "../outside/file.ts" }, ctx)
expect(result.success).toBe(false) expect(result.success).toBe(false)
expect(result.error).toBe("Path must be within project root") expect(result.error).toBe("Path contains traversal patterns")
}) })
it("should return error when file not found", async () => { it("should return error when file not found", async () => {

View File

@@ -228,7 +228,7 @@ describe("GetStructureTool", () => {
const result = await tool.execute({ path: "../outside" }, ctx) const result = await tool.execute({ path: "../outside" }, ctx)
expect(result.success).toBe(false) expect(result.success).toBe(false)
expect(result.error).toBe("Path must be within project root") expect(result.error).toBe("Path contains traversal patterns")
}) })
it("should return error for non-directory path", async () => { it("should return error for non-directory path", async () => {

Some files were not shown because too many files have changed in this diff Show More