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
This commit is contained in:
imfozilbek
2025-12-01 21:32:20 +05:00
parent 5a9470929c
commit 6695cb73d4
9 changed files with 840 additions and 247 deletions

View File

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

View File

@@ -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 <fozilbek.samiyev@gmail.com>",
"license": "MIT",

View File

@@ -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<boolean>
/**
* 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<ExecuteToolResult> {
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<boolean> {
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<string> {
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
}
}

View File

@@ -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<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 { 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)
}
const context: ToolContext = {
projectRoot: this.projectRoot,
storage: this.storage,
requestConfirmation: async (msg: string, diff?: DiffInfo) => {
return this.handleConfirmation(msg, diff, toolCall, session)
return true
},
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)
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<void> {

View File

@@ -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<IndexingStats> {
const startTime = Date.now()
const stats: IndexingStats = {
filesScanned: 0,
filesParsed: 0,
parseErrors: 0,
timeMs: 0,
}
const fileDataMap = new Map<string, FileData>()
const astMap = new Map<string, FileAST>()
const contentMap = new Map<string, string>()
// 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 })
}
}
}

View File

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

View File

@@ -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<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(`\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
}
}

View File

@@ -90,12 +90,10 @@ export function App({
)
const reindex = useCallback(async (): Promise<void> => {
/*
* 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(
{

View File

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