mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
Compare commits
3 Commits
ipuaro-v0.
...
ipuaro-v0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6695cb73d4 | ||
|
|
5a9470929c | ||
|
|
137c77cc53 |
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@samiyev/ipuaro",
|
||||
"version": "0.18.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",
|
||||
@@ -8,7 +8,7 @@
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"bin": {
|
||||
"ipuaro": "./bin/ipuaro.js"
|
||||
"ipuaro": "bin/ipuaro.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
|
||||
201
packages/ipuaro/src/application/use-cases/ExecuteTool.ts
Normal file
201
packages/ipuaro/src/application/use-cases/ExecuteTool.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 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<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> {
|
||||
|
||||
184
packages/ipuaro/src/application/use-cases/IndexProject.ts
Normal file
184
packages/ipuaro/src/application/use-cases/IndexProject.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user