From 6695cb73d4794ea99e35dec62e885b08a6902eac Mon Sep 17 00:00:00 2001 From: imfozilbek Date: Mon, 1 Dec 2025 21:32:20 +0500 Subject: [PATCH] chore(ipuaro): release v0.20.0 Added IndexProject and ExecuteTool use cases: - IndexProject orchestrates full indexing pipeline - ExecuteTool manages tool execution with confirmation - Refactored CLI index and TUI /reindex commands - Refactored HandleMessage to use ExecuteTool - Added 19 unit tests for IndexProject - All 1463 tests passing, 91.58% branch coverage --- packages/ipuaro/CHANGELOG.md | 46 +++ packages/ipuaro/package.json | 2 +- .../src/application/use-cases/ExecuteTool.ts | 201 +++++++++++ .../application/use-cases/HandleMessage.ts | 111 ++---- .../src/application/use-cases/IndexProject.ts | 184 ++++++++++ .../ipuaro/src/application/use-cases/index.ts | 2 + packages/ipuaro/src/cli/commands/index-cmd.ts | 214 ++++-------- packages/ipuaro/src/tui/App.tsx | 10 +- .../use-cases/IndexProject.test.ts | 317 ++++++++++++++++++ 9 files changed, 840 insertions(+), 247 deletions(-) create mode 100644 packages/ipuaro/src/application/use-cases/ExecuteTool.ts create mode 100644 packages/ipuaro/src/application/use-cases/IndexProject.ts create mode 100644 packages/ipuaro/tests/unit/application/use-cases/IndexProject.test.ts diff --git a/packages/ipuaro/CHANGELOG.md b/packages/ipuaro/CHANGELOG.md index 148c724..aa9d905 100644 --- a/packages/ipuaro/CHANGELOG.md +++ b/packages/ipuaro/CHANGELOG.md @@ -5,6 +5,52 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.20.0] - 2025-12-01 - Missing Use Cases + +### Added + +- **IndexProject Use Case (0.20.1)** + - Full indexing pipeline orchestration in `src/application/use-cases/IndexProject.ts` + - Coordinates FileScanner, ASTParser, MetaAnalyzer, and IndexBuilder + - Progress reporting with phases: scanning, parsing, analyzing, indexing + - Stores file data, ASTs, metadata, symbol index, and dependency graph in Redis + - Returns indexing statistics: filesScanned, filesParsed, parseErrors, timeMs + - 19 unit tests + +- **ExecuteTool Use Case (0.20.2)** + - Tool execution orchestration in `src/application/use-cases/ExecuteTool.ts` + - Parameter validation and error handling + - Confirmation flow management with auto-apply support + - Undo stack management with entry creation + - Returns execution result with undo tracking + - Supports progress callbacks + +### Changed + +- **CLI index Command Refactored** + - Now uses IndexProject use case instead of direct infrastructure calls + - Simplified progress reporting and output formatting + - Better statistics display + +- **TUI /reindex Command Integrated** + - App.tsx reindex function now uses IndexProject use case + - Full project reindexation via slash command + +- **HandleMessage Refactored** + - Now uses ExecuteTool use case for tool execution + - Simplified executeToolCall method (from 35 lines to 24 lines) + - Better separation of concerns: tool execution delegated to ExecuteTool + - Undo entry tracking via undoEntryId + +### Technical Details + +- Total tests: 1463 passed (was 1444, +19 tests) +- Coverage: 97.71% lines, 91.58% branches, 98.97% functions, 97.71% statements +- All existing tests passing after refactoring +- Clean architecture: use cases properly orchestrate infrastructure components + +--- + ## [0.19.0] - 2025-12-01 - XML Tool Format Refactor ### Changed diff --git a/packages/ipuaro/package.json b/packages/ipuaro/package.json index a805020..6bc7c48 100644 --- a/packages/ipuaro/package.json +++ b/packages/ipuaro/package.json @@ -1,6 +1,6 @@ { "name": "@samiyev/ipuaro", - "version": "0.19.0", + "version": "0.20.0", "description": "Local AI agent for codebase operations with infinite context feeling", "author": "Fozilbek Samiyev ", "license": "MIT", diff --git a/packages/ipuaro/src/application/use-cases/ExecuteTool.ts b/packages/ipuaro/src/application/use-cases/ExecuteTool.ts new file mode 100644 index 0000000..448c776 --- /dev/null +++ b/packages/ipuaro/src/application/use-cases/ExecuteTool.ts @@ -0,0 +1,201 @@ +import { randomUUID } from "node:crypto" +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 type { DiffInfo, ToolContext } from "../../domain/services/ITool.js" +import type { ToolCall } from "../../domain/value-objects/ToolCall.js" +import { createErrorResult, type ToolResult } from "../../domain/value-objects/ToolResult.js" +import { createUndoEntry } from "../../domain/value-objects/UndoEntry.js" +import type { IToolRegistry } from "../interfaces/IToolRegistry.js" + +/** + * Confirmation handler callback type. + */ +export type ConfirmationHandler = (message: string, diff?: DiffInfo) => Promise + +/** + * Progress handler callback type. + */ +export type ProgressHandler = (message: string) => void + +/** + * Options for ExecuteTool. + */ +export interface ExecuteToolOptions { + /** Auto-apply edits without confirmation */ + autoApply?: boolean + /** Confirmation handler */ + onConfirmation?: ConfirmationHandler + /** Progress handler */ + onProgress?: ProgressHandler +} + +/** + * Result of tool execution. + */ +export interface ExecuteToolResult { + result: ToolResult + undoEntryCreated: boolean + undoEntryId?: string +} + +/** + * Use case for executing a single tool. + * Orchestrates tool execution with: + * - Parameter validation + * - Confirmation flow + * - Undo stack management + * - Storage updates + */ +export class ExecuteTool { + private readonly storage: IStorage + private readonly sessionStorage: ISessionStorage + private readonly tools: IToolRegistry + private readonly projectRoot: string + private lastUndoEntryId?: string + + constructor( + storage: IStorage, + sessionStorage: ISessionStorage, + tools: IToolRegistry, + projectRoot: string, + ) { + this.storage = storage + this.sessionStorage = sessionStorage + this.tools = tools + this.projectRoot = projectRoot + } + + /** + * Execute a tool call. + * + * @param toolCall - The tool call to execute + * @param session - Current session (for undo stack) + * @param options - Execution options + * @returns Execution result + */ + async execute( + toolCall: ToolCall, + session: Session, + options: ExecuteToolOptions = {}, + ): Promise { + this.lastUndoEntryId = undefined + const startTime = Date.now() + const tool = this.tools.get(toolCall.name) + + if (!tool) { + return { + result: createErrorResult( + toolCall.id, + `Unknown tool: ${toolCall.name}`, + Date.now() - startTime, + ), + undoEntryCreated: false, + } + } + + const validationError = tool.validateParams(toolCall.params) + if (validationError) { + return { + result: createErrorResult(toolCall.id, validationError, Date.now() - startTime), + undoEntryCreated: false, + } + } + + const context = this.buildToolContext(toolCall, session, options) + + try { + const result = await tool.execute(toolCall.params, context) + + return { + result, + undoEntryCreated: this.lastUndoEntryId !== undefined, + undoEntryId: this.lastUndoEntryId, + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + return { + result: createErrorResult(toolCall.id, errorMessage, Date.now() - startTime), + undoEntryCreated: false, + } + } + } + + /** + * Build tool context for execution. + */ + private buildToolContext( + toolCall: ToolCall, + session: Session, + options: ExecuteToolOptions, + ): ToolContext { + return { + projectRoot: this.projectRoot, + storage: this.storage, + requestConfirmation: async (msg: string, diff?: DiffInfo) => { + return this.handleConfirmation(msg, diff, toolCall, session, options) + }, + onProgress: (msg: string) => { + options.onProgress?.(msg) + }, + } + } + + /** + * Handle confirmation for tool actions. + */ + private async handleConfirmation( + msg: string, + diff: DiffInfo | undefined, + toolCall: ToolCall, + session: Session, + options: ExecuteToolOptions, + ): Promise { + if (options.autoApply) { + if (diff) { + this.lastUndoEntryId = await this.createUndoEntry(diff, toolCall, session) + } + return true + } + + if (options.onConfirmation) { + const confirmed = await options.onConfirmation(msg, diff) + + if (confirmed && diff) { + this.lastUndoEntryId = await this.createUndoEntry(diff, toolCall, session) + } + + return confirmed + } + + if (diff) { + this.lastUndoEntryId = await this.createUndoEntry(diff, toolCall, session) + } + return true + } + + /** + * Create undo entry from diff. + */ + private async createUndoEntry( + diff: DiffInfo, + toolCall: ToolCall, + session: Session, + ): Promise { + const entryId = randomUUID() + const entry = createUndoEntry( + entryId, + diff.filePath, + diff.oldLines, + diff.newLines, + `${toolCall.name}: ${diff.filePath}`, + toolCall.id, + ) + + session.addUndoEntry(entry) + await this.sessionStorage.pushUndoEntry(session.id, entry) + session.stats.editsApplied++ + + return entryId + } +} diff --git a/packages/ipuaro/src/application/use-cases/HandleMessage.ts b/packages/ipuaro/src/application/use-cases/HandleMessage.ts index b96b11e..89aa835 100644 --- a/packages/ipuaro/src/application/use-cases/HandleMessage.ts +++ b/packages/ipuaro/src/application/use-cases/HandleMessage.ts @@ -1,9 +1,8 @@ -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 { DiffInfo } from "../../domain/services/ITool.js" import { type ChatMessage, createAssistantMessage, @@ -12,8 +11,8 @@ import { 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 { ToolResult } from "../../domain/value-objects/ToolResult.js" +import type { UndoEntry } from "../../domain/value-objects/UndoEntry.js" import { type ErrorOption, IpuaroError } from "../../shared/errors/IpuaroError.js" import { buildInitialContext, @@ -23,6 +22,7 @@ import { import { parseToolCalls } from "../../infrastructure/llm/ResponseParser.js" import type { IToolRegistry } from "../interfaces/IToolRegistry.js" import { ContextManager } from "./ContextManager.js" +import { ExecuteTool } from "./ExecuteTool.js" /** * Status during message handling. @@ -82,6 +82,7 @@ export class HandleMessage { private readonly llm: ILLMClient private readonly tools: IToolRegistry private readonly contextManager: ContextManager + private readonly executeTool: ExecuteTool private readonly projectRoot: string private projectStructure?: ProjectStructure @@ -102,6 +103,7 @@ export class HandleMessage { this.tools = tools this.projectRoot = projectRoot this.contextManager = new ContextManager(llm.getContextWindowSize()) + this.executeTool = new ExecuteTool(storage, sessionStorage, tools, projectRoot) } /** @@ -257,87 +259,32 @@ export class HandleMessage { } private async executeToolCall(toolCall: ToolCall, session: Session): Promise { - 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) + const { result, undoEntryCreated, undoEntryId } = await this.executeTool.execute( + toolCall, + session, + { + autoApply: this.options.autoApply, + onConfirmation: async (msg: string, diff?: DiffInfo) => { + this.emitStatus("awaiting_confirmation") + if (this.events.onConfirmation) { + return this.events.onConfirmation(msg, diff) + } + return true + }, + onProgress: (_msg: string) => { + this.events.onStatusChange?.("tool_call") + }, }, - 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 { - 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) + if (undoEntryCreated && undoEntryId) { + const undoEntry = session.undoStack.find((entry) => entry.id === undoEntryId) + if (undoEntry) { + this.events.onUndoEntry?.(undoEntry) + } + } + + return result } private async handleLLMError(error: unknown, session: Session): Promise { diff --git a/packages/ipuaro/src/application/use-cases/IndexProject.ts b/packages/ipuaro/src/application/use-cases/IndexProject.ts new file mode 100644 index 0000000..ce1cef8 --- /dev/null +++ b/packages/ipuaro/src/application/use-cases/IndexProject.ts @@ -0,0 +1,184 @@ +import * as path from "node:path" +import type { IStorage } from "../../domain/services/IStorage.js" +import type { IndexingStats, IndexProgress } from "../../domain/services/IIndexer.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, type FileData } from "../../domain/value-objects/FileData.js" +import type { FileAST } from "../../domain/value-objects/FileAST.js" +import { md5 } from "../../shared/utils/hash.js" + +/** + * Options for indexing a project. + */ +export interface IndexProjectOptions { + /** Additional ignore patterns */ + additionalIgnore?: string[] + /** Progress callback */ + onProgress?: (progress: IndexProgress) => void +} + +/** + * Use case for indexing a project. + * Orchestrates the full indexing pipeline: + * 1. Scan files + * 2. Parse AST + * 3. Analyze metadata + * 4. Build indexes + * 5. Store in Redis + */ +export class IndexProject { + private readonly storage: IStorage + private readonly scanner: FileScanner + private readonly parser: ASTParser + private readonly metaAnalyzer: MetaAnalyzer + private readonly indexBuilder: IndexBuilder + + constructor(storage: IStorage, projectRoot: string) { + this.storage = storage + this.scanner = new FileScanner() + this.parser = new ASTParser() + this.metaAnalyzer = new MetaAnalyzer(projectRoot) + this.indexBuilder = new IndexBuilder(projectRoot) + } + + /** + * Execute the indexing pipeline. + * + * @param projectRoot - Absolute path to project root + * @param options - Optional configuration + * @returns Indexing statistics + */ + async execute(projectRoot: string, options: IndexProjectOptions = {}): Promise { + const startTime = Date.now() + const stats: IndexingStats = { + filesScanned: 0, + filesParsed: 0, + parseErrors: 0, + timeMs: 0, + } + + const fileDataMap = new Map() + const astMap = new Map() + const contentMap = new Map() + + // Phase 1: Scanning + this.reportProgress(options.onProgress, 0, 0, "", "scanning") + + const scanResults = await this.scanner.scanAll(projectRoot) + stats.filesScanned = scanResults.length + + // Phase 2: Parsing + let current = 0 + const total = scanResults.length + + for (const scanResult of scanResults) { + current++ + const fullPath = path.join(projectRoot, scanResult.path) + this.reportProgress(options.onProgress, current, total, scanResult.path, "parsing") + + const content = await FileScanner.readFileContent(fullPath) + if (!content) { + continue + } + + contentMap.set(scanResult.path, content) + + const lines = content.split("\n") + const hash = md5(content) + + const fileData = createFileData(lines, hash, scanResult.size, scanResult.lastModified) + fileDataMap.set(scanResult.path, fileData) + + const language = this.detectLanguage(scanResult.path) + if (!language) { + continue + } + + const ast = this.parser.parse(content, language) + astMap.set(scanResult.path, ast) + + stats.filesParsed++ + if (ast.parseError) { + stats.parseErrors++ + } + } + + // Phase 3: Analyzing metadata + current = 0 + for (const [filePath, ast] of astMap) { + current++ + this.reportProgress(options.onProgress, current, astMap.size, filePath, "analyzing") + + const content = contentMap.get(filePath) + if (!content) { + continue + } + + const fullPath = path.join(projectRoot, filePath) + const meta = this.metaAnalyzer.analyze(fullPath, ast, content, astMap) + + await this.storage.setMeta(filePath, meta) + } + + // Phase 4: Building indexes + this.reportProgress(options.onProgress, 1, 1, "Building indexes", "indexing") + + const symbolIndex = this.indexBuilder.buildSymbolIndex(astMap) + const depsGraph = this.indexBuilder.buildDepsGraph(astMap) + + // Phase 5: Store everything + for (const [filePath, fileData] of fileDataMap) { + await this.storage.setFile(filePath, fileData) + } + + for (const [filePath, ast] of astMap) { + await this.storage.setAST(filePath, ast) + } + + await this.storage.setSymbolIndex(symbolIndex) + await this.storage.setDepsGraph(depsGraph) + + // Store last indexed timestamp + await this.storage.setProjectConfig("last_indexed", Date.now()) + + stats.timeMs = Date.now() - startTime + + return stats + } + + /** + * Detect language from file extension. + */ + private detectLanguage(filePath: string): "ts" | "tsx" | "js" | "jsx" | 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 + } + } + + /** + * Report progress to callback if provided. + */ + private reportProgress( + callback: ((progress: IndexProgress) => void) | undefined, + current: number, + total: number, + currentFile: string, + phase: IndexProgress["phase"], + ): void { + if (callback) { + callback({ current, total, currentFile, phase }) + } + } +} diff --git a/packages/ipuaro/src/application/use-cases/index.ts b/packages/ipuaro/src/application/use-cases/index.ts index c9968a7..061f498 100644 --- a/packages/ipuaro/src/application/use-cases/index.ts +++ b/packages/ipuaro/src/application/use-cases/index.ts @@ -4,3 +4,5 @@ export * from "./StartSession.js" export * from "./HandleMessage.js" export * from "./UndoChange.js" export * from "./ContextManager.js" +export * from "./IndexProject.js" +export * from "./ExecuteTool.js" diff --git a/packages/ipuaro/src/cli/commands/index-cmd.ts b/packages/ipuaro/src/cli/commands/index-cmd.ts index 4ac4bb3..afdf642 100644 --- a/packages/ipuaro/src/cli/commands/index-cmd.ts +++ b/packages/ipuaro/src/cli/commands/index-cmd.ts @@ -3,23 +3,14 @@ * 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 { IndexProject } from "../../application/use-cases/IndexProject.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. */ @@ -52,7 +43,6 @@ export async function executeIndex( 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`) @@ -76,142 +66,69 @@ export async function executeIndex( 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 indexProject = new IndexProject(storage, resolvedPath) + + let lastPhase: "scanning" | "parsing" | "analyzing" | "indexing" = "scanning" + let lastProgress = 0 + + const stats = await indexProject.execute(resolvedPath, { + onProgress: (progress) => { + if (progress.phase !== lastPhase) { + if (lastPhase === "scanning") { + console.warn(` Found ${String(progress.total)} files\n`) + } else if (lastProgress > 0) { + console.warn("") + } + + const phaseLabels = { + scanning: "šŸ” Scanning files...", + parsing: "šŸ“ Parsing files...", + analyzing: "šŸ“Š Analyzing metadata...", + indexing: "šŸ—ļø Building indexes...", + } + console.warn(phaseLabels[progress.phase]) + lastPhase = progress.phase + } + + if (progress.phase === "indexing") { + onProgress?.("storing", progress.current, progress.total) + } else { + onProgress?.( + progress.phase, + progress.current, + progress.total, + progress.currentFile, + ) + } + + if ( + progress.current % 50 === 0 && + progress.phase !== "scanning" && + progress.phase !== "indexing" + ) { + process.stdout.write( + `\r ${progress.phase === "parsing" ? "Parsed" : "Analyzed"} ${String(progress.current)}/${String(progress.total)} files...`, + ) + } + lastProgress = progress.current }, }) - 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`) + const symbolIndex = await storage.getSymbolIndex() + const durationSec = (stats.timeMs / 1000).toFixed(2) - 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() - const fileContents = new Map() - 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(`\nāœ… Indexing complete in ${durationSec}s`) + console.warn(` Files scanned: ${String(stats.filesScanned)}`) + console.warn(` Files parsed: ${String(stats.filesParsed)}`) + console.warn(` Parse errors: ${String(stats.parseErrors)}`) 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, + filesIndexed: stats.filesParsed, + filesSkipped: stats.filesScanned - stats.filesParsed, + errors: [], + duration: stats.timeMs, } } catch (error) { const message = error instanceof Error ? error.message : String(error) @@ -229,22 +146,3 @@ export async function executeIndex( } } } - -/** - * 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 - } -} diff --git a/packages/ipuaro/src/tui/App.tsx b/packages/ipuaro/src/tui/App.tsx index be4fe4b..a965f29 100644 --- a/packages/ipuaro/src/tui/App.tsx +++ b/packages/ipuaro/src/tui/App.tsx @@ -90,12 +90,10 @@ export function App({ ) const reindex = useCallback(async (): Promise => { - /* - * TODO: Implement full reindex via IndexProject use case - * For now, this is a placeholder - */ - await Promise.resolve() - }, []) + const { IndexProject } = await import("../application/use-cases/IndexProject.js") + const indexProject = new IndexProject(deps.storage, projectPath) + await indexProject.execute(projectPath) + }, [deps.storage, projectPath]) const { executeCommand, isCommand } = useCommands( { diff --git a/packages/ipuaro/tests/unit/application/use-cases/IndexProject.test.ts b/packages/ipuaro/tests/unit/application/use-cases/IndexProject.test.ts new file mode 100644 index 0000000..d4a03e8 --- /dev/null +++ b/packages/ipuaro/tests/unit/application/use-cases/IndexProject.test.ts @@ -0,0 +1,317 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { IndexProject } from "../../../../src/application/use-cases/IndexProject.js" +import type { IStorage, SymbolIndex, DepsGraph } from "../../../../src/domain/services/IStorage.js" +import type { IndexProgress } from "../../../../src/domain/services/IIndexer.js" +import { createFileData } from "../../../../src/domain/value-objects/FileData.js" +import { createEmptyFileAST } from "../../../../src/domain/value-objects/FileAST.js" +import { createFileMeta } from "../../../../src/domain/value-objects/FileMeta.js" + +vi.mock("../../../../src/infrastructure/indexer/FileScanner.js", () => ({ + FileScanner: class { + async scanAll() { + return [ + { path: "src/index.ts", type: "file", size: 100, lastModified: Date.now() }, + { path: "src/utils.ts", type: "file", size: 200, lastModified: Date.now() }, + ] + } + static async readFileContent(path: string) { + if (path.includes("index.ts")) { + return 'export function main() { return "hello" }' + } + if (path.includes("utils.ts")) { + return 'export const add = (a: number, b: number) => a + b' + } + return null + } + }, +})) + +vi.mock("../../../../src/infrastructure/indexer/ASTParser.js", () => ({ + ASTParser: class { + parse() { + return { + ...createEmptyFileAST(), + functions: [{ name: "test", lineStart: 1, lineEnd: 5, params: [], isAsync: false, isExported: true }], + } + } + }, +})) + +vi.mock("../../../../src/infrastructure/indexer/MetaAnalyzer.js", () => ({ + MetaAnalyzer: class { + constructor() {} + analyze() { + return createFileMeta() + } + }, +})) + +vi.mock("../../../../src/infrastructure/indexer/IndexBuilder.js", () => ({ + IndexBuilder: class { + constructor() {} + buildSymbolIndex() { + return new Map([ + ["test", [{ path: "src/index.ts", line: 1, type: "function" }]], + ]) as SymbolIndex + } + buildDepsGraph() { + return { + imports: new Map(), + importedBy: new Map(), + } as DepsGraph + } + }, +})) + +describe("IndexProject", () => { + let useCase: IndexProject + let mockStorage: IStorage + + 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), + } + + useCase = new IndexProject(mockStorage, "/test/project") + }) + + describe("execute", () => { + it("should index project and return stats", async () => { + const stats = await useCase.execute("/test/project") + + expect(stats.filesScanned).toBe(2) + expect(stats.filesParsed).toBe(2) + expect(stats.parseErrors).toBe(0) + expect(stats.timeMs).toBeGreaterThanOrEqual(0) + }) + + it("should store file data for all scanned files", async () => { + await useCase.execute("/test/project") + + expect(mockStorage.setFile).toHaveBeenCalledTimes(2) + expect(mockStorage.setFile).toHaveBeenCalledWith( + "src/index.ts", + expect.objectContaining({ + hash: expect.any(String), + lines: expect.any(Array), + }) + ) + }) + + it("should store AST for all parsed files", async () => { + await useCase.execute("/test/project") + + expect(mockStorage.setAST).toHaveBeenCalledTimes(2) + expect(mockStorage.setAST).toHaveBeenCalledWith( + "src/index.ts", + expect.objectContaining({ + functions: expect.any(Array), + }) + ) + }) + + it("should store metadata for all files", async () => { + await useCase.execute("/test/project") + + expect(mockStorage.setMeta).toHaveBeenCalledTimes(2) + expect(mockStorage.setMeta).toHaveBeenCalledWith( + "src/index.ts", + expect.any(Object) + ) + }) + + it("should build and store symbol index", async () => { + await useCase.execute("/test/project") + + expect(mockStorage.setSymbolIndex).toHaveBeenCalledTimes(1) + expect(mockStorage.setSymbolIndex).toHaveBeenCalledWith( + expect.any(Map) + ) + }) + + it("should build and store dependency graph", async () => { + await useCase.execute("/test/project") + + expect(mockStorage.setDepsGraph).toHaveBeenCalledTimes(1) + expect(mockStorage.setDepsGraph).toHaveBeenCalledWith( + expect.objectContaining({ + imports: expect.any(Map), + importedBy: expect.any(Map), + }) + ) + }) + + it("should store last indexed timestamp", async () => { + await useCase.execute("/test/project") + + expect(mockStorage.setProjectConfig).toHaveBeenCalledWith( + "last_indexed", + expect.any(Number) + ) + }) + + it("should call progress callback during indexing", async () => { + const progressCallback = vi.fn() + + await useCase.execute("/test/project", { + onProgress: progressCallback, + }) + + expect(progressCallback).toHaveBeenCalled() + expect(progressCallback).toHaveBeenCalledWith( + expect.objectContaining({ + current: expect.any(Number), + total: expect.any(Number), + currentFile: expect.any(String), + phase: expect.stringMatching(/scanning|parsing|analyzing|indexing/), + }) + ) + }) + + it("should report scanning phase", async () => { + const progressCallback = vi.fn() + + await useCase.execute("/test/project", { + onProgress: progressCallback, + }) + + const scanningCalls = progressCallback.mock.calls.filter( + (call) => call[0].phase === "scanning" + ) + expect(scanningCalls.length).toBeGreaterThan(0) + }) + + it("should report parsing phase", async () => { + const progressCallback = vi.fn() + + await useCase.execute("/test/project", { + onProgress: progressCallback, + }) + + const parsingCalls = progressCallback.mock.calls.filter( + (call) => call[0].phase === "parsing" + ) + expect(parsingCalls.length).toBeGreaterThan(0) + }) + + it("should report analyzing phase", async () => { + const progressCallback = vi.fn() + + await useCase.execute("/test/project", { + onProgress: progressCallback, + }) + + const analyzingCalls = progressCallback.mock.calls.filter( + (call) => call[0].phase === "analyzing" + ) + expect(analyzingCalls.length).toBeGreaterThan(0) + }) + + it("should report indexing phase", async () => { + const progressCallback = vi.fn() + + await useCase.execute("/test/project", { + onProgress: progressCallback, + }) + + const indexingCalls = progressCallback.mock.calls.filter( + (call) => call[0].phase === "indexing" + ) + expect(indexingCalls.length).toBeGreaterThan(0) + }) + + it("should detect TypeScript files", async () => { + await useCase.execute("/test/project") + + expect(mockStorage.setAST).toHaveBeenCalledWith( + "src/index.ts", + expect.any(Object) + ) + }) + + it("should handle files without parseable language", async () => { + vi.mocked(mockStorage.setFile).mockClear() + + await useCase.execute("/test/project") + + const stats = await useCase.execute("/test/project") + expect(stats.filesScanned).toBeGreaterThanOrEqual(0) + }) + + it("should calculate indexing duration", async () => { + const startTime = Date.now() + const stats = await useCase.execute("/test/project") + const endTime = Date.now() + + expect(stats.timeMs).toBeGreaterThanOrEqual(0) + expect(stats.timeMs).toBeLessThanOrEqual(endTime - startTime + 10) + }) + }) + + describe("language detection", () => { + it("should detect .ts files", async () => { + await useCase.execute("/test/project") + + expect(mockStorage.setAST).toHaveBeenCalledWith( + expect.stringContaining(".ts"), + expect.any(Object) + ) + }) + }) + + describe("progress reporting", () => { + it("should not fail if progress callback is not provided", async () => { + await expect(useCase.execute("/test/project")).resolves.toBeDefined() + }) + + it("should include current file in progress updates", async () => { + const progressCallback = vi.fn() + + await useCase.execute("/test/project", { + onProgress: progressCallback, + }) + + const callsWithFiles = progressCallback.mock.calls.filter( + (call) => call[0].currentFile && call[0].currentFile.length > 0 + ) + expect(callsWithFiles.length).toBeGreaterThan(0) + }) + + it("should report correct total count", async () => { + const progressCallback = vi.fn() + + await useCase.execute("/test/project", { + onProgress: progressCallback, + }) + + const parsingCalls = progressCallback.mock.calls.filter( + (call) => call[0].phase === "parsing" + ) + if (parsingCalls.length > 0) { + expect(parsingCalls[0][0].total).toBe(2) + } + }) + }) +})