From e6482fb1c425021e6359dbe0f9ee3fba49c29c46 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Sat, 14 Feb 2026 22:41:06 +0000 Subject: [PATCH 1/3] refactor(main): extract repository analyze/process services --- src/main/index.ts | 318 ++---------------- src/main/services/repository-analyzer.ts | 104 ++++++ src/main/services/repository-processing.ts | 262 +++++++++++++++ tests/catalog.md | 2 + tests/unit/main/repository-processing.test.ts | 129 +++++++ 5 files changed, 522 insertions(+), 293 deletions(-) create mode 100644 src/main/services/repository-analyzer.ts create mode 100644 src/main/services/repository-processing.ts create mode 100644 tests/unit/main/repository-processing.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index ea1a936..06b0af5 100755 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,18 +4,9 @@ import path from 'path'; import { app, BrowserWindow, dialog, ipcMain, net, protocol } from 'electron'; import { autoUpdater } from 'electron-updater'; -import yaml from 'yaml'; import { loadDefaultConfig } from '../utils/config-manager'; -import { ContentProcessor } from '../utils/content-processor'; -import { - normalizeExportFormat, - normalizeTokenCount, - toXmlNumericAttribute, - wrapXmlCdata, -} from '../utils/export-format'; -import { FileAnalyzer, isBinaryFile } from '../utils/file-analyzer'; -import { getRelativePath } from '../utils/filter-utils'; +import { isBinaryFile } from '../utils/file-analyzer'; import { GitignoreParser } from '../utils/gitignore-parser'; import { TokenCounter } from '../utils/token-counter'; @@ -28,15 +19,15 @@ import { } from './security/path-guard'; import { getDirectoryTree } from './services/directory-tree'; import { testProviderConnection } from './services/provider-connection'; +import { analyzeRepository } from './services/repository-analyzer'; +import { processRepository } from './services/repository-processing'; import { createUpdaterService, resolveUpdaterRuntimeOptions } from './updater'; import type { AnalyzeRepositoryOptions, AnalyzeRepositoryResult, - ConfigObject, CountFilesTokensOptions, CountFilesTokensResult, - FileInfo, ProviderConnectionOptions, ProviderConnectionResult, ProcessRepositoryOptions, @@ -282,76 +273,18 @@ ipcMain.handle( throw new Error('Unauthorized root path. Please select the directory again.'); } - const config = (yaml.parse(configContent) || {}) as ConfigObject; - const localTokenCounter = new TokenCounter(); - - // Process gitignore if enabled - let gitignorePatterns = { excludePatterns: [], includePatterns: [] }; - if (config.use_gitignore !== false) { - gitignorePatterns = gitignoreParser.parseGitignore(authorizedAnalyzeRoot); - } - - // Create a file analyzer instance with the appropriate settings - const fileAnalyzer = new FileAnalyzer(config, localTokenCounter, { - useGitignore: config.use_gitignore !== false, - gitignorePatterns: gitignorePatterns, + return analyzeRepository({ + rootPath: authorizedAnalyzeRoot, + configContent, + selectedFiles, + gitignoreParser, + onWarn: (message: string) => { + console.warn(message); + }, + onInfo: (message: string) => { + console.info(message); + }, }); - - // If selectedFiles is provided, only analyze those files - const filesInfo: FileInfo[] = []; - let totalTokens = 0; - let skippedBinaryFiles = 0; - - for (const filePath of selectedFiles) { - const resolvedFilePath = path.resolve(authorizedAnalyzeRoot, filePath); - - // Verify the file is within the current root path - if (!isPathWithinRoot(authorizedAnalyzeRoot, resolvedFilePath)) { - console.warn(`Skipping file outside current root directory: ${filePath}`); - continue; - } - - // Use consistent path normalization - const relativePath = getRelativePath(resolvedFilePath, authorizedAnalyzeRoot); - - // For binary files, record them as skipped but don't prevent selection - const binaryFile = isBinaryFile(resolvedFilePath); - if (binaryFile) { - console.log(`Binary file detected (will skip processing): ${relativePath}`); - skippedBinaryFiles++; - } - - if (binaryFile) { - // For binary files, add to filesInfo but with zero tokens and a flag - filesInfo.push({ - path: relativePath, - tokens: 0, - isBinary: true, - }); - } else if (fileAnalyzer.shouldProcessFile(relativePath)) { - const tokenCount = fileAnalyzer.analyzeFile(resolvedFilePath); - - if (tokenCount !== null) { - filesInfo.push({ - path: relativePath, - tokens: tokenCount, - }); - - totalTokens += tokenCount; - } - } - } - - // Sort by token count - filesInfo.sort((a, b) => b.tokens - a.tokens); - - console.log(`Skipped ${skippedBinaryFiles} binary files during analysis`); - - return { - filesInfo, - totalTokens, - skippedBinaryFiles, - }; } catch (error) { console.error('Error analyzing repository:', error); throw error; @@ -359,165 +292,6 @@ ipcMain.handle( } ); -// Helper function to generate tree view from filesInfo -function generateTreeView(filesInfo: FileInfo[]): string { - if (!filesInfo || !Array.isArray(filesInfo)) { - return ''; - } - - // Generate a more structured tree view from filesInfo - const sortedFiles = [...filesInfo].sort((a, b) => a.path.localeCompare(b.path)); - - // Build a path tree - interface PathTree { - [key: string]: PathTree | null; - } - const pathTree: PathTree = {}; - sortedFiles.forEach((file) => { - if (!file?.path) return; - - const parts = file.path.split('/'); - let currentLevel: PathTree = pathTree; - - parts.forEach((part, index) => { - if (!currentLevel[part]) { - currentLevel[part] = index === parts.length - 1 ? null : {}; - } - - if (index < parts.length - 1) { - const nextLevel = currentLevel[part]; - if (nextLevel) { - currentLevel = nextLevel; - } - } - }); - }); - - // Recursive function to print the tree - const printTree = (tree: PathTree, prefix = '', _isLast = true): string => { - const entries = Object.entries(tree); - let result = ''; - - entries.forEach(([key, value], index) => { - const isLastItem = index === entries.length - 1; - - // Print current level - result += `${prefix}${isLastItem ? '└── ' : '├── '}${key}\n`; - - // Print children - if (value !== null) { - const newPrefix = `${prefix}${isLastItem ? ' ' : '│ '}`; - result += printTree(value, newPrefix, isLastItem); - } - }); - - return result; - }; - - return printTree(pathTree); -} - -type RepositoryProcessingOptions = { - showTokenCount: boolean; - includeTreeView: boolean; - exportFormat: ReturnType; -}; - -type ProcessedRepositoryFileResult = { - content: string; - tokenCount: number; -} | null; - -const resolveRepositoryProcessingOptions = ( - options: ProcessRepositoryOptions['options'] = {} -): RepositoryProcessingOptions => ({ - showTokenCount: options.showTokenCount !== false, - includeTreeView: options.includeTreeView === true, - exportFormat: normalizeExportFormat(options.exportFormat), -}); - -const buildRepositoryHeader = ( - processingOptions: RepositoryProcessingOptions, - treeView: string | undefined, - filesInfo: FileInfo[] -): string => { - let header = - processingOptions.exportFormat === 'xml' - ? '\n\n' - : '# Repository Content\n\n'; - - if (processingOptions.includeTreeView) { - const resolvedTreeView = treeView || generateTreeView(filesInfo); - if (processingOptions.exportFormat === 'xml') { - header += `${wrapXmlCdata(resolvedTreeView)}\n`; - } else { - header += '## File Structure\n\n'; - header += '```\n'; - header += resolvedTreeView; - header += '```\n\n'; - } - } - - if (processingOptions.exportFormat === 'markdown' && processingOptions.includeTreeView) { - header += '## File Contents\n\n'; - } - - if (processingOptions.exportFormat === 'xml') { - header += '\n'; - } - - return header; -}; - -const processRepositoryFile = ( - authorizedProcessRoot: string, - fileInfo: FileInfo, - contentProcessor: ContentProcessor, - processingOptions: RepositoryProcessingOptions -): ProcessedRepositoryFileResult => { - const filePath = fileInfo.path; - const tokenCount = normalizeTokenCount(fileInfo.tokens); - const fullPath = path.resolve(authorizedProcessRoot, filePath); - - if (!isPathWithinRoot(authorizedProcessRoot, fullPath)) { - console.warn(`Skipping file outside root directory: ${filePath}`); - return null; - } - - if (!fs.existsSync(fullPath)) { - console.warn(`File not found: ${filePath}`); - return null; - } - - const content = contentProcessor.processFile(fullPath, filePath, { - exportFormat: processingOptions.exportFormat, - showTokenCount: processingOptions.showTokenCount, - tokenCount, - }); - if (!content) { - return null; - } - - return { content, tokenCount }; -}; - -const buildRepositoryFooter = ( - processingOptions: RepositoryProcessingOptions, - summary: { totalTokens: number; processedFiles: number; skippedFiles: number } -): string => { - if (processingOptions.exportFormat === 'xml') { - return ( - '\n' + - `\n` + - '\n' - ); - } - - return '\n--END--\n'; -}; - // Process repository ipcMain.handle( 'repo:process', @@ -531,60 +305,18 @@ ipcMain.handle( throw new Error('Unauthorized root path. Please select the directory again.'); } - const tokenCounter = new TokenCounter(); - const contentProcessor = new ContentProcessor(tokenCounter); - const processingOptions = resolveRepositoryProcessingOptions(options); - - console.log('Processing with options:', processingOptions); - - const normalizedFilesInfo = filesInfo ?? []; - let processedContent = buildRepositoryHeader(processingOptions, treeView, normalizedFilesInfo); - - let totalTokens = 0; - let processedFiles = 0; - let skippedFiles = 0; - - for (const fileInfo of normalizedFilesInfo) { - if (!fileInfo?.path) { - console.warn('Skipping invalid file info entry'); - skippedFiles++; - continue; - } - - try { - const processedFile = processRepositoryFile( - authorizedProcessRoot, - fileInfo, - contentProcessor, - processingOptions - ); - if (!processedFile) { - skippedFiles++; - continue; - } - - processedContent += processedFile.content; - totalTokens += processedFile.tokenCount; - processedFiles++; - } catch (error) { - console.warn(`Failed to process file: ${getErrorMessage(error)}`); - skippedFiles++; - } - } - processedContent += buildRepositoryFooter(processingOptions, { - totalTokens, - processedFiles, - skippedFiles, + return processRepository({ + rootPath: authorizedProcessRoot, + filesInfo, + treeView, + options, + onWarn: (message: string) => { + console.warn(message); + }, + onInfo: (message: string, metadata?: unknown) => { + console.info(message, metadata); + }, }); - - return { - content: processedContent, - exportFormat: processingOptions.exportFormat, - totalTokens, - processedFiles, - skippedFiles, - filesInfo: normalizedFilesInfo, - }; } catch (error) { console.error('Error processing repository:', error); throw error; diff --git a/src/main/services/repository-analyzer.ts b/src/main/services/repository-analyzer.ts new file mode 100644 index 0000000..7e7b2cb --- /dev/null +++ b/src/main/services/repository-analyzer.ts @@ -0,0 +1,104 @@ +import path from 'path'; + +import yaml from 'yaml'; + +import { FileAnalyzer, isBinaryFile } from '../../utils/file-analyzer'; +import { getRelativePath } from '../../utils/filter-utils'; +import { TokenCounter } from '../../utils/token-counter'; +import { isPathWithinRoot } from '../security/path-guard'; + +import type { AnalyzeRepositoryResult, ConfigObject, FileInfo } from '../../types/ipc'; + +type GitignorePatterns = { + excludePatterns: string[]; + includePatterns: string[]; +}; + +type GitignoreParserLike = { + parseGitignore: (rootPath: string) => GitignorePatterns; +}; + +type AnalyzeRepositoryInput = { + rootPath: string; + configContent: string; + selectedFiles: string[]; + gitignoreParser: GitignoreParserLike; + onWarn?: (message: string) => void; + onInfo?: (message: string) => void; +}; + +const EMPTY_GITIGNORE_PATTERNS: GitignorePatterns = { + excludePatterns: [], + includePatterns: [], +}; + +export const analyzeRepository = ({ + rootPath, + configContent, + selectedFiles, + gitignoreParser, + onWarn, + onInfo, +}: AnalyzeRepositoryInput): AnalyzeRepositoryResult => { + const config = (yaml.parse(configContent) || {}) as ConfigObject; + const localTokenCounter = new TokenCounter(); + + let gitignorePatterns = EMPTY_GITIGNORE_PATTERNS; + if (config.use_gitignore !== false) { + gitignorePatterns = gitignoreParser.parseGitignore(rootPath); + } + + const fileAnalyzer = new FileAnalyzer(config, localTokenCounter, { + useGitignore: config.use_gitignore !== false, + gitignorePatterns, + }); + + const filesInfo: FileInfo[] = []; + let totalTokens = 0; + let skippedBinaryFiles = 0; + + for (const filePath of selectedFiles) { + const resolvedFilePath = path.resolve(rootPath, filePath); + if (!isPathWithinRoot(rootPath, resolvedFilePath)) { + onWarn?.(`Skipping file outside current root directory: ${filePath}`); + continue; + } + + const relativePath = getRelativePath(resolvedFilePath, rootPath); + const binaryFile = isBinaryFile(resolvedFilePath); + if (binaryFile) { + onInfo?.(`Binary file detected (will skip processing): ${relativePath}`); + skippedBinaryFiles++; + filesInfo.push({ + path: relativePath, + tokens: 0, + isBinary: true, + }); + continue; + } + + if (!fileAnalyzer.shouldProcessFile(relativePath)) { + continue; + } + + const tokenCount = fileAnalyzer.analyzeFile(resolvedFilePath); + if (tokenCount === null) { + continue; + } + + filesInfo.push({ + path: relativePath, + tokens: tokenCount, + }); + totalTokens += tokenCount; + } + + filesInfo.sort((a, b) => b.tokens - a.tokens); + onInfo?.(`Skipped ${skippedBinaryFiles} binary files during analysis`); + + return { + filesInfo, + totalTokens, + skippedBinaryFiles, + }; +}; diff --git a/src/main/services/repository-processing.ts b/src/main/services/repository-processing.ts new file mode 100644 index 0000000..cd127f4 --- /dev/null +++ b/src/main/services/repository-processing.ts @@ -0,0 +1,262 @@ +import fs from 'fs'; +import path from 'path'; + +import { ContentProcessor } from '../../utils/content-processor'; +import { + normalizeExportFormat, + normalizeTokenCount, + toXmlNumericAttribute, + wrapXmlCdata, +} from '../../utils/export-format'; +import { TokenCounter } from '../../utils/token-counter'; +import { getErrorMessage } from '../errors'; +import { isPathWithinRoot } from '../security/path-guard'; + +import type { FileInfo, ProcessRepositoryOptions, ProcessRepositoryResult } from '../../types/ipc'; + +type RepositoryProcessingOptions = { + showTokenCount: boolean; + includeTreeView: boolean; + exportFormat: ReturnType; +}; + +type ProcessedRepositoryFileResult = { + content: string; + tokenCount: number; +} | null; + +type ProcessRepositoryInput = { + rootPath: string; + filesInfo: FileInfo[] | undefined; + treeView?: string | null; + options?: ProcessRepositoryOptions['options']; + onWarn?: (message: string) => void; + onInfo?: (message: string, metadata?: unknown) => void; +}; + +interface PathTree { + [key: string]: PathTree | null; +} + +const resolveRepositoryProcessingOptions = ( + options: ProcessRepositoryOptions['options'] = {} +): RepositoryProcessingOptions => ({ + showTokenCount: options.showTokenCount !== false, + includeTreeView: options.includeTreeView === true, + exportFormat: normalizeExportFormat(options.exportFormat), +}); + +const upsertPathPart = (tree: PathTree, part: string, isLeaf: boolean): PathTree | null => { + const existing = tree[part]; + if (existing !== undefined) { + return existing; + } + + const nextValue = isLeaf ? null : {}; + tree[part] = nextValue; + return nextValue; +}; + +const addFilePathToTree = (pathTree: PathTree, filePath: string): void => { + const parts = filePath.split('/'); + let currentLevel: PathTree = pathTree; + + for (const [index, part] of parts.entries()) { + const isLeaf = index === parts.length - 1; + const nextLevel = upsertPathPart(currentLevel, part, isLeaf); + if (isLeaf || nextLevel === null) { + continue; + } + currentLevel = nextLevel; + } +}; + +const buildPathTree = (filesInfo: FileInfo[]): PathTree => { + const pathTree: PathTree = {}; + const sortedFiles = [...filesInfo].sort((a, b) => a.path.localeCompare(b.path)); + + for (const file of sortedFiles) { + if (!file?.path) { + continue; + } + addFilePathToTree(pathTree, file.path); + } + + return pathTree; +}; + +const renderTreeView = (tree: PathTree, prefix = ''): string => { + const entries = Object.entries(tree); + let result = ''; + + entries.forEach(([key, value], index) => { + const isLastItem = index === entries.length - 1; + result += `${prefix}${isLastItem ? '└── ' : '├── '}${key}\n`; + + if (value !== null) { + const nextPrefix = `${prefix}${isLastItem ? ' ' : '│ '}`; + result += renderTreeView(value, nextPrefix); + } + }); + + return result; +}; + +const generateTreeView = (filesInfo: FileInfo[]): string => { + if (!Array.isArray(filesInfo)) { + return ''; + } + return renderTreeView(buildPathTree(filesInfo)); +}; + +const buildRepositoryHeader = ( + processingOptions: RepositoryProcessingOptions, + treeView: string | undefined, + filesInfo: FileInfo[] +): string => { + let header = + processingOptions.exportFormat === 'xml' + ? '\n\n' + : '# Repository Content\n\n'; + + if (processingOptions.includeTreeView) { + const resolvedTreeView = treeView || generateTreeView(filesInfo); + if (processingOptions.exportFormat === 'xml') { + header += `${wrapXmlCdata(resolvedTreeView)}\n`; + } else { + header += '## File Structure\n\n'; + header += '```\n'; + header += resolvedTreeView; + header += '```\n\n'; + } + } + + if (processingOptions.exportFormat === 'markdown' && processingOptions.includeTreeView) { + header += '## File Contents\n\n'; + } + + if (processingOptions.exportFormat === 'xml') { + header += '\n'; + } + + return header; +}; + +const processRepositoryFile = ( + rootPath: string, + fileInfo: FileInfo, + contentProcessor: ContentProcessor, + processingOptions: RepositoryProcessingOptions, + onWarn?: (message: string) => void +): ProcessedRepositoryFileResult => { + const filePath = fileInfo.path; + const tokenCount = normalizeTokenCount(fileInfo.tokens); + const fullPath = path.resolve(rootPath, filePath); + + if (!isPathWithinRoot(rootPath, fullPath)) { + onWarn?.(`Skipping file outside root directory: ${filePath}`); + return null; + } + + if (!fs.existsSync(fullPath)) { + onWarn?.(`File not found: ${filePath}`); + return null; + } + + const content = contentProcessor.processFile(fullPath, filePath, { + exportFormat: processingOptions.exportFormat, + showTokenCount: processingOptions.showTokenCount, + tokenCount, + }); + if (!content) { + return null; + } + + return { content, tokenCount }; +}; + +const buildRepositoryFooter = ( + processingOptions: RepositoryProcessingOptions, + summary: { totalTokens: number; processedFiles: number; skippedFiles: number } +): string => { + if (processingOptions.exportFormat === 'xml') { + return ( + '\n' + + `\n` + + '\n' + ); + } + + return '\n--END--\n'; +}; + +export const processRepository = ({ + rootPath, + filesInfo, + treeView, + options, + onWarn, + onInfo, +}: ProcessRepositoryInput): ProcessRepositoryResult => { + const tokenCounter = new TokenCounter(); + const contentProcessor = new ContentProcessor(tokenCounter); + const processingOptions = resolveRepositoryProcessingOptions(options); + onInfo?.('Processing with options:', processingOptions); + + const normalizedFilesInfo = filesInfo ?? []; + let processedContent = buildRepositoryHeader( + processingOptions, + treeView ?? undefined, + normalizedFilesInfo + ); + + let totalTokens = 0; + let processedFiles = 0; + let skippedFiles = 0; + + for (const fileInfo of normalizedFilesInfo) { + if (!fileInfo?.path) { + onWarn?.('Skipping invalid file info entry'); + skippedFiles++; + continue; + } + + try { + const processedFile = processRepositoryFile( + rootPath, + fileInfo, + contentProcessor, + processingOptions, + onWarn + ); + if (!processedFile) { + skippedFiles++; + continue; + } + + processedContent += processedFile.content; + totalTokens += processedFile.tokenCount; + processedFiles++; + } catch (error) { + onWarn?.(`Failed to process file: ${getErrorMessage(error)}`); + skippedFiles++; + } + } + + processedContent += buildRepositoryFooter(processingOptions, { + totalTokens, + processedFiles, + skippedFiles, + }); + + return { + content: processedContent, + exportFormat: processingOptions.exportFormat, + totalTokens, + processedFiles, + skippedFiles, + filesInfo: normalizedFilesInfo, + }; +}; diff --git a/tests/catalog.md b/tests/catalog.md index eee2ac8..df38dd5 100644 --- a/tests/catalog.md +++ b/tests/catalog.md @@ -54,6 +54,7 @@ Purpose: quick map of what is covered, why it exists, and which command to run. | `tests/unit/main/path-security.test.ts` | `src/main/security/path-guard.ts` | Root-path authorization, temp-root boundaries, symlink-aware realpath resolution | | `tests/unit/main/provider-connection.test.ts` | `src/main/services/provider-connection.ts` | Provider defaults, URL validation/normalization, request construction, timeout/error handling | | `tests/unit/main/directory-tree.test.ts` | `src/main/services/directory-tree.ts` | Exclude/include pattern merge, symlink skip policy, canonical recursion-loop guard, parse-failure fallback | +| `tests/unit/main/repository-processing.test.ts` | `src/main/services/repository-processing.ts` | Repository output assembly, tree/header/footer behavior, XML token flag handling, path-boundary and missing-file skips | ## Integration Tests @@ -118,6 +119,7 @@ Stress benchmark outputs: - `tests/unit/main/path-security.test.ts` - `tests/unit/main/provider-connection.test.ts` - `tests/unit/main/directory-tree.test.ts` + - `tests/unit/main/repository-processing.test.ts` - `tests/stress/main-process/ipc-latency.stress.test.ts` - Content/token pipeline changes: - `tests/unit/file-analyzer.test.ts` diff --git a/tests/unit/main/repository-processing.test.ts b/tests/unit/main/repository-processing.test.ts new file mode 100644 index 0000000..9580afc --- /dev/null +++ b/tests/unit/main/repository-processing.test.ts @@ -0,0 +1,129 @@ +jest.unmock('fs'); + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { processRepository } from '../../../src/main/services/repository-processing'; + +import type { FileInfo } from '../../../src/types/ipc'; + +const createTempRepository = () => { + const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), 'repo-processing-')); + const createFile = (relativePath: string, content: string) => { + const fullPath = path.join(rootPath, relativePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content, 'utf-8'); + }; + + return { + rootPath, + createFile, + cleanup: () => { + fs.rmSync(rootPath, { recursive: true, force: true }); + }, + }; +}; + +describe('repository-processing service', () => { + test('processes markdown output and skips invalid/missing/out-of-root entries', () => { + const { rootPath, createFile, cleanup } = createTempRepository(); + try { + createFile('src/index.js', 'const answer = 42;\n'); + const warnMock = jest.fn(); + const invalidFileEntry = null as unknown as FileInfo; + + const result = processRepository({ + rootPath, + filesInfo: [ + { path: 'src/index.js', tokens: 12 }, + { path: '../outside.js', tokens: 2 }, + { path: 'missing.js', tokens: 1 }, + invalidFileEntry, + ], + onWarn: warnMock, + }); + + expect(result.exportFormat).toBe('markdown'); + expect(result.processedFiles).toBe(1); + expect(result.skippedFiles).toBe(3); + expect(result.content).toContain('# Repository Content'); + expect(result.content).toContain('src/index.js'); + expect(result.content).toContain('--END--'); + expect(warnMock).toHaveBeenCalledWith( + expect.stringContaining('Skipping file outside root directory') + ); + expect(warnMock).toHaveBeenCalledWith(expect.stringContaining('File not found')); + expect(warnMock).toHaveBeenCalledWith('Skipping invalid file info entry'); + } finally { + cleanup(); + } + }); + + test('generates tree view section when includeTreeView is enabled', () => { + const { rootPath, createFile, cleanup } = createTempRepository(); + try { + createFile('src/index.js', 'console.log("tree");\n'); + createFile('README.md', '# Readme\n'); + + const result = processRepository({ + rootPath, + filesInfo: [ + { path: 'src/index.js', tokens: 5 }, + { path: 'README.md', tokens: 3 }, + ], + options: { includeTreeView: true }, + }); + + expect(result.content).toContain('## File Structure'); + expect(result.content).toContain('src'); + expect(result.content).toContain('README.md'); + expect(result.content).toContain('## File Contents'); + } finally { + cleanup(); + } + }); + + test('keeps xml token attributes enabled by default', () => { + const { rootPath, createFile, cleanup } = createTempRepository(); + try { + createFile('src/index.js', 'console.log("xml");\n'); + + const result = processRepository({ + rootPath, + filesInfo: [{ path: 'src/index.js', tokens: 42 }], + options: { exportFormat: 'xml' }, + }); + + expect(result.exportFormat).toBe('xml'); + expect(result.content).toContain(''); + expect(result.content).toContain( + '' + ); + } finally { + cleanup(); + } + }); + + test('omits xml token attributes when showTokenCount is false', () => { + const { rootPath, createFile, cleanup } = createTempRepository(); + try { + createFile('src/index.js', 'console.log("xml no token");\n'); + + const result = processRepository({ + rootPath, + filesInfo: [{ path: 'src/index.js', tokens: 42 }], + options: { + exportFormat: 'xml', + showTokenCount: false, + }, + }); + + expect(result.exportFormat).toBe('xml'); + expect(result.content).toContain(''); + expect(result.content).not.toContain('tokens="42"'); + } finally { + cleanup(); + } + }); +}); From 6c42908e0ba719068fe9b2a0e2e569dd92a821c4 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Sat, 14 Feb 2026 22:47:37 +0000 Subject: [PATCH 2/3] fix(main): harden repository service edge cases --- src/main/services/repository-analyzer.ts | 11 ++++------ src/main/services/repository-processing.ts | 14 ++++++++++++- tests/unit/main/repository-processing.test.ts | 21 +++++++++++++++++++ 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/main/services/repository-analyzer.ts b/src/main/services/repository-analyzer.ts index 7e7b2cb..3be172d 100644 --- a/src/main/services/repository-analyzer.ts +++ b/src/main/services/repository-analyzer.ts @@ -27,11 +27,6 @@ type AnalyzeRepositoryInput = { onInfo?: (message: string) => void; }; -const EMPTY_GITIGNORE_PATTERNS: GitignorePatterns = { - excludePatterns: [], - includePatterns: [], -}; - export const analyzeRepository = ({ rootPath, configContent, @@ -42,8 +37,10 @@ export const analyzeRepository = ({ }: AnalyzeRepositoryInput): AnalyzeRepositoryResult => { const config = (yaml.parse(configContent) || {}) as ConfigObject; const localTokenCounter = new TokenCounter(); - - let gitignorePatterns = EMPTY_GITIGNORE_PATTERNS; + let gitignorePatterns: GitignorePatterns = { + excludePatterns: [], + includePatterns: [], + }; if (config.use_gitignore !== false) { gitignorePatterns = gitignoreParser.parseGitignore(rootPath); } diff --git a/src/main/services/repository-processing.ts b/src/main/services/repository-processing.ts index cd127f4..ffff213 100644 --- a/src/main/services/repository-processing.ts +++ b/src/main/services/repository-processing.ts @@ -48,6 +48,12 @@ const resolveRepositoryProcessingOptions = ( const upsertPathPart = (tree: PathTree, part: string, isLeaf: boolean): PathTree | null => { const existing = tree[part]; + if (existing === null && !isLeaf) { + const promotedPathNode: PathTree = {}; + tree[part] = promotedPathNode; + return promotedPathNode; + } + if (existing !== undefined) { return existing; } @@ -64,9 +70,15 @@ const addFilePathToTree = (pathTree: PathTree, filePath: string): void => { for (const [index, part] of parts.entries()) { const isLeaf = index === parts.length - 1; const nextLevel = upsertPathPart(currentLevel, part, isLeaf); - if (isLeaf || nextLevel === null) { + + if (isLeaf) { continue; } + + if (nextLevel === null) { + break; + } + currentLevel = nextLevel; } }; diff --git a/tests/unit/main/repository-processing.test.ts b/tests/unit/main/repository-processing.test.ts index 9580afc..0fb66c6 100644 --- a/tests/unit/main/repository-processing.test.ts +++ b/tests/unit/main/repository-processing.test.ts @@ -84,6 +84,27 @@ describe('repository-processing service', () => { } }); + test('keeps nested tree structure stable when a path prefix appears as a file entry', () => { + const { rootPath, cleanup } = createTempRepository(); + try { + const result = processRepository({ + rootPath, + filesInfo: [ + { path: 'a/b', tokens: 1 }, + { path: 'a/b/c.txt', tokens: 1 }, + ], + options: { includeTreeView: true }, + }); + + expect(result.content).toContain('## File Structure'); + expect(result.content).toContain('└── a'); + expect(result.content).toContain(' └── b'); + expect(result.content).toContain(' └── c.txt'); + } finally { + cleanup(); + } + }); + test('keeps xml token attributes enabled by default', () => { const { rootPath, createFile, cleanup } = createTempRepository(); try { From 239e6a20f0855d3926f7e265703063b2acd49dcc Mon Sep 17 00:00:00 2001 From: Mehdi Date: Sat, 14 Feb 2026 22:54:30 +0000 Subject: [PATCH 3/3] fix(main): harden tree builder against prototype key paths --- src/main/services/repository-processing.ts | 10 +++++--- tests/unit/main/repository-processing.test.ts | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/main/services/repository-processing.ts b/src/main/services/repository-processing.ts index ffff213..3ad35a1 100644 --- a/src/main/services/repository-processing.ts +++ b/src/main/services/repository-processing.ts @@ -38,6 +38,10 @@ interface PathTree { [key: string]: PathTree | null; } +const createPathTreeNode = (): PathTree => { + return Object.create(null) as PathTree; +}; + const resolveRepositoryProcessingOptions = ( options: ProcessRepositoryOptions['options'] = {} ): RepositoryProcessingOptions => ({ @@ -49,7 +53,7 @@ const resolveRepositoryProcessingOptions = ( const upsertPathPart = (tree: PathTree, part: string, isLeaf: boolean): PathTree | null => { const existing = tree[part]; if (existing === null && !isLeaf) { - const promotedPathNode: PathTree = {}; + const promotedPathNode = createPathTreeNode(); tree[part] = promotedPathNode; return promotedPathNode; } @@ -58,7 +62,7 @@ const upsertPathPart = (tree: PathTree, part: string, isLeaf: boolean): PathTree return existing; } - const nextValue = isLeaf ? null : {}; + const nextValue = isLeaf ? null : createPathTreeNode(); tree[part] = nextValue; return nextValue; }; @@ -84,7 +88,7 @@ const addFilePathToTree = (pathTree: PathTree, filePath: string): void => { }; const buildPathTree = (filesInfo: FileInfo[]): PathTree => { - const pathTree: PathTree = {}; + const pathTree = createPathTreeNode(); const sortedFiles = [...filesInfo].sort((a, b) => a.path.localeCompare(b.path)); for (const file of sortedFiles) { diff --git a/tests/unit/main/repository-processing.test.ts b/tests/unit/main/repository-processing.test.ts index 0fb66c6..e1801e6 100644 --- a/tests/unit/main/repository-processing.test.ts +++ b/tests/unit/main/repository-processing.test.ts @@ -105,6 +105,29 @@ describe('repository-processing service', () => { } }); + test('does not mutate object prototypes when tree paths contain special keys', () => { + const { rootPath, cleanup } = createTempRepository(); + try { + const prototypeBefore = Reflect.get({}, 'polluted'); + + const result = processRepository({ + rootPath, + filesInfo: [ + { path: '__proto__/polluted.txt', tokens: 1 }, + { path: 'constructor/test.ts', tokens: 1 }, + ], + options: { includeTreeView: true }, + }); + + expect(prototypeBefore).toBeUndefined(); + expect(Reflect.get({}, 'polluted')).toBeUndefined(); + expect(result.content).toContain('__proto__'); + expect(result.content).toContain('constructor'); + } finally { + cleanup(); + } + }); + test('keeps xml token attributes enabled by default', () => { const { rootPath, createFile, cleanup } = createTempRepository(); try {