From 4205dfb1d9c19e5387f5770ebed836a44f27e31c Mon Sep 17 00:00:00 2001 From: Mehdi Date: Mon, 9 Feb 2026 03:58:29 +0000 Subject: [PATCH 01/16] feat(export): harden xml export flow and add end-to-end coverage --- src/main/index.ts | 601 ++++++++++-------- src/renderer/components/App.tsx | 31 +- src/renderer/components/ConfigTab.tsx | 32 +- src/types/ipc.ts | 3 + src/utils/config.default.yaml | 1 + src/utils/content-processor.ts | 50 +- src/utils/export-format.ts | 49 ++ tests/fixtures/configs/default.yaml | 1 + tests/fixtures/configs/minimal.yaml | 1 + .../integration/main-process/handlers.test.ts | 111 +++- .../main-process/xml-export-e2e.test.ts | 94 +++ tests/mocks/yaml-mock.ts | 31 +- tests/unit/components/app.test.tsx | 62 ++ tests/unit/components/config-tab.test.tsx | 23 + tests/unit/utils/content-processor.test.ts | 56 ++ tests/unit/utils/export-format.test.ts | 42 ++ 16 files changed, 898 insertions(+), 290 deletions(-) create mode 100644 src/utils/export-format.ts create mode 100644 tests/integration/main-process/xml-export-e2e.test.ts create mode 100644 tests/unit/utils/export-format.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index 9225cb6..15f5fac 100755 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -8,6 +8,12 @@ import { FileAnalyzer, isBinaryFile } from '../utils/file-analyzer'; import { normalizePath, getRelativePath, shouldExclude } from '../utils/filter-utils'; import { GitignoreParser } from '../utils/gitignore-parser'; import { TokenCounter } from '../utils/token-counter'; +import { + normalizeExportFormat, + normalizeTokenCount, + toXmlNumericAttribute, + wrapXmlCdata, +} from '../utils/export-format'; import type { AnalyzeRepositoryOptions, AnalyzeRepositoryResult, @@ -120,132 +126,132 @@ ipcMain.handle('dialog:selectDirectory', async () => { ipcMain.handle( 'fs:getDirectoryTree', async (_event, dirPath: string, configContent?: string | null) => { - // IMPORTANT: This function applies exclude patterns to the directory tree, - // preventing node_modules, .git, and other large directories from being included - // in the UI tree view. This is critical for performance with large repositories. - - // Parse config to get settings and exclude patterns - let excludePatterns: FilterPatternBundle = []; - try { - const config = (configContent - ? (yaml.parse(configContent) as ConfigObject) - : ({ exclude_patterns: [] } as ConfigObject)) || { exclude_patterns: [] }; - - // Check if we should use custom excludes (default to true if not specified) - const useCustomExcludes = config.use_custom_excludes !== false; - - // Check if we should use custom includes (default to true if not specified) - const useCustomIncludes = config.use_custom_includes !== false; - - // Check if we should use gitignore (default to true if not specified) - const useGitignore = config.use_gitignore !== false; - - // Start with empty excludePatterns array (no hardcoded patterns) - // Add custom exclude patterns if enabled - if (useCustomExcludes && config.exclude_patterns && Array.isArray(config.exclude_patterns)) { - excludePatterns = [...excludePatterns, ...config.exclude_patterns]; - } - - // Store include extensions for filtering later (if enabled) - if ( - useCustomIncludes && - config.include_extensions && - Array.isArray(config.include_extensions) - ) { - excludePatterns.includeExtensions = config.include_extensions; - } + // IMPORTANT: This function applies exclude patterns to the directory tree, + // preventing node_modules, .git, and other large directories from being included + // in the UI tree view. This is critical for performance with large repositories. + + // Parse config to get settings and exclude patterns + let excludePatterns: FilterPatternBundle = []; + try { + const config = (configContent + ? (yaml.parse(configContent) as ConfigObject) + : ({ exclude_patterns: [] } as ConfigObject)) || { exclude_patterns: [] }; + + // Check if we should use custom excludes (default to true if not specified) + const useCustomExcludes = config.use_custom_excludes !== false; + + // Check if we should use custom includes (default to true if not specified) + const useCustomIncludes = config.use_custom_includes !== false; + + // Check if we should use gitignore (default to true if not specified) + const useGitignore = config.use_gitignore !== false; + + // Start with empty excludePatterns array (no hardcoded patterns) + // Add custom exclude patterns if enabled + if (useCustomExcludes && config.exclude_patterns && Array.isArray(config.exclude_patterns)) { + excludePatterns = [...excludePatterns, ...config.exclude_patterns]; + } - // Add gitignore patterns if enabled - if (useGitignore) { - const gitignoreResult = gitignoreParser.parseGitignore(dirPath); - if (gitignoreResult.excludePatterns && gitignoreResult.excludePatterns.length > 0) { - excludePatterns = [...excludePatterns, ...gitignoreResult.excludePatterns]; + // Store include extensions for filtering later (if enabled) + if ( + useCustomIncludes && + config.include_extensions && + Array.isArray(config.include_extensions) + ) { + excludePatterns.includeExtensions = config.include_extensions; } - // Handle negated patterns (these will be processed later to override excludes) - if (gitignoreResult.includePatterns && gitignoreResult.includePatterns.length > 0) { - // We'll store includePatterns separately to process later - excludePatterns.includePatterns = gitignoreResult.includePatterns; + // Add gitignore patterns if enabled + if (useGitignore) { + const gitignoreResult = gitignoreParser.parseGitignore(dirPath); + if (gitignoreResult.excludePatterns && gitignoreResult.excludePatterns.length > 0) { + excludePatterns = [...excludePatterns, ...gitignoreResult.excludePatterns]; + } + + // Handle negated patterns (these will be processed later to override excludes) + if (gitignoreResult.includePatterns && gitignoreResult.includePatterns.length > 0) { + // We'll store includePatterns separately to process later + excludePatterns.includePatterns = gitignoreResult.includePatterns; + } } + } catch (error) { + console.error('Error parsing config:', error); + // Fall back to only hiding .git folder + excludePatterns = ['**/.git/**']; } - } catch (error) { - console.error('Error parsing config:', error); - // Fall back to only hiding .git folder - excludePatterns = ['**/.git/**']; - } - - // Import the fnmatch module - // Get the config for filtering - const config = (configContent - ? (yaml.parse(configContent) as ConfigObject) - : ({ exclude_patterns: [] } as ConfigObject)) || { exclude_patterns: [] }; + // Import the fnmatch module - // Use the shared shouldExclude function from filter-utils - const localShouldExclude = (itemPath: string) => { - return shouldExclude(itemPath, dirPath, excludePatterns, config); - }; + // Get the config for filtering + const config = (configContent + ? (yaml.parse(configContent) as ConfigObject) + : ({ exclude_patterns: [] } as ConfigObject)) || { exclude_patterns: [] }; - const walkDirectory = (dir: string): DirectoryTreeItem[] => { - const items = fs.readdirSync(dir); - const result: DirectoryTreeItem[] = []; + // Use the shared shouldExclude function from filter-utils + const localShouldExclude = (itemPath: string) => { + return shouldExclude(itemPath, dirPath, excludePatterns, config); + }; - for (const item of items) { - try { - const itemPath = path.join(dir, item); + const walkDirectory = (dir: string): DirectoryTreeItem[] => { + const items = fs.readdirSync(dir); + const result: DirectoryTreeItem[] = []; - // Skip excluded items based on patterns, but don't exclude binary files from the tree - if (localShouldExclude(itemPath)) { - continue; - } + for (const item of items) { + try { + const itemPath = path.join(dir, item); - const stats = fs.statSync(itemPath); - const ext = path.extname(item).toLowerCase(); + // Skip excluded items based on patterns, but don't exclude binary files from the tree + if (localShouldExclude(itemPath)) { + continue; + } - if (stats.isDirectory()) { - const children = walkDirectory(itemPath); - // Only include directory if it has children or if we want to show empty dirs - if (children.length > 0) { + const stats = fs.statSync(itemPath); + const ext = path.extname(item).toLowerCase(); + + if (stats.isDirectory()) { + const children = walkDirectory(itemPath); + // Only include directory if it has children or if we want to show empty dirs + if (children.length > 0) { + result.push({ + name: item, + path: itemPath, + type: 'directory', + size: stats.size, + lastModified: stats.mtime, + children: children, + itemCount: children.length, + }); + } + } else { result.push({ name: item, path: itemPath, - type: 'directory', + type: 'file', size: stats.size, lastModified: stats.mtime, - children: children, - itemCount: children.length, + extension: ext, }); } - } else { - result.push({ - name: item, - path: itemPath, - type: 'file', - size: stats.size, - lastModified: stats.mtime, - extension: ext, - }); + } catch (err) { + console.error(`Error processing ${path.join(dir, item)}:`, err); + // Continue with next file instead of breaking } - } catch (err) { - console.error(`Error processing ${path.join(dir, item)}:`, err); - // Continue with next file instead of breaking } - } - // Sort directories first, then files alphabetically - return result.sort((a, b) => { - if (a.type === 'directory' && b.type === 'file') return -1; - if (a.type === 'file' && b.type === 'directory') return 1; - return a.name.localeCompare(b.name); - }); - }; + // Sort directories first, then files alphabetically + return result.sort((a, b) => { + if (a.type === 'directory' && b.type === 'file') return -1; + if (a.type === 'file' && b.type === 'directory') return 1; + return a.name.localeCompare(b.name); + }); + }; - try { - return walkDirectory(dirPath); - } catch (error) { - console.error('Error getting directory tree:', error); - return []; - } + try { + return walkDirectory(dirPath); + } catch (error) { + console.error('Error getting directory tree:', error); + return []; + } } ); @@ -258,79 +264,79 @@ ipcMain.handle( _event, { rootPath, configContent, selectedFiles }: AnalyzeRepositoryOptions ): Promise => { - try { - const config = (yaml.parse(configContent) || {}) as ConfigObject; - const localTokenCounter = new TokenCounter(); - - // Process gitignore if enabled - let gitignorePatterns = { excludePatterns: [], includePatterns: [] }; - if (config.use_gitignore === true) { - gitignorePatterns = gitignoreParser.parseGitignore(rootPath); - } - - // Create a file analyzer instance with the appropriate settings - const fileAnalyzer = new FileAnalyzer(config, localTokenCounter, { - useGitignore: config.use_gitignore === true, - gitignorePatterns: gitignorePatterns, - }); - - // If selectedFiles is provided, only analyze those files - const filesInfo: FileInfo[] = []; - let totalTokens = 0; - let skippedBinaryFiles = 0; - - for (const filePath of selectedFiles) { - // Verify the file is within the current root path - if (!filePath.startsWith(rootPath)) { - console.warn(`Skipping file outside current root directory: ${filePath}`); - continue; + try { + const config = (yaml.parse(configContent) || {}) as ConfigObject; + const localTokenCounter = new TokenCounter(); + + // Process gitignore if enabled + let gitignorePatterns = { excludePatterns: [], includePatterns: [] }; + if (config.use_gitignore === true) { + gitignorePatterns = gitignoreParser.parseGitignore(rootPath); } - // Use consistent path normalization - const relativePath = getRelativePath(filePath, rootPath); + // Create a file analyzer instance with the appropriate settings + const fileAnalyzer = new FileAnalyzer(config, localTokenCounter, { + useGitignore: config.use_gitignore === true, + gitignorePatterns: gitignorePatterns, + }); + + // If selectedFiles is provided, only analyze those files + const filesInfo: FileInfo[] = []; + let totalTokens = 0; + let skippedBinaryFiles = 0; + + for (const filePath of selectedFiles) { + // Verify the file is within the current root path + if (!filePath.startsWith(rootPath)) { + console.warn(`Skipping file outside current root directory: ${filePath}`); + continue; + } + + // Use consistent path normalization + const relativePath = getRelativePath(filePath, rootPath); - // For binary files, record them as skipped but don't prevent selection - const binaryFile = isBinaryFile(filePath); - if (binaryFile) { - console.log(`Binary file detected (will skip processing): ${relativePath}`); - skippedBinaryFiles++; - } + // For binary files, record them as skipped but don't prevent selection + const binaryFile = isBinaryFile(filePath); + 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(filePath); - - if (tokenCount !== null) { + if (binaryFile) { + // For binary files, add to filesInfo but with zero tokens and a flag filesInfo.push({ path: relativePath, - tokens: tokenCount, + tokens: 0, + isBinary: true, }); + } else if (fileAnalyzer.shouldProcessFile(relativePath)) { + const tokenCount = fileAnalyzer.analyzeFile(filePath); + + if (tokenCount !== null) { + filesInfo.push({ + path: relativePath, + tokens: tokenCount, + }); - totalTokens += tokenCount; + totalTokens += tokenCount; + } } } - } - // Sort by token count - filesInfo.sort((a, b) => b.tokens - a.tokens); + // Sort by token count + filesInfo.sort((a, b) => b.tokens - a.tokens); - console.log(`Skipped ${skippedBinaryFiles} binary files during analysis`); + console.log(`Skipped ${skippedBinaryFiles} binary files during analysis`); - return { - filesInfo, - totalTokens, - skippedBinaryFiles, - }; - } catch (error) { - console.error('Error analyzing repository:', error); - throw error; - } + return { + filesInfo, + totalTokens, + skippedBinaryFiles, + }; + } catch (error) { + console.error('Error analyzing repository:', error); + throw error; + } } ); @@ -392,6 +398,9 @@ function generateTreeView(filesInfo: FileInfo[]): string { return printTree(pathTree); } +const getErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + // Process repository ipcMain.handle( 'repo:process', @@ -399,101 +408,145 @@ ipcMain.handle( _event, { rootPath, filesInfo, treeView, options = {} }: ProcessRepositoryOptions ): Promise => { - try { - const tokenCounter = new TokenCounter(); - const contentProcessor = new ContentProcessor(tokenCounter); - - // Ensure options is an object with default values if missing - const processingOptions = { - showTokenCount: options.showTokenCount !== false, // Default to true if not explicitly false - }; - - console.log('Processing with options:', processingOptions); - - let processedContent = '# Repository Content\n\n'; + try { + const tokenCounter = new TokenCounter(); + const contentProcessor = new ContentProcessor(tokenCounter); + + // Ensure options is an object with default values if missing + const processingOptions = { + showTokenCount: options.showTokenCount !== false, // Default to true if not explicitly false + includeTreeView: options.includeTreeView === true, + exportFormat: normalizeExportFormat(options.exportFormat), + }; + + console.log('Processing with options:', processingOptions); + + let processedContent = ''; + + if (processingOptions.exportFormat === 'xml') { + processedContent += '\n'; + processedContent += '\n'; + } else { + processedContent += '# Repository Content\n\n'; + } - // Add tree view if requested in options, whether provided or not - if (options.includeTreeView) { - processedContent += '## File Structure\n\n'; - processedContent += '```\n'; + // Add tree view if requested in options, whether provided or not + if (processingOptions.includeTreeView) { + const resolvedTreeView = treeView || generateTreeView(filesInfo); + if (processingOptions.exportFormat === 'xml') { + processedContent += `${wrapXmlCdata(resolvedTreeView)}\n`; + } else { + processedContent += '## File Structure\n\n'; + processedContent += '```\n'; + processedContent += resolvedTreeView; + processedContent += '```\n\n'; + } + } - // If treeView was provided, use it, otherwise generate a more complete one - processedContent += treeView || generateTreeView(filesInfo); + if (processingOptions.exportFormat === 'markdown') { + processedContent += '## File Contents\n\n'; + } - processedContent += '```\n\n'; - processedContent += '## File Contents\n\n'; - } + if (processingOptions.exportFormat === 'xml') { + processedContent += '\n'; + } - let totalTokens = 0; - let processedFiles = 0; - let skippedFiles = 0; + let totalTokens = 0; + let processedFiles = 0; + let skippedFiles = 0; - for (const fileInfo of filesInfo ?? []) { - try { - if (!fileInfo || !fileInfo.path) { - console.warn('Skipping invalid file info entry'); - skippedFiles++; - continue; - } + for (const fileInfo of filesInfo ?? []) { + try { + if (!fileInfo || !fileInfo.path) { + console.warn('Skipping invalid file info entry'); + skippedFiles++; + continue; + } - const { path: filePath, tokens = 0 } = fileInfo; + const filePath = fileInfo.path; + const tokenCount = normalizeTokenCount(fileInfo.tokens); - // Use consistent path joining - const fullPath = path.join(rootPath, filePath); + // Use consistent path joining + const fullPath = path.join(rootPath, filePath); - // Validate the full path is within the root path - const normalizedFullPath = normalizePath(fullPath); - const normalizedRootPath = normalizePath(rootPath); + // Validate the full path is within the root path + const normalizedFullPath = normalizePath(fullPath); + const normalizedRootPath = normalizePath(rootPath); - if (!normalizedFullPath.startsWith(normalizedRootPath)) { - console.warn(`Skipping file outside root directory: ${filePath}`); - skippedFiles++; - continue; - } + if (!normalizedFullPath.startsWith(normalizedRootPath)) { + console.warn(`Skipping file outside root directory: ${filePath}`); + skippedFiles++; + continue; + } - if (fs.existsSync(fullPath)) { - const content = contentProcessor.processFile(fullPath, filePath); + if (fs.existsSync(fullPath)) { + const content = contentProcessor.processFile(fullPath, filePath, { + exportFormat: processingOptions.exportFormat, + showTokenCount: processingOptions.showTokenCount, + tokenCount, + }); - if (content) { - processedContent += content; - totalTokens += tokens; - processedFiles++; + if (content) { + processedContent += content; + totalTokens += tokenCount; + processedFiles++; + } + } else { + console.warn(`File not found: ${filePath}`); + skippedFiles++; } - } else { - console.warn(`File not found: ${filePath}`); + } catch (error) { + console.warn(`Failed to process file: ${getErrorMessage(error)}`); skippedFiles++; } - } catch (error) { - console.warn(`Failed to process file: ${error.message}`); - skippedFiles++; } - } - processedContent += '\n--END--\n'; + if (processingOptions.exportFormat === 'xml') { + processedContent += '\n'; + processedContent += + `\n`; + processedContent += '\n'; + } else { + processedContent += '\n--END--\n'; + } - return { - content: processedContent, - totalTokens, - processedFiles, - skippedFiles, - filesInfo: filesInfo, // Add filesInfo to the response - }; - } catch (error) { - console.error('Error processing repository:', error); - throw error; - } + return { + content: processedContent, + totalTokens, + processedFiles, + skippedFiles, + filesInfo: filesInfo, // Add filesInfo to the response + }; + } catch (error) { + console.error('Error processing repository:', error); + throw error; + } } ); // Save output to file ipcMain.handle('fs:saveFile', async (_event, { content, defaultPath }: SaveFileOptions) => { - const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, { + const defaultExtension = path.extname(defaultPath).toLowerCase(); + const filters = + defaultExtension === '.xml' + ? [ + { name: 'XML Files', extensions: ['xml'] }, + { name: 'Markdown Files', extensions: ['md'] }, + { name: 'Text Files', extensions: ['txt'] }, + { name: 'All Files', extensions: ['*'] }, + ] + : [ + { name: 'Markdown Files', extensions: ['md'] }, + { name: 'XML Files', extensions: ['xml'] }, + { name: 'Text Files', extensions: ['txt'] }, + { name: 'All Files', extensions: ['*'] }, + ]; + + const { canceled, filePath } = await dialog.showSaveDialog(mainWindow ?? undefined, { defaultPath, - filters: [ - { name: 'Markdown Files', extensions: ['md'] }, - { name: 'Text Files', extensions: ['txt'] }, - { name: 'All Files', extensions: ['*'] }, - ], + filters, }); if (canceled) { @@ -539,50 +592,50 @@ ipcMain.handle('assets:getPath', (_event, assetName: string) => { ipcMain.handle( 'tokens:countFiles', async (_event, filePaths: string[]): Promise => { - try { - const results: Record = {}; - const stats: Record = {}; - - // Process each file - for (const filePath of filePaths) { - try { - // Check if file exists - if (!fs.existsSync(filePath)) { - console.warn(`File not found for token counting: ${filePath}`); - results[filePath] = 0; - continue; - } + try { + const results: Record = {}; + const stats: Record = {}; + + // Process each file + for (const filePath of filePaths) { + try { + // Check if file exists + if (!fs.existsSync(filePath)) { + console.warn(`File not found for token counting: ${filePath}`); + results[filePath] = 0; + continue; + } - // Get file stats - const fileStats = fs.statSync(filePath); - stats[filePath] = { - size: fileStats.size, - mtime: fileStats.mtime.getTime(), // Modification time for cache validation - }; + // Get file stats + const fileStats = fs.statSync(filePath); + stats[filePath] = { + size: fileStats.size, + mtime: fileStats.mtime.getTime(), // Modification time for cache validation + }; + + // Skip binary files + if (isBinaryFile(filePath)) { + console.log(`Skipping binary file for token counting: ${filePath}`); + results[filePath] = 0; + continue; + } + + // Read file content + const content = fs.readFileSync(filePath, { encoding: 'utf-8', flag: 'r' }); - // Skip binary files - if (isBinaryFile(filePath)) { - console.log(`Skipping binary file for token counting: ${filePath}`); + // Count tokens using the singleton token counter + const tokenCount = tokenCounter.countTokens(content); + results[filePath] = tokenCount; + } catch (error) { + console.error(`Error counting tokens for file ${filePath}:`, error); results[filePath] = 0; - continue; } - - // Read file content - const content = fs.readFileSync(filePath, { encoding: 'utf-8', flag: 'r' }); - - // Count tokens using the singleton token counter - const tokenCount = tokenCounter.countTokens(content); - results[filePath] = tokenCount; - } catch (error) { - console.error(`Error counting tokens for file ${filePath}:`, error); - results[filePath] = 0; } - } - return { results, stats }; - } catch (error) { - console.error(`Error counting tokens for files:`, error); - return { results: {}, stats: {} }; - } + return { results, stats }; + } catch (error) { + console.error(`Error counting tokens for files:`, error); + return { results: {}, stats: {} }; + } } ); diff --git a/src/renderer/components/App.tsx b/src/renderer/components/App.tsx index 0defa89..994e057 100755 --- a/src/renderer/components/App.tsx +++ b/src/renderer/components/App.tsx @@ -6,10 +6,12 @@ import ProcessedTab from './ProcessedTab'; import DarkModeToggle from './DarkModeToggle'; import { DarkModeProvider } from '../context/DarkModeContext'; import yaml from 'yaml'; +import { normalizeExportFormat } from '../../utils/export-format'; import type { AnalyzeRepositoryResult, ConfigObject, DirectoryTreeItem, + ExportFormat, ProcessRepositoryOptions, ProcessRepositoryResult, TabId, @@ -21,9 +23,19 @@ const ensureError = (error: unknown): Error => { return new Error(String(error)); }; +const resolveExportFormatFromConfig = (rawConfigContent: string) => { + try { + const config = (yaml.parse(rawConfigContent) || {}) as ConfigObject; + return normalizeExportFormat(config.export_format); + } catch { + return 'markdown'; + } +}; + type ProcessingOptions = { showTokenCount: boolean; includeTreeView: boolean; + exportFormat: ExportFormat; }; const App = () => { @@ -35,8 +47,9 @@ const App = () => { const [, setAnalysisResult] = useState(null); const [processedResult, setProcessedResult] = useState(null); const [processingOptions, setProcessingOptions] = useState({ - showTokenCount: false, + showTokenCount: true, includeTreeView: false, + exportFormat: 'markdown', }); // Load config from localStorage or via API, no fallbacks const [configContent, setConfigContent] = useState('# Loading configuration...'); @@ -129,8 +142,9 @@ const App = () => { // Update processing options from config to maintain consistency setProcessingOptions({ - showTokenCount: config.show_token_count === true, + showTokenCount: config.show_token_count !== false, includeTreeView: config.include_tree_view === true, + exportFormat: normalizeExportFormat(config.export_format), }); // Ensure we've saved any config changes before switching tabs @@ -255,13 +269,15 @@ const App = () => { // Read options from config const options: ProcessRepositoryOptions['options'] = { - showTokenCount: false, + showTokenCount: true, includeTreeView: false, + exportFormat: 'markdown', }; try { const config = (yaml.parse(configContent) || {}) as ConfigObject; - options.showTokenCount = config.show_token_count === true; + options.showTokenCount = config.show_token_count !== false; options.includeTreeView = config.include_tree_view === true; + options.exportFormat = normalizeExportFormat(config.export_format); } catch (error) { console.error('Error parsing config for processing:', ensureError(error)); } @@ -329,8 +345,9 @@ const App = () => { const configStr = localStorage.getItem('configContent'); if (configStr) { const config = (yaml.parse(configStr) || {}) as ConfigObject; - options.showTokenCount = config.show_token_count === true; + options.showTokenCount = config.show_token_count !== false; options.includeTreeView = config.include_tree_view === true; + options.exportFormat = normalizeExportFormat(config.export_format); } } catch (error) { console.error('Error parsing config for refresh:', ensureError(error)); @@ -370,9 +387,11 @@ const App = () => { } try { + const exportFormat = resolveExportFormatFromConfig(configContent); + const outputExtension = exportFormat === 'xml' ? 'xml' : 'md'; await window.electronAPI?.saveFile?.({ content: processedResult.content, - defaultPath: `${rootPath}/output.md`, + defaultPath: `${rootPath}/output.${outputExtension}`, }); } catch (error) { const processedError = ensureError(error); diff --git a/src/renderer/components/ConfigTab.tsx b/src/renderer/components/ConfigTab.tsx index bee9603..f27758d 100755 --- a/src/renderer/components/ConfigTab.tsx +++ b/src/renderer/components/ConfigTab.tsx @@ -1,7 +1,8 @@ import React, { useCallback, useEffect, useState } from 'react'; import yaml from 'yaml'; import { yamlArrayToPlainText } from '../../utils/formatters/list-formatter'; -import type { ConfigObject } from '../../types/ipc'; +import { normalizeExportFormat } from '../../utils/export-format'; +import type { ConfigObject, ExportFormat } from '../../types/ipc'; type ConfigTabProps = { configContent: string; @@ -18,6 +19,7 @@ type ConfigStateSetters = { setExcludeSuspiciousFiles: React.Dispatch>; setIncludeTreeView: React.Dispatch>; setShowTokenCount: React.Dispatch>; + setExportFormat: React.Dispatch>; }; // Helper functions for extension and pattern handling to reduce complexity @@ -55,6 +57,7 @@ const updateConfigStates = (config: ConfigObject, stateSetters: ConfigStateSette setExcludeSuspiciousFiles, setIncludeTreeView, setShowTokenCount, + setExportFormat, } = stateSetters; // Process extensions and patterns @@ -89,6 +92,10 @@ const updateConfigStates = (config: ConfigObject, stateSetters: ConfigStateSette if (config?.show_token_count !== undefined) { setShowTokenCount(config.show_token_count === true); } + + if (config?.export_format !== undefined) { + setExportFormat(normalizeExportFormat(config.export_format)); + } }; const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { @@ -100,6 +107,7 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { const [excludeSuspiciousFiles, setExcludeSuspiciousFiles] = useState(true); const [includeTreeView, setIncludeTreeView] = useState(true); const [showTokenCount, setShowTokenCount] = useState(true); + const [exportFormat, setExportFormat] = useState('markdown'); const [fileExtensions, setFileExtensions] = useState(''); const [excludePatterns, setExcludePatterns] = useState(''); @@ -120,6 +128,7 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { setExcludeSuspiciousFiles, setIncludeTreeView, setShowTokenCount, + setExportFormat, }); } catch (error) { console.error('Error parsing config:', error); @@ -151,6 +160,7 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { config.exclude_suspicious_files = excludeSuspiciousFiles; config.include_tree_view = includeTreeView; config.show_token_count = showTokenCount; + config.export_format = exportFormat; // Process file extensions from the textarea config.include_extensions = fileExtensions @@ -189,6 +199,7 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { excludeSuspiciousFiles, includeTreeView, showTokenCount, + exportFormat, fileExtensions, excludePatterns, onConfigChange, @@ -207,6 +218,7 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { excludeSuspiciousFiles, includeTreeView, showTokenCount, + exportFormat, saveConfig, ]); @@ -440,6 +452,24 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { Display token counts + +
+ + +
diff --git a/src/types/ipc.ts b/src/types/ipc.ts index 56b0a4a..b892c14 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -1,4 +1,5 @@ export type TabId = 'config' | 'source' | 'processed'; +export type ExportFormat = 'markdown' | 'xml'; export type SelectionHandler = (path: string, isSelected: boolean) => void; @@ -12,6 +13,7 @@ export interface ConfigObject { exclude_suspicious_files?: boolean; include_tree_view?: boolean; show_token_count?: boolean; + export_format?: ExportFormat; } export interface DirectoryTreeItem { @@ -50,6 +52,7 @@ export interface ProcessRepositoryOptions { options?: { showTokenCount?: boolean; includeTreeView?: boolean; + exportFormat?: ExportFormat; }; } diff --git a/src/utils/config.default.yaml b/src/utils/config.default.yaml index e20f40d..c20f658 100644 --- a/src/utils/config.default.yaml +++ b/src/utils/config.default.yaml @@ -6,6 +6,7 @@ enable_secret_scanning: true exclude_suspicious_files: true include_tree_view: true show_token_count: true +export_format: markdown # File extensions to include (with dot) include_extensions: diff --git a/src/utils/content-processor.ts b/src/utils/content-processor.ts index d9e72db..0cf3f9e 100755 --- a/src/utils/content-processor.ts +++ b/src/utils/content-processor.ts @@ -2,12 +2,25 @@ import fs from 'fs'; import path from 'path'; import { isBinaryFile } from './file-analyzer'; import type { TokenCounter } from './token-counter'; +import type { ExportFormat } from '../types/ipc'; +import { + escapeXmlAttribute, + normalizeExportFormat, + normalizeTokenCount, + wrapXmlCdata, +} from './export-format'; interface AnalysisEntry { path: string; tokens: number; } +interface ProcessFileOptions { + exportFormat?: ExportFormat; + showTokenCount?: boolean; + tokenCount?: number; +} + export class ContentProcessor { private tokenCounter: TokenCounter; @@ -15,8 +28,14 @@ export class ContentProcessor { this.tokenCounter = tokenCounter; } - processFile(filePath: string, relativePath: string): string | null { + processFile( + filePath: string, + relativePath: string, + options: ProcessFileOptions = {} + ): string | null { try { + const exportFormat: ExportFormat = normalizeExportFormat(options.exportFormat); + // For binary files, show a note instead of content if (isBinaryFile(filePath)) { console.log(`Binary file detected during processing: ${filePath}`); @@ -27,6 +46,20 @@ export class ContentProcessor { const headerContent = `${relativePath} (binary file)`; + if (exportFormat === 'xml') { + const fileType = path.extname(filePath).replace('.', '').toUpperCase(); + return ( + `\n` + + `${wrapXmlCdata( + 'Binary files are included in the file tree but not processed for content.' + )}\n` + + '\n' + ); + } + const formattedContent = '######\n' + `${headerContent}\n` + @@ -44,6 +77,21 @@ export class ContentProcessor { console.log(`Reading fresh content from: ${filePath}`); const content = fs.readFileSync(filePath, { encoding: 'utf-8', flag: 'r' }); + if (exportFormat === 'xml') { + const resolvedTokenCount = + options.tokenCount !== undefined + ? normalizeTokenCount(options.tokenCount) + : this.tokenCounter.countTokens(content); + const tokenAttribute = options.showTokenCount + ? ` tokens="${escapeXmlAttribute(String(resolvedTokenCount))}"` + : ''; + return ( + `\n` + + `${wrapXmlCdata(content)}\n` + + '\n' + ); + } + // Always use just the path without token count const headerContent = `${relativePath}`; diff --git a/src/utils/export-format.ts b/src/utils/export-format.ts new file mode 100644 index 0000000..d04b1de --- /dev/null +++ b/src/utils/export-format.ts @@ -0,0 +1,49 @@ +import type { ExportFormat } from '../types/ipc'; + +const isValidXmlCodePoint = (codePoint: number): boolean => + codePoint === 0x9 || + codePoint === 0xa || + codePoint === 0xd || + (codePoint >= 0x20 && codePoint <= 0xd7ff) || + (codePoint >= 0xe000 && codePoint <= 0xfffd) || + (codePoint >= 0x10000 && codePoint <= 0x10ffff); + +export const normalizeExportFormat = (format: unknown): ExportFormat => + format === 'xml' ? 'xml' : 'markdown'; + +export const escapeXmlAttribute = (value: string): string => + value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>'); + +export const sanitizeXmlContent = (value: string): string => { + let sanitized = ''; + + for (const char of value) { + const codePoint = char.codePointAt(0); + if (codePoint !== undefined && isValidXmlCodePoint(codePoint)) { + sanitized += char; + } + } + + return sanitized; +}; + +export const wrapXmlCdata = (value: string): string => + `/g, ']]]]>')}]]>`; + +export const normalizeTokenCount = (value: unknown): number => { + const numericValue = typeof value === 'number' ? value : Number(value); + + if (!Number.isFinite(numericValue) || numericValue < 0) { + return 0; + } + + return Math.trunc(numericValue); +}; + +export const toXmlNumericAttribute = (value: unknown): string => + escapeXmlAttribute(String(normalizeTokenCount(value))); diff --git a/tests/fixtures/configs/default.yaml b/tests/fixtures/configs/default.yaml index 3e3a20f..88cb059 100644 --- a/tests/fixtures/configs/default.yaml +++ b/tests/fixtures/configs/default.yaml @@ -6,6 +6,7 @@ enable_secret_scanning: true exclude_suspicious_files: true include_tree_view: true show_token_count: true +export_format: markdown # File extensions to include (with dot) include_extensions: diff --git a/tests/fixtures/configs/minimal.yaml b/tests/fixtures/configs/minimal.yaml index 4233736..1f8fb5e 100644 --- a/tests/fixtures/configs/minimal.yaml +++ b/tests/fixtures/configs/minimal.yaml @@ -2,6 +2,7 @@ use_custom_excludes: false use_custom_includes: true use_gitignore: false +export_format: markdown # File extensions to include (with dot) include_extensions: diff --git a/tests/integration/main-process/handlers.test.ts b/tests/integration/main-process/handlers.test.ts index 8f50839..3dda311 100644 --- a/tests/integration/main-process/handlers.test.ts +++ b/tests/integration/main-process/handlers.test.ts @@ -92,7 +92,18 @@ jest.mock('../../../src/utils/file-analyzer', () => { jest.mock('../../../src/utils/content-processor', () => ({ ContentProcessor: jest.fn().mockImplementation(() => ({ - processFile: jest.fn().mockImplementation((filePath, relativePath) => { + processFile: jest.fn().mockImplementation((filePath, relativePath, options = {}) => { + if (options.exportFormat === 'xml') { + if (filePath.includes('binary') || filePath.endsWith('.png') || filePath.endsWith('.ico')) { + return `\n`; + } + const tokenAttribute = + options.showTokenCount === true && Number.isFinite(options.tokenCount) + ? ` tokens="${options.tokenCount}"` + : ''; + return `\n`; + } + if (filePath.includes('binary') || filePath.endsWith('.png') || filePath.endsWith('.ico')) { return `${relativePath} (binary file)\n[BINARY FILE]\n`; } @@ -107,6 +118,11 @@ require('../../../src/main/index'); describe('Main Process IPC Handlers', () => { beforeEach(() => { jest.clearAllMocks(); + const { dialog } = require('electron'); + dialog.showSaveDialog.mockResolvedValue({ + canceled: false, + filePath: '/mock/repo/output.md', + }); // Basic mocks for file system fs.readdirSync.mockImplementation((dir) => { @@ -391,6 +407,37 @@ describe('Main Process IPC Handlers', () => { expect(result.content).toContain('package.json'); }); + test('should generate xml output when exportFormat is xml', async () => { + const rootPath = '/mock/repo'; + const filesInfo = [ + { path: 'src/index.js', tokens: 100 }, + { path: 'package.json', tokens: 50 }, + ]; + const options = { + showTokenCount: true, + includeTreeView: true, + exportFormat: 'xml', + }; + + const handler = mockIpcHandlers['repo:process']; + const result = await handler(null, { + rootPath, + filesInfo, + treeView: '/ mock-repo\n ├── src\n │ └── index.js\n └── package.json', + options, + }); + + expect(result.content).toContain(''); + expect(result.content).toContain(''); + expect(result.content).toContain(''); + expect(result.content).toContain(''); + expect(result.content).toContain( + '' + ); + expect(result.content).toContain(''); + }); + test('should handle binary files correctly', async () => { // Setup const rootPath = '/mock/repo'; @@ -449,6 +496,68 @@ describe('Main Process IPC Handlers', () => { }); }); + describe('fs:saveFile', () => { + test('should prioritize xml filter when default path uses .xml extension', async () => { + const { dialog } = require('electron'); + dialog.showSaveDialog.mockResolvedValue({ + canceled: false, + filePath: '/mock/repo/output.xml', + }); + + const handler = mockIpcHandlers['fs:saveFile']; + const result = await handler(null, { + content: '', + defaultPath: '/mock/repo/output.xml', + }); + + const saveDialogOptions = dialog.showSaveDialog.mock.calls[0][1]; + expect(saveDialogOptions.filters[0]).toEqual({ + name: 'XML Files', + extensions: ['xml'], + }); + expect(result).toBe('/mock/repo/output.xml'); + expect(fs.writeFileSync).toHaveBeenCalledWith('/mock/repo/output.xml', ''); + }); + + test('should prioritize markdown filter when default path uses .md extension', async () => { + const { dialog } = require('electron'); + dialog.showSaveDialog.mockResolvedValue({ + canceled: false, + filePath: '/mock/repo/output.md', + }); + + const handler = mockIpcHandlers['fs:saveFile']; + await handler(null, { + content: '# output', + defaultPath: '/mock/repo/output.md', + }); + + const saveDialogOptions = dialog.showSaveDialog.mock.calls[0][1]; + expect(saveDialogOptions.filters[0]).toEqual({ + name: 'Markdown Files', + extensions: ['md'], + }); + expect(fs.writeFileSync).toHaveBeenCalledWith('/mock/repo/output.md', '# output'); + }); + + test('should return null when save dialog is canceled', async () => { + const { dialog } = require('electron'); + dialog.showSaveDialog.mockResolvedValue({ + canceled: true, + filePath: undefined, + }); + + const handler = mockIpcHandlers['fs:saveFile']; + const result = await handler(null, { + content: '# output', + defaultPath: '/mock/repo/output.md', + }); + + expect(result).toBeNull(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + }); + describe('tokens:countFiles', () => { test('should count tokens for multiple files', async () => { // Setup diff --git a/tests/integration/main-process/xml-export-e2e.test.ts b/tests/integration/main-process/xml-export-e2e.test.ts new file mode 100644 index 0000000..b8d04e0 --- /dev/null +++ b/tests/integration/main-process/xml-export-e2e.test.ts @@ -0,0 +1,94 @@ +const os = require('os'); +const path = require('path'); + +jest.unmock('fs'); +jest.unmock('../../../src/utils/content-processor'); + +const fs = require('fs'); + +describe('XML export end-to-end', () => { + const mockIpcHandlers = {}; + let tempRoot = ''; + + beforeEach(() => { + jest.resetModules(); + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-code-fusion-xml-')); + + jest.doMock('electron', () => ({ + app: { + whenReady: jest.fn().mockResolvedValue(), + on: jest.fn(), + setAppUserModelId: jest.fn(), + quit: jest.fn(), + }, + BrowserWindow: jest.fn().mockImplementation(() => ({ + loadFile: jest.fn().mockResolvedValue(null), + on: jest.fn(), + setMenu: jest.fn(), + webContents: { + openDevTools: jest.fn(), + }, + })), + ipcMain: { + handle: jest.fn((channel, handler) => { + mockIpcHandlers[channel] = handler; + }), + }, + dialog: { + showOpenDialog: jest.fn(), + showSaveDialog: jest.fn(), + }, + protocol: { + registerFileProtocol: jest.fn(), + }, + })); + + jest.isolateModules(() => { + require('../../../src/main/index'); + }); + }); + + afterEach(() => { + if (tempRoot && fs.existsSync(tempRoot)) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + test('generates well-formed xml structure with safe content handling', async () => { + const srcDir = path.join(tempRoot, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + + const filePath = path.join(srcDir, 'sample.ts'); + const fileContent = 'const marker = "]]>";\nconst invalid = "\u0001";\nconst done = true;\n'; + fs.writeFileSync(filePath, fileContent, 'utf-8'); + + const repoProcessHandler = mockIpcHandlers['repo:process']; + expect(repoProcessHandler).toBeDefined(); + + const result = await repoProcessHandler(null, { + rootPath: tempRoot, + filesInfo: [{ path: 'src/sample.ts', tokens: 123 }], + treeView: '/ mock-repository\n└── src\n └── sample.ts', + options: { + exportFormat: 'xml', + includeTreeView: true, + showTokenCount: true, + }, + }); + + expect(result.content).toContain(''); + expect(result.content).toContain(''); + expect(result.content).toContain(''); + expect(result.content).toContain(''); + expect(result.content).toContain(']]]]>'); + expect(result.content).not.toContain('\u0001'); + expect(result.content).toContain( + '' + ); + expect(result.content).toContain(''); + expect(result.totalTokens).toBe(123); + expect(result.processedFiles).toBe(1); + expect(result.skippedFiles).toBe(0); + }); +}); diff --git a/tests/mocks/yaml-mock.ts b/tests/mocks/yaml-mock.ts index dedb939..51b1b7c 100644 --- a/tests/mocks/yaml-mock.ts +++ b/tests/mocks/yaml-mock.ts @@ -7,14 +7,31 @@ const yamlMock = { return {}; } - // Return a mock object for testing + const parsedConfig: Record = {}; + if (yamlString.includes('include_extensions')) { - return { - include_extensions: ['.js', '.jsx'], - use_custom_includes: true, - use_gitignore: true, - exclude_patterns: ['**/node_modules/**'], - }; + parsedConfig.include_extensions = ['.js', '.jsx']; + parsedConfig.use_custom_includes = true; + parsedConfig.use_gitignore = true; + parsedConfig.exclude_patterns = ['**/node_modules/**']; + } + + if (/export_format\s*:\s*xml/.test(yamlString)) { + parsedConfig.export_format = 'xml'; + } else if (/export_format\s*:\s*markdown/.test(yamlString)) { + parsedConfig.export_format = 'markdown'; + } + + if (/include_tree_view\s*:\s*true/.test(yamlString)) { + parsedConfig.include_tree_view = true; + } + + if (/show_token_count\s*:\s*true/.test(yamlString)) { + parsedConfig.show_token_count = true; + } + + if (Object.keys(parsedConfig).length > 0) { + return parsedConfig; } return { diff --git a/tests/unit/components/app.test.tsx b/tests/unit/components/app.test.tsx index a066093..1f9f0d2 100644 --- a/tests/unit/components/app.test.tsx +++ b/tests/unit/components/app.test.tsx @@ -358,6 +358,13 @@ describe('App Component', () => { }) ); expect(window.electronAPI.processRepository).toHaveBeenCalled(); + expect(window.electronAPI.processRepository).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + exportFormat: 'markdown', + }), + }) + ); // Verify tab switch const processedTab = tabElements.find((el) => el.textContent === 'Processed'); @@ -417,6 +424,61 @@ describe('App Component', () => { }); }); + test('uses xml export format for processing and save path when configured', async () => { + window.electronAPI.processRepository.mockResolvedValue({ + content: '', + totalTokens: 300, + processedFiles: 2, + skippedFiles: 0, + }); + + localStorage.setItem('rootPath', '/mock/directory'); + localStorage.setItem( + 'configContent', + ['export_format: xml', 'include_tree_view: true', 'show_token_count: true'].join('\n') + ); + + render(); + + await waitFor(() => { + expect((screen.getByTestId('config-content') as HTMLTextAreaElement).value).toContain( + 'export_format: xml' + ); + }); + + const tabElements = screen.getAllByRole('button'); + const sourceTab = tabElements.find((el) => el.textContent === 'Source'); + fireEvent.click(sourceTab); + + const selectFileBtn = screen.getByTestId('mock-select-file-btn'); + fireEvent.click(selectFileBtn); + + const analyzeBtn = screen.getByTestId('analyze-btn'); + await act(async () => { + fireEvent.click(analyzeBtn); + await waitFor(() => window.electronAPI.processRepository.mock.calls.length > 0); + }); + + expect(window.electronAPI.processRepository).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + exportFormat: 'xml', + }), + }) + ); + + const saveBtn = screen.getByTestId('save-btn'); + await act(async () => { + fireEvent.click(saveBtn); + await waitFor(() => window.electronAPI.saveFile.mock.calls.length > 0); + }); + + expect(window.electronAPI.saveFile).toHaveBeenCalledWith({ + content: '', + defaultPath: '/mock/directory/output.xml', + }); + }); + test('handles error when repository analysis fails', async () => { // Setup jest.clearAllMocks(); diff --git a/tests/unit/components/config-tab.test.tsx b/tests/unit/components/config-tab.test.tsx index 370e78c..3290a00 100644 --- a/tests/unit/components/config-tab.test.tsx +++ b/tests/unit/components/config-tab.test.tsx @@ -19,6 +19,7 @@ jest.mock('yaml', () => ({ use_custom_includes: true, enable_secret_scanning: true, exclude_suspicious_files: true, + export_format: 'markdown', }; } return {}; @@ -73,6 +74,7 @@ describe('ConfigTab', () => { expect(screen.getByLabelText('Apply .gitignore rules')).toBeChecked(); expect(screen.getByLabelText('Scan content for secrets')).toBeChecked(); expect(screen.getByLabelText('Exclude suspicious files')).toBeChecked(); + expect(screen.getByLabelText('Export format')).toHaveValue('markdown'); // Check textareas const extensionsTextarea = screen.getByPlaceholderText(/\.py/); @@ -120,6 +122,27 @@ describe('ConfigTab', () => { expect(savedConfig.exclude_suspicious_files).toBe(false); }); + test('persists export format changes in saved config', async () => { + render(); + + const exportFormatSelect = screen.getByLabelText('Export format'); + expect(exportFormatSelect).toHaveValue('markdown'); + + act(() => { + fireEvent.change(exportFormatSelect, { target: { value: 'xml' } }); + jest.advanceTimersByTime(100); + }); + + await waitFor(() => { + expect(mockOnConfigChange).toHaveBeenCalled(); + }); + + const yamlLib = require('yaml'); + expect(yamlLib.stringify).toHaveBeenCalled(); + const savedConfig = yamlLib.stringify.mock.calls.at(-1)[0]; + expect(savedConfig.export_format).toBe('xml'); + }); + test('calls selectDirectory when folder button is clicked', async () => { render(); diff --git a/tests/unit/utils/content-processor.test.ts b/tests/unit/utils/content-processor.test.ts index 26259fd..927c8fc 100644 --- a/tests/unit/utils/content-processor.test.ts +++ b/tests/unit/utils/content-processor.test.ts @@ -58,6 +58,45 @@ describe('ContentProcessor', () => { expect(result).toContain(fileContent); }); + test('should process text files as xml when export format is xml', () => { + const filePath = '/project/src/file.ts'; + const relativePath = 'src/file.ts'; + const fileContent = 'const value = "x";'; + + isBinaryFile.mockReturnValue(false); + fs.readFileSync.mockReturnValue(fileContent); + + const result = contentProcessor.processFile(filePath, relativePath, { + exportFormat: 'xml', + showTokenCount: true, + tokenCount: 7, + }); + + expect(result).toContain(''); + expect(result).toContain(''); + expect(mockTokenCounter.countTokens).not.toHaveBeenCalled(); + }); + + test('should escape cdata end markers and sanitize invalid xml characters', () => { + const filePath = '/project/src/weird.ts'; + const relativePath = 'src/weird.ts'; + const fileContent = 'const marker = "]]>";\u0001const done = true;'; + + isBinaryFile.mockReturnValue(false); + fs.readFileSync.mockReturnValue(fileContent); + + const result = contentProcessor.processFile(filePath, relativePath, { + exportFormat: 'xml', + showTokenCount: true, + }); + + expect(result).toContain(''); + expect(result).not.toContain('\u0001'); + }); + test('should handle binary files correctly', () => { // Setup const filePath = '/project/images/logo.png'; @@ -83,6 +122,23 @@ describe('ContentProcessor', () => { expect(result).toContain('1.00 KB'); }); + test('should process binary files as xml when export format is xml', () => { + const filePath = '/project/images/logo.png'; + const relativePath = 'images/logo.png'; + + isBinaryFile.mockReturnValue(true); + fs.statSync.mockReturnValue({ size: 2048 }); + path.extname.mockReturnValue('.png'); + + const result = contentProcessor.processFile(filePath, relativePath, { exportFormat: 'xml' }); + + expect(result).toContain(''); + }); + test('should handle errors when reading files', () => { // Setup const filePath = '/project/src/missing.js'; diff --git a/tests/unit/utils/export-format.test.ts b/tests/unit/utils/export-format.test.ts new file mode 100644 index 0000000..3c6e439 --- /dev/null +++ b/tests/unit/utils/export-format.test.ts @@ -0,0 +1,42 @@ +const { + normalizeExportFormat, + escapeXmlAttribute, + sanitizeXmlContent, + wrapXmlCdata, + normalizeTokenCount, + toXmlNumericAttribute, +} = require('../../../src/utils/export-format'); + +describe('export-format utils', () => { + test('normalizeExportFormat should accept xml and fallback to markdown', () => { + expect(normalizeExportFormat('xml')).toBe('xml'); + expect(normalizeExportFormat('markdown')).toBe('markdown'); + expect(normalizeExportFormat('other')).toBe('markdown'); + expect(normalizeExportFormat(undefined)).toBe('markdown'); + }); + + test('escapeXmlAttribute should escape xml-sensitive characters', () => { + expect(escapeXmlAttribute(`a&b"c'd`)).toBe('a&b"c'd<e>'); + }); + + test('sanitizeXmlContent should remove invalid xml characters', () => { + const value = 'ok\u0001still\u0008valid\u0009newline\u000A'; + expect(sanitizeXmlContent(value)).toBe('okstillvalid\u0009newline\u000A'); + }); + + test('wrapXmlCdata should split cdata terminators safely', () => { + const wrapped = wrapXmlCdata('hello ]]> world'); + expect(wrapped).toContain(''); + expect(wrapped.endsWith(']]>')).toBe(true); + }); + + test('normalizeTokenCount and toXmlNumericAttribute should coerce invalid inputs to zero', () => { + expect(normalizeTokenCount(12.8)).toBe(12); + expect(normalizeTokenCount(-1)).toBe(0); + expect(normalizeTokenCount('7')).toBe(7); + expect(normalizeTokenCount('not-a-number')).toBe(0); + expect(toXmlNumericAttribute('42')).toBe('42'); + expect(toXmlNumericAttribute('bad')).toBe('0'); + }); +}); From 6474187d38e2ec99093cfab74bb5862808c79757 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Mon, 9 Feb 2026 04:15:42 +0000 Subject: [PATCH 02/16] fix: address PR review feedback for xml export and IPC validation --- src/main/index.ts | 68 +++++++++------ src/main/preload.ts | 9 +- src/renderer/components/App.tsx | 19 ++-- src/renderer/components/SourceTab.tsx | 19 +++- src/types/ipc.ts | 7 +- src/utils/export-format.ts | 27 ++---- .../integration/main-process/handlers.test.ts | 87 +++++++++++++------ tests/unit/components/config-tab.test.tsx | 19 +++- tests/unit/utils/content-processor.test.ts | 18 ++++ tests/unit/utils/export-format.test.ts | 4 +- 10 files changed, 179 insertions(+), 98 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 15f5fac..49b1255 100755 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,7 +5,7 @@ import yaml from 'yaml'; import { loadDefaultConfig } from '../utils/config-manager'; import { ContentProcessor } from '../utils/content-processor'; import { FileAnalyzer, isBinaryFile } from '../utils/file-analyzer'; -import { normalizePath, getRelativePath, shouldExclude } from '../utils/filter-utils'; +import { getRelativePath, shouldExclude } from '../utils/filter-utils'; import { GitignoreParser } from '../utils/gitignore-parser'; import { TokenCounter } from '../utils/token-counter'; import { @@ -18,6 +18,7 @@ import type { AnalyzeRepositoryOptions, AnalyzeRepositoryResult, ConfigObject, + CountFilesTokensOptions, CountFilesTokensResult, DirectoryTreeItem, FileInfo, @@ -132,8 +133,9 @@ ipcMain.handle( // Parse config to get settings and exclude patterns let excludePatterns: FilterPatternBundle = []; + let config: ConfigObject = { exclude_patterns: [] }; try { - const config = (configContent + config = (configContent ? (yaml.parse(configContent) as ConfigObject) : ({ exclude_patterns: [] } as ConfigObject)) || { exclude_patterns: [] }; @@ -178,15 +180,9 @@ ipcMain.handle( console.error('Error parsing config:', error); // Fall back to only hiding .git folder excludePatterns = ['**/.git/**']; + config = { exclude_patterns: [] }; } - // Import the fnmatch module - - // Get the config for filtering - const config = (configContent - ? (yaml.parse(configContent) as ConfigObject) - : ({ exclude_patterns: [] } as ConfigObject)) || { exclude_patterns: [] }; - // Use the shared shouldExclude function from filter-utils const localShouldExclude = (itemPath: string) => { return shouldExclude(itemPath, dirPath, excludePatterns, config); @@ -255,7 +251,17 @@ ipcMain.handle( } ); -// Use the imported normalizePath from filter-utils +const isPathWithinRoot = (rootPath: string, candidatePath: string): boolean => { + if (!rootPath || !candidatePath) { + return false; + } + + const resolvedRootPath = path.resolve(rootPath); + const resolvedCandidatePath = path.resolve(candidatePath); + const relativePath = path.relative(resolvedRootPath, resolvedCandidatePath); + + return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); +}; // Analyze repository ipcMain.handle( @@ -270,7 +276,7 @@ ipcMain.handle( // Process gitignore if enabled let gitignorePatterns = { excludePatterns: [], includePatterns: [] }; - if (config.use_gitignore === true) { + if (config.use_gitignore !== false) { gitignorePatterns = gitignoreParser.parseGitignore(rootPath); } @@ -287,7 +293,7 @@ ipcMain.handle( for (const filePath of selectedFiles) { // Verify the file is within the current root path - if (!filePath.startsWith(rootPath)) { + if (!isPathWithinRoot(rootPath, filePath)) { console.warn(`Skipping file outside current root directory: ${filePath}`); continue; } @@ -466,14 +472,10 @@ ipcMain.handle( const filePath = fileInfo.path; const tokenCount = normalizeTokenCount(fileInfo.tokens); - // Use consistent path joining - const fullPath = path.join(rootPath, filePath); - - // Validate the full path is within the root path - const normalizedFullPath = normalizePath(fullPath); - const normalizedRootPath = normalizePath(rootPath); + // Resolve and validate against root path to prevent traversal and prefix bypasses. + const fullPath = path.resolve(rootPath, filePath); - if (!normalizedFullPath.startsWith(normalizedRootPath)) { + if (!isPathWithinRoot(rootPath, fullPath)) { console.warn(`Skipping file outside root directory: ${filePath}`); skippedFiles++; continue; @@ -544,12 +546,17 @@ ipcMain.handle('fs:saveFile', async (_event, { content, defaultPath }: SaveFileO { name: 'All Files', extensions: ['*'] }, ]; - const { canceled, filePath } = await dialog.showSaveDialog(mainWindow ?? undefined, { - defaultPath, - filters, - }); + const { canceled, filePath } = mainWindow + ? await dialog.showSaveDialog(mainWindow, { + defaultPath, + filters, + }) + : await dialog.showSaveDialog({ + defaultPath, + filters, + }); - if (canceled) { + if (canceled || !filePath) { return null; } @@ -591,14 +598,25 @@ ipcMain.handle('assets:getPath', (_event, assetName: string) => { // Count tokens for multiple files in a single call ipcMain.handle( 'tokens:countFiles', - async (_event, filePaths: string[]): Promise => { + async (_event, options: CountFilesTokensOptions): Promise => { try { + const { rootPath, filePaths } = options ?? {}; + if (!rootPath || !Array.isArray(filePaths) || filePaths.length === 0) { + return { results: {}, stats: {} }; + } + const results: Record = {}; const stats: Record = {}; // Process each file for (const filePath of filePaths) { try { + if (!isPathWithinRoot(rootPath, filePath)) { + console.warn(`Skipping file outside current root directory: ${filePath}`); + results[filePath] = 0; + continue; + } + // Check if file exists if (!fs.existsSync(filePath)) { console.warn(`File not found for token counting: ${filePath}`); diff --git a/src/main/preload.ts b/src/main/preload.ts index 049ef38..49a3d49 100755 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer, shell } from 'electron'; import type { AnalyzeRepositoryOptions, AnalyzeRepositoryResult, + CountFilesTokensOptions, CountFilesTokensResult, DirectoryTreeItem, ElectronApi, @@ -37,7 +38,9 @@ const electronShellApi: ElectronShellApi = { const electronAPI: ElectronApi = { selectDirectory: () => ipcRenderer.invoke('dialog:selectDirectory') as Promise, getDirectoryTree: (dirPath: string, configContent?: string | null) => - ipcRenderer.invoke('fs:getDirectoryTree', dirPath, configContent) as Promise, + ipcRenderer.invoke('fs:getDirectoryTree', dirPath, configContent) as Promise< + DirectoryTreeItem[] + >, saveFile: (options: SaveFileOptions) => ipcRenderer.invoke('fs:saveFile', options) as Promise, resetGitignoreCache: () => ipcRenderer.invoke('gitignore:resetCache') as Promise, @@ -48,8 +51,8 @@ const electronAPI: ElectronApi = { getDefaultConfig: () => ipcRenderer.invoke('config:getDefault') as Promise, getAssetPath: (assetName: string) => ipcRenderer.invoke('assets:getPath', assetName) as Promise, - countFilesTokens: (filePaths: string[]) => - ipcRenderer.invoke('tokens:countFiles', filePaths) as Promise, + countFilesTokens: (options: CountFilesTokensOptions) => + ipcRenderer.invoke('tokens:countFiles', options) as Promise, }; contextBridge.exposeInMainWorld('devUtils', devUtils); diff --git a/src/renderer/components/App.tsx b/src/renderer/components/App.tsx index 994e057..3d4f85b 100755 --- a/src/renderer/components/App.tsx +++ b/src/renderer/components/App.tsx @@ -12,7 +12,6 @@ import type { ConfigObject, DirectoryTreeItem, ExportFormat, - ProcessRepositoryOptions, ProcessRepositoryResult, TabId, } from '../../types/ipc'; @@ -23,15 +22,6 @@ const ensureError = (error: unknown): Error => { return new Error(String(error)); }; -const resolveExportFormatFromConfig = (rawConfigContent: string) => { - try { - const config = (yaml.parse(rawConfigContent) || {}) as ConfigObject; - return normalizeExportFormat(config.export_format); - } catch { - return 'markdown'; - } -}; - type ProcessingOptions = { showTokenCount: boolean; includeTreeView: boolean; @@ -268,7 +258,7 @@ const App = () => { setAnalysisResult(currentAnalysisResult); // Read options from config - const options: ProcessRepositoryOptions['options'] = { + const options: ProcessingOptions = { showTokenCount: true, includeTreeView: false, exportFormat: 'markdown', @@ -281,6 +271,7 @@ const App = () => { } catch (error) { console.error('Error parsing config for processing:', ensureError(error)); } + setProcessingOptions(options); // Process directly without going to analyze tab const result = await window.electronAPI.processRepository({ @@ -340,7 +331,7 @@ const App = () => { setAnalysisResult(currentReanalysisResult); // Get the latest config options - const options: ProcessRepositoryOptions['options'] = { ...processingOptions }; + const options: ProcessingOptions = { ...processingOptions }; try { const configStr = localStorage.getItem('configContent'); if (configStr) { @@ -352,6 +343,7 @@ const App = () => { } catch (error) { console.error('Error parsing config for refresh:', ensureError(error)); } + setProcessingOptions(options); console.log('Processing with fresh analysis and options:', options); @@ -387,8 +379,7 @@ const App = () => { } try { - const exportFormat = resolveExportFormatFromConfig(configContent); - const outputExtension = exportFormat === 'xml' ? 'xml' : 'md'; + const outputExtension = processingOptions.exportFormat === 'xml' ? 'xml' : 'md'; await window.electronAPI?.saveFile?.({ content: processedResult.content, defaultPath: `${rootPath}/output.${outputExtension}`, diff --git a/src/renderer/components/SourceTab.tsx b/src/renderer/components/SourceTab.tsx index 9f5cab6..b7a93bb 100755 --- a/src/renderer/components/SourceTab.tsx +++ b/src/renderer/components/SourceTab.tsx @@ -130,7 +130,10 @@ const SourceTab = ({ const batchSize = Math.min(20, filesToProcess.length); const fileBatch = filesToProcess.slice(0, batchSize); - const { results, stats } = await window.electronAPI!.countFilesTokens(fileBatch); + const { results, stats } = await window.electronAPI!.countFilesTokens({ + rootPath, + filePaths: fileBatch, + }); updateTokenCache(results, stats, setTokenCache); @@ -280,7 +283,9 @@ const SourceTab = ({
Files - {selectedFiles.length} + + {selectedFiles.length} +
{showTokenCount && ( @@ -329,8 +334,14 @@ const SourceTab = ({ setIsAnalyzing(false); }); }} - disabled={!rootPath || (selectedFiles.length === 0 && selectedFolders.length === 0) || isAnalyzing} - className={getProcessButtonClass(rootPath, selectedFiles.length > 0 || selectedFolders.length > 0, isAnalyzing)} + disabled={ + !rootPath || (selectedFiles.length === 0 && selectedFolders.length === 0) || isAnalyzing + } + className={getProcessButtonClass( + rootPath, + selectedFiles.length > 0 || selectedFolders.length > 0, + isAnalyzing + )} > {isAnalyzing ? ( <> diff --git a/src/types/ipc.ts b/src/types/ipc.ts index b892c14..be83720 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -74,6 +74,11 @@ export interface CountFilesTokensResult { stats: Record; } +export interface CountFilesTokensOptions { + rootPath: string; + filePaths: string[]; +} + export interface ElectronApi { selectDirectory: () => Promise; getDirectoryTree: ( @@ -86,5 +91,5 @@ export interface ElectronApi { processRepository: (options: ProcessRepositoryOptions) => Promise; getDefaultConfig: () => Promise; getAssetPath: (assetName: string) => Promise; - countFilesTokens: (filePaths: string[]) => Promise; + countFilesTokens: (options: CountFilesTokensOptions) => Promise; } diff --git a/src/utils/export-format.ts b/src/utils/export-format.ts index d04b1de..15c6b7d 100644 --- a/src/utils/export-format.ts +++ b/src/utils/export-format.ts @@ -1,37 +1,22 @@ import type { ExportFormat } from '../types/ipc'; -const isValidXmlCodePoint = (codePoint: number): boolean => - codePoint === 0x9 || - codePoint === 0xa || - codePoint === 0xd || - (codePoint >= 0x20 && codePoint <= 0xd7ff) || - (codePoint >= 0xe000 && codePoint <= 0xfffd) || - (codePoint >= 0x10000 && codePoint <= 0x10ffff); +const INVALID_XML_CHARACTERS_REGEX = + /[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/gu; // eslint-disable-line no-control-regex export const normalizeExportFormat = (format: unknown): ExportFormat => format === 'xml' ? 'xml' : 'markdown'; +export const sanitizeXmlContent = (value: string): string => + value.replace(INVALID_XML_CHARACTERS_REGEX, ''); + export const escapeXmlAttribute = (value: string): string => - value + sanitizeXmlContent(value) .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, ''') .replace(//g, '>'); -export const sanitizeXmlContent = (value: string): string => { - let sanitized = ''; - - for (const char of value) { - const codePoint = char.codePointAt(0); - if (codePoint !== undefined && isValidXmlCodePoint(codePoint)) { - sanitized += char; - } - } - - return sanitized; -}; - export const wrapXmlCdata = (value: string): string => `/g, ']]]]>')}]]>`; diff --git a/tests/integration/main-process/handlers.test.ts b/tests/integration/main-process/handlers.test.ts index 3dda311..cf53e86 100644 --- a/tests/integration/main-process/handlers.test.ts +++ b/tests/integration/main-process/handlers.test.ts @@ -37,26 +37,23 @@ jest.mock('electron', () => ({ })); jest.mock('fs'); -jest.mock('path', () => ({ - ...jest.requireActual('path'), - join: jest.fn().mockImplementation((...args) => args.join('/')), - normalize: jest.fn().mockImplementation((p) => p), - relative: jest.fn().mockImplementation((from, to) => { - // Simple implementation for testing - if (to.startsWith(from)) { - return to.substring(from.length).replace(/^\//, ''); - } - return to; - }), - extname: jest.fn().mockImplementation((filePath) => { - const parts = filePath.split('.'); - return parts.length > 1 ? `.${parts[parts.length - 1]}` : ''; - }), - basename: jest.fn().mockImplementation((filePath) => { - const parts = filePath.split('/'); - return parts[parts.length - 1]; - }), -})); +jest.mock('path', () => { + const realPath = jest.requireActual('path'); + return { + ...realPath, + join: jest.fn().mockImplementation((...args) => args.join('/')), + normalize: jest.fn().mockImplementation((p) => p), + relative: jest.fn().mockImplementation((from, to) => realPath.posix.relative(from, to)), + extname: jest.fn().mockImplementation((filePath) => { + const parts = filePath.split('.'); + return parts.length > 1 ? `.${parts[parts.length - 1]}` : ''; + }), + basename: jest.fn().mockImplementation((filePath) => { + const parts = filePath.split('/'); + return parts[parts.length - 1]; + }), + }; +}); jest.mock('yaml'); @@ -326,6 +323,8 @@ describe('Main Process IPC Handlers', () => { const selectedFiles = [ '/mock/repo/src/index.js', '/another/path/file.js', // Outside root + '/mock/repo-secrets/config.js', // Prefix collision should be rejected + '/mock/repo/../repo-secrets/hidden.js', // Traversal should be rejected ]; // Execute @@ -494,6 +493,27 @@ describe('Main Process IPC Handlers', () => { // Should have skipped files count expect(result.skippedFiles).toBe(1); }); + + test('should skip files outside root even with traversal or prefix-collision paths', async () => { + const rootPath = '/mock/repo'; + const filesInfo = [ + { path: 'src/index.js', tokens: 100 }, + { path: '../repo-secrets/config.js', tokens: 50 }, + ]; + + const handler = mockIpcHandlers['repo:process']; + const result = await handler(null, { + rootPath, + filesInfo, + treeView: '', + options: {}, + }); + + expect(result.processedFiles).toBe(1); + expect(result.skippedFiles).toBe(1); + expect(result.content).toContain('src/index.js'); + expect(result.content).not.toContain('../repo-secrets/config.js'); + }); }); describe('fs:saveFile', () => { @@ -510,7 +530,8 @@ describe('Main Process IPC Handlers', () => { defaultPath: '/mock/repo/output.xml', }); - const saveDialogOptions = dialog.showSaveDialog.mock.calls[0][1]; + const saveDialogCallArgs = dialog.showSaveDialog.mock.calls[0]; + const saveDialogOptions = saveDialogCallArgs[saveDialogCallArgs.length - 1]; expect(saveDialogOptions.filters[0]).toEqual({ name: 'XML Files', extensions: ['xml'], @@ -532,7 +553,8 @@ describe('Main Process IPC Handlers', () => { defaultPath: '/mock/repo/output.md', }); - const saveDialogOptions = dialog.showSaveDialog.mock.calls[0][1]; + const saveDialogCallArgs = dialog.showSaveDialog.mock.calls[0]; + const saveDialogOptions = saveDialogCallArgs[saveDialogCallArgs.length - 1]; expect(saveDialogOptions.filters[0]).toEqual({ name: 'Markdown Files', extensions: ['md'], @@ -561,6 +583,7 @@ describe('Main Process IPC Handlers', () => { describe('tokens:countFiles', () => { test('should count tokens for multiple files', async () => { // Setup + const rootPath = '/mock/repo'; const filePaths = [ '/mock/repo/src/file1.js', '/mock/repo/src/file2.js', @@ -569,7 +592,7 @@ describe('Main Process IPC Handlers', () => { // Execute const handler = mockIpcHandlers['tokens:countFiles']; - const result = await handler(null, filePaths); + const result = await handler(null, { rootPath, filePaths }); // Verify expect(result).toBeDefined(); @@ -584,6 +607,7 @@ describe('Main Process IPC Handlers', () => { test('should handle binary files correctly', async () => { // Setup + const rootPath = '/mock/repo'; const filePaths = [ '/mock/repo/src/file.js', '/mock/repo/image.png', // binary file @@ -591,7 +615,7 @@ describe('Main Process IPC Handlers', () => { // Execute const handler = mockIpcHandlers['tokens:countFiles']; - const result = await handler(null, filePaths); + const result = await handler(null, { rootPath, filePaths }); // Verify expect(result).toBeDefined(); @@ -604,6 +628,7 @@ describe('Main Process IPC Handlers', () => { test('should handle missing files gracefully', async () => { // Setup + const rootPath = '/mock/repo'; const filePaths = ['/mock/repo/src/file.js', '/mock/repo/nonexistent/file.js']; // Mock to make nonexistent file fail @@ -613,7 +638,7 @@ describe('Main Process IPC Handlers', () => { // Execute const handler = mockIpcHandlers['tokens:countFiles']; - const result = await handler(null, filePaths); + const result = await handler(null, { rootPath, filePaths }); // Verify expect(result).toBeDefined(); @@ -623,5 +648,17 @@ describe('Main Process IPC Handlers', () => { expect(result.results['/mock/repo/src/file.js']).toBe(100); expect(result.results['/mock/repo/nonexistent/file.js']).toBe(0); }); + + test('should skip files outside root path', async () => { + const rootPath = '/mock/repo'; + const filePaths = ['/mock/repo/src/file.js', '/mock/repo-secrets/secret.js']; + + const handler = mockIpcHandlers['tokens:countFiles']; + const result = await handler(null, { rootPath, filePaths }); + + expect(result.results['/mock/repo/src/file.js']).toBe(100); + expect(result.results['/mock/repo-secrets/secret.js']).toBe(0); + expect(result.stats['/mock/repo-secrets/secret.js']).toBeUndefined(); + }); }); }); diff --git a/tests/unit/components/config-tab.test.tsx b/tests/unit/components/config-tab.test.tsx index 3290a00..ff42621 100644 --- a/tests/unit/components/config-tab.test.tsx +++ b/tests/unit/components/config-tab.test.tsx @@ -10,7 +10,8 @@ jest.mock('../../../src/utils/formatters/list-formatter', () => ({ // Mock yaml package jest.mock('yaml', () => ({ - parse: jest.fn().mockImplementation((str) => { + parse: jest.fn().mockImplementation((str = '') => { + const exportFormat = str.includes('export_format: xml') ? 'xml' : 'markdown'; if (str && str.includes('include_extensions')) { return { include_extensions: ['.js', '.jsx'], @@ -19,10 +20,15 @@ jest.mock('yaml', () => ({ use_custom_includes: true, enable_secret_scanning: true, exclude_suspicious_files: true, - export_format: 'markdown', + export_format: exportFormat, }; } - return {}; + if (str && str.includes('export_format')) { + return { + export_format: exportFormat, + }; + } + return { export_format: 'markdown' }; }), stringify: jest.fn().mockReturnValue('mocked yaml string'), })); @@ -143,6 +149,13 @@ describe('ConfigTab', () => { expect(savedConfig.export_format).toBe('xml'); }); + test('initializes export format selector to xml when config specifies export_format: xml', () => { + const xmlConfigContent = `${mockConfigContent}\nexport_format: xml`; + render(); + + expect(screen.getByLabelText('Export format')).toHaveValue('xml'); + }); + test('calls selectDirectory when folder button is clicked', async () => { render(); diff --git a/tests/unit/utils/content-processor.test.ts b/tests/unit/utils/content-processor.test.ts index 927c8fc..dca30e5 100644 --- a/tests/unit/utils/content-processor.test.ts +++ b/tests/unit/utils/content-processor.test.ts @@ -79,6 +79,24 @@ describe('ContentProcessor', () => { expect(mockTokenCounter.countTokens).not.toHaveBeenCalled(); }); + test('should sanitize invalid xml code points in xml attribute values', () => { + const filePath = '/project/src/weird.ts'; + const relativePath = 'src/weird\u0001name.ts'; + const fileContent = 'const value = "ok";'; + + isBinaryFile.mockReturnValue(false); + fs.readFileSync.mockReturnValue(fileContent); + + const result = contentProcessor.processFile(filePath, relativePath, { + exportFormat: 'xml', + showTokenCount: true, + tokenCount: 5, + }); + + expect(result).toContain(''); + expect(result).not.toContain('\u0001'); + }); + test('should escape cdata end markers and sanitize invalid xml characters', () => { const filePath = '/project/src/weird.ts'; const relativePath = 'src/weird.ts'; diff --git a/tests/unit/utils/export-format.test.ts b/tests/unit/utils/export-format.test.ts index 3c6e439..fbbddbf 100644 --- a/tests/unit/utils/export-format.test.ts +++ b/tests/unit/utils/export-format.test.ts @@ -15,8 +15,8 @@ describe('export-format utils', () => { expect(normalizeExportFormat(undefined)).toBe('markdown'); }); - test('escapeXmlAttribute should escape xml-sensitive characters', () => { - expect(escapeXmlAttribute(`a&b"c'd`)).toBe('a&b"c'd<e>'); + test('escapeXmlAttribute should escape xml-sensitive characters and remove invalid code points', () => { + expect(escapeXmlAttribute(`a&b"c'd\u0001`)).toBe('a&b"c'd<e>'); }); test('sanitizeXmlContent should remove invalid xml characters', () => { From 47ab23b644794f00899b1bc67907cc04f336c687 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Mon, 9 Feb 2026 04:19:35 +0000 Subject: [PATCH 03/16] test: make path mock OS-native for windows CI --- tests/integration/main-process/handlers.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/main-process/handlers.test.ts b/tests/integration/main-process/handlers.test.ts index cf53e86..16db7e1 100644 --- a/tests/integration/main-process/handlers.test.ts +++ b/tests/integration/main-process/handlers.test.ts @@ -43,7 +43,7 @@ jest.mock('path', () => { ...realPath, join: jest.fn().mockImplementation((...args) => args.join('/')), normalize: jest.fn().mockImplementation((p) => p), - relative: jest.fn().mockImplementation((from, to) => realPath.posix.relative(from, to)), + relative: jest.fn().mockImplementation((from, to) => realPath.relative(from, to)), extname: jest.fn().mockImplementation((filePath) => { const parts = filePath.split('.'); return parts.length > 1 ? `.${parts[parts.length - 1]}` : ''; From 82e47c117f528a7eb05e8e91fc1f145d85511322 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Mon, 9 Feb 2026 04:26:39 +0000 Subject: [PATCH 04/16] fix: align gitignore defaults and harden root-scoped path resolution --- src/main/index.ts | 24 +++++++++++-------- .../integration/main-process/handlers.test.ts | 24 +++++++++++++++++++ 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 49b1255..0e77681 100755 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -282,7 +282,7 @@ ipcMain.handle( // Create a file analyzer instance with the appropriate settings const fileAnalyzer = new FileAnalyzer(config, localTokenCounter, { - useGitignore: config.use_gitignore === true, + useGitignore: config.use_gitignore !== false, gitignorePatterns: gitignorePatterns, }); @@ -292,17 +292,19 @@ ipcMain.handle( let skippedBinaryFiles = 0; for (const filePath of selectedFiles) { + const resolvedFilePath = path.resolve(rootPath, filePath); + // Verify the file is within the current root path - if (!isPathWithinRoot(rootPath, filePath)) { + if (!isPathWithinRoot(rootPath, resolvedFilePath)) { console.warn(`Skipping file outside current root directory: ${filePath}`); continue; } // Use consistent path normalization - const relativePath = getRelativePath(filePath, rootPath); + const relativePath = getRelativePath(resolvedFilePath, rootPath); // For binary files, record them as skipped but don't prevent selection - const binaryFile = isBinaryFile(filePath); + const binaryFile = isBinaryFile(resolvedFilePath); if (binaryFile) { console.log(`Binary file detected (will skip processing): ${relativePath}`); skippedBinaryFiles++; @@ -316,7 +318,7 @@ ipcMain.handle( isBinary: true, }); } else if (fileAnalyzer.shouldProcessFile(relativePath)) { - const tokenCount = fileAnalyzer.analyzeFile(filePath); + const tokenCount = fileAnalyzer.analyzeFile(resolvedFilePath); if (tokenCount !== null) { filesInfo.push({ @@ -611,35 +613,37 @@ ipcMain.handle( // Process each file for (const filePath of filePaths) { try { - if (!isPathWithinRoot(rootPath, filePath)) { + const resolvedFilePath = path.resolve(rootPath, filePath); + + if (!isPathWithinRoot(rootPath, resolvedFilePath)) { console.warn(`Skipping file outside current root directory: ${filePath}`); results[filePath] = 0; continue; } // Check if file exists - if (!fs.existsSync(filePath)) { + if (!fs.existsSync(resolvedFilePath)) { console.warn(`File not found for token counting: ${filePath}`); results[filePath] = 0; continue; } // Get file stats - const fileStats = fs.statSync(filePath); + const fileStats = fs.statSync(resolvedFilePath); stats[filePath] = { size: fileStats.size, mtime: fileStats.mtime.getTime(), // Modification time for cache validation }; // Skip binary files - if (isBinaryFile(filePath)) { + if (isBinaryFile(resolvedFilePath)) { console.log(`Skipping binary file for token counting: ${filePath}`); results[filePath] = 0; continue; } // Read file content - const content = fs.readFileSync(filePath, { encoding: 'utf-8', flag: 'r' }); + const content = fs.readFileSync(resolvedFilePath, { encoding: 'utf-8', flag: 'r' }); // Count tokens using the singleton token counter const tokenCount = tokenCounter.countTokens(content); diff --git a/tests/integration/main-process/handlers.test.ts b/tests/integration/main-process/handlers.test.ts index 16db7e1..f7dfd51 100644 --- a/tests/integration/main-process/handlers.test.ts +++ b/tests/integration/main-process/handlers.test.ts @@ -342,6 +342,18 @@ describe('Main Process IPC Handlers', () => { expect(result.filesInfo.length).toBe(1); }); + test('should allow relative selected files inside root and reject traversal', async () => { + const rootPath = '/mock/repo'; + const configContent = ''; + const selectedFiles = ['src/index.js', '../repo-secrets/hidden.js']; + + const handler = mockIpcHandlers['repo:analyze']; + const result = await handler(null, { rootPath, configContent, selectedFiles }); + + expect(result.filesInfo.find((f) => f.path === 'src/index.js')).toBeDefined(); + expect(result.filesInfo.length).toBe(1); + }); + test('should skip suspicious file content when secret scanning is enabled', async () => { const rootPath = '/mock/repo'; const configContent = ` @@ -660,5 +672,17 @@ describe('Main Process IPC Handlers', () => { expect(result.results['/mock/repo-secrets/secret.js']).toBe(0); expect(result.stats['/mock/repo-secrets/secret.js']).toBeUndefined(); }); + + test('should resolve relative paths against root and reject traversal escapes', async () => { + const rootPath = '/mock/repo'; + const filePaths = ['src/file.js', '../repo-secrets/secret.js']; + + const handler = mockIpcHandlers['tokens:countFiles']; + const result = await handler(null, { rootPath, filePaths }); + + expect(result.results['src/file.js']).toBe(100); + expect(result.results['../repo-secrets/secret.js']).toBe(0); + expect(result.stats['../repo-secrets/secret.js']).toBeUndefined(); + }); }); }); From aa55686406cc6a5e2c5c89f6c596af6c6d6fa273 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Mon, 9 Feb 2026 13:39:43 +0000 Subject: [PATCH 05/16] fix: preserve processed export format on save and sync test catalog --- src/main/index.ts | 1 + src/renderer/components/App.tsx | 2 +- src/types/ipc.ts | 1 + tests/catalog.md | 13 +++-- .../integration/main-process/handlers.test.ts | 2 + .../main-process/xml-export-e2e.test.ts | 1 + tests/setup.ts | 1 + tests/unit/components/app.test.tsx | 56 +++++++++++++++++++ 8 files changed, 72 insertions(+), 5 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 0e77681..d627622 100755 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -518,6 +518,7 @@ ipcMain.handle( return { content: processedContent, + exportFormat: processingOptions.exportFormat, totalTokens, processedFiles, skippedFiles, diff --git a/src/renderer/components/App.tsx b/src/renderer/components/App.tsx index 3d4f85b..0dffe82 100755 --- a/src/renderer/components/App.tsx +++ b/src/renderer/components/App.tsx @@ -379,7 +379,7 @@ const App = () => { } try { - const outputExtension = processingOptions.exportFormat === 'xml' ? 'xml' : 'md'; + const outputExtension = processedResult.exportFormat === 'xml' ? 'xml' : 'md'; await window.electronAPI?.saveFile?.({ content: processedResult.content, defaultPath: `${rootPath}/output.${outputExtension}`, diff --git a/src/types/ipc.ts b/src/types/ipc.ts index be83720..5cd797d 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -58,6 +58,7 @@ export interface ProcessRepositoryOptions { export interface ProcessRepositoryResult { content: string; + exportFormat: ExportFormat; totalTokens: number; processedFiles: number; skippedFiles: number; diff --git a/tests/catalog.md b/tests/catalog.md index 0f81159..cee4a83 100644 --- a/tests/catalog.md +++ b/tests/catalog.md @@ -21,6 +21,7 @@ Purpose: quick map of what is covered, why it exists, and which command to run. | `tests/unit/utils/filter-utils.test.ts` | `src/utils/filter-utils.ts` | Path normalization, extension filtering, custom excludes, gitignore precedence | | `tests/unit/utils/secret-scanner.test.ts` | `src/utils/secret-scanner.ts` | Sensitive path detection, secret-pattern scanning, default-on safety toggles | | `tests/unit/utils/fnmatch.test.ts` | `src/utils/fnmatch.ts` | Glob semantics: wildcards, classes, double-star, braces, path anchors | +| `tests/unit/utils/export-format.test.ts` | `src/utils/export-format.ts` | Export format normalization, XML attribute escaping, CDATA-safe sanitization | | `tests/unit/utils/content-processor.test.ts` | `src/utils/content-processor.ts` | Content assembly, binary skip logic, malformed input handling | | `tests/unit/utils/config-manager.test.ts` | `src/utils/config-manager.ts` | Default config load, parse failures, graceful fallback behavior | | `tests/unit/utils/token-counter.test.ts` | `src/utils/token-counter.ts` | Token counting basics, empty/null input handling | @@ -28,10 +29,11 @@ Purpose: quick map of what is covered, why it exists, and which command to run. ## Integration Tests -| File | Primary Target | Key Use Cases | -| ------------------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------- | -| `tests/integration/main-process/handlers.test.ts` | Main IPC handlers | `fs:getDirectoryTree`, `repo:analyze`, `repo:process`, `tokens:countFiles` correctness and failures | -| `tests/integration/pattern-merging.test.ts` | Filtering + gitignore merge behavior | Combined behavior of include/exclude patterns with gitignore toggles | +| File | Primary Target | Key Use Cases | +| ------------------------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------- | +| `tests/integration/main-process/handlers.test.ts` | Main IPC handlers | `fs:getDirectoryTree`, `repo:analyze`, `repo:process`, `tokens:countFiles` correctness and failures | +| `tests/integration/main-process/xml-export-e2e.test.ts` | XML export pipeline | End-to-end XML shape, CDATA wrapping, invalid-character sanitization, summary metrics | +| `tests/integration/pattern-merging.test.ts` | Filtering + gitignore merge behavior | Combined behavior of include/exclude patterns with gitignore toggles | ## Visual Regression Signal @@ -55,5 +57,8 @@ Purpose: quick map of what is covered, why it exists, and which command to run. - `tests/integration/main-process/handlers.test.ts` - Content/token pipeline changes: - `tests/unit/file-analyzer.test.ts` + - `tests/unit/utils/export-format.test.ts` - `tests/unit/utils/content-processor.test.ts` - `tests/unit/utils/token-counter.test.ts` +- XML export end-to-end: + - `tests/integration/main-process/xml-export-e2e.test.ts` diff --git a/tests/integration/main-process/handlers.test.ts b/tests/integration/main-process/handlers.test.ts index f7dfd51..0be36be 100644 --- a/tests/integration/main-process/handlers.test.ts +++ b/tests/integration/main-process/handlers.test.ts @@ -406,6 +406,7 @@ describe('Main Process IPC Handlers', () => { // Verify expect(result).toBeDefined(); + expect(result.exportFormat).toBe('markdown'); expect(result.content).toBeDefined(); expect(typeof result.content).toBe('string'); @@ -439,6 +440,7 @@ describe('Main Process IPC Handlers', () => { }); expect(result.content).toContain(''); + expect(result.exportFormat).toBe('xml'); expect(result.content).toContain(''); expect(result.content).toContain(''); diff --git a/tests/integration/main-process/xml-export-e2e.test.ts b/tests/integration/main-process/xml-export-e2e.test.ts index b8d04e0..a75a19c 100644 --- a/tests/integration/main-process/xml-export-e2e.test.ts +++ b/tests/integration/main-process/xml-export-e2e.test.ts @@ -77,6 +77,7 @@ describe('XML export end-to-end', () => { }); expect(result.content).toContain(''); + expect(result.exportFormat).toBe('xml'); expect(result.content).toContain(''); expect(result.content).toContain(''); diff --git a/tests/setup.ts b/tests/setup.ts index 7d4cb8c..1484871 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -29,6 +29,7 @@ window.electronAPI = { }), processRepository: jest.fn().mockResolvedValue({ content: '', + exportFormat: 'markdown', totalTokens: 0, processedFiles: 0, skippedFiles: 0, diff --git a/tests/unit/components/app.test.tsx b/tests/unit/components/app.test.tsx index 1f9f0d2..b424d0c 100644 --- a/tests/unit/components/app.test.tsx +++ b/tests/unit/components/app.test.tsx @@ -170,6 +170,7 @@ window.electronAPI = { }), processRepository: jest.fn().mockResolvedValue({ content: 'Processed content', + exportFormat: 'markdown', totalTokens: 300, processedFiles: 2, skippedFiles: 0, @@ -378,6 +379,7 @@ describe('App Component', () => { // Mock API responses window.electronAPI.processRepository.mockResolvedValue({ content: 'Test processed content', + exportFormat: 'markdown', totalTokens: 300, processedFiles: 2, skippedFiles: 0, @@ -427,6 +429,7 @@ describe('App Component', () => { test('uses xml export format for processing and save path when configured', async () => { window.electronAPI.processRepository.mockResolvedValue({ content: '', + exportFormat: 'xml', totalTokens: 300, processedFiles: 2, skippedFiles: 0, @@ -479,6 +482,59 @@ describe('App Component', () => { }); }); + test('saves using processed result format even if config changes later', async () => { + window.electronAPI.processRepository.mockResolvedValue({ + content: 'Processed markdown content', + exportFormat: 'markdown', + totalTokens: 120, + processedFiles: 1, + skippedFiles: 0, + }); + + localStorage.setItem('rootPath', '/mock/directory'); + localStorage.setItem( + 'configContent', + ['export_format: markdown', 'include_tree_view: false', 'show_token_count: true'].join('\n') + ); + + render(); + + const tabElements = screen.getAllByRole('button'); + const sourceTab = tabElements.find((el) => el.textContent === 'Source'); + fireEvent.click(sourceTab); + + fireEvent.click(screen.getByTestId('mock-select-file-btn')); + + await act(async () => { + fireEvent.click(screen.getByTestId('analyze-btn')); + await waitFor(() => window.electronAPI.processRepository.mock.calls.length > 0); + }); + + const configTab = tabElements.find((el) => el.textContent === 'Config'); + fireEvent.click(configTab); + + fireEvent.change(screen.getByTestId('config-content'), { + target: { + value: ['export_format: xml', 'include_tree_view: false', 'show_token_count: true'].join( + '\n' + ), + }, + }); + + const processedTab = tabElements.find((el) => el.textContent === 'Processed'); + fireEvent.click(processedTab); + + await act(async () => { + fireEvent.click(screen.getByTestId('save-btn')); + await waitFor(() => window.electronAPI.saveFile.mock.calls.length > 0); + }); + + expect(window.electronAPI.saveFile).toHaveBeenCalledWith({ + content: 'Processed markdown content', + defaultPath: '/mock/directory/output.md', + }); + }); + test('handles error when repository analysis fails', async () => { // Setup jest.clearAllMocks(); From 7b3f0aaf2192bfed064038fd577eab25a535c955 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Mon, 9 Feb 2026 14:40:30 +0000 Subject: [PATCH 06/16] fix: preserve legacy markdown/token defaults for missing config keys --- src/main/index.ts | 2 +- src/renderer/components/App.tsx | 10 +++---- .../integration/main-process/handlers.test.ts | 24 +++++++++++++++ tests/unit/components/app.test.tsx | 30 +++++++++++++++++++ 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index d627622..26a342c 100755 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -451,7 +451,7 @@ ipcMain.handle( } } - if (processingOptions.exportFormat === 'markdown') { + if (processingOptions.exportFormat === 'markdown' && processingOptions.includeTreeView) { processedContent += '## File Contents\n\n'; } diff --git a/src/renderer/components/App.tsx b/src/renderer/components/App.tsx index 0dffe82..07e0b8c 100755 --- a/src/renderer/components/App.tsx +++ b/src/renderer/components/App.tsx @@ -37,7 +37,7 @@ const App = () => { const [, setAnalysisResult] = useState(null); const [processedResult, setProcessedResult] = useState(null); const [processingOptions, setProcessingOptions] = useState({ - showTokenCount: true, + showTokenCount: false, includeTreeView: false, exportFormat: 'markdown', }); @@ -132,7 +132,7 @@ const App = () => { // Update processing options from config to maintain consistency setProcessingOptions({ - showTokenCount: config.show_token_count !== false, + showTokenCount: config.show_token_count === true, includeTreeView: config.include_tree_view === true, exportFormat: normalizeExportFormat(config.export_format), }); @@ -259,13 +259,13 @@ const App = () => { // Read options from config const options: ProcessingOptions = { - showTokenCount: true, + showTokenCount: false, includeTreeView: false, exportFormat: 'markdown', }; try { const config = (yaml.parse(configContent) || {}) as ConfigObject; - options.showTokenCount = config.show_token_count !== false; + options.showTokenCount = config.show_token_count === true; options.includeTreeView = config.include_tree_view === true; options.exportFormat = normalizeExportFormat(config.export_format); } catch (error) { @@ -336,7 +336,7 @@ const App = () => { const configStr = localStorage.getItem('configContent'); if (configStr) { const config = (yaml.parse(configStr) || {}) as ConfigObject; - options.showTokenCount = config.show_token_count !== false; + options.showTokenCount = config.show_token_count === true; options.includeTreeView = config.include_tree_view === true; options.exportFormat = normalizeExportFormat(config.export_format); } diff --git a/tests/integration/main-process/handlers.test.ts b/tests/integration/main-process/handlers.test.ts index 0be36be..032d0d6 100644 --- a/tests/integration/main-process/handlers.test.ts +++ b/tests/integration/main-process/handlers.test.ts @@ -413,12 +413,36 @@ describe('Main Process IPC Handlers', () => { // Should include tree view expect(result.content).toContain('## File Structure'); expect(result.content).toContain('```'); + expect(result.content).toContain('## File Contents'); // Should include file content sections expect(result.content).toContain('src/index.js'); expect(result.content).toContain('package.json'); }); + test('should not include markdown file-contents heading when tree view is disabled', async () => { + const rootPath = '/mock/repo'; + const filesInfo = [{ path: 'src/index.js', tokens: 100 }]; + const options = { + showTokenCount: true, + includeTreeView: false, + }; + + const handler = mockIpcHandlers['repo:process']; + const result = await handler(null, { + rootPath, + filesInfo, + treeView: '', + options, + }); + + expect(result.exportFormat).toBe('markdown'); + expect(result.content).toContain('# Repository Content'); + expect(result.content).not.toContain('## File Structure'); + expect(result.content).not.toContain('## File Contents'); + expect(result.content).toContain('src/index.js'); + }); + test('should generate xml output when exportFormat is xml', async () => { const rootPath = '/mock/repo'; const filesInfo = [ diff --git a/tests/unit/components/app.test.tsx b/tests/unit/components/app.test.tsx index b424d0c..a2666a2 100644 --- a/tests/unit/components/app.test.tsx +++ b/tests/unit/components/app.test.tsx @@ -482,6 +482,36 @@ describe('App Component', () => { }); }); + test('defaults showTokenCount to false when config omits show_token_count', async () => { + localStorage.setItem('rootPath', '/mock/directory'); + localStorage.setItem( + 'configContent', + ['export_format: markdown', 'include_tree_view: false'].join('\n') + ); + + render(); + + const tabElements = screen.getAllByRole('button'); + const sourceTab = tabElements.find((el) => el.textContent === 'Source'); + fireEvent.click(sourceTab); + + fireEvent.click(screen.getByTestId('mock-select-file-btn')); + + await act(async () => { + fireEvent.click(screen.getByTestId('analyze-btn')); + await waitFor(() => window.electronAPI.processRepository.mock.calls.length > 0); + }); + + expect(window.electronAPI.processRepository).toHaveBeenLastCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + showTokenCount: false, + exportFormat: 'markdown', + }), + }) + ); + }); + test('saves using processed result format even if config changes later', async () => { window.electronAPI.processRepository.mockResolvedValue({ content: 'Processed markdown content', From 14316293aa9c4e24f0218509524d321db0d719c4 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Mon, 9 Feb 2026 14:49:41 +0000 Subject: [PATCH 07/16] fix: harden renderer root boundary checks and align token defaults --- src/main/index.ts | 2 +- src/renderer/components/App.tsx | 49 ++++++++++++++++--- .../integration/main-process/handlers.test.ts | 21 ++++++++ tests/unit/components/app.test.tsx | 19 +++++++ 4 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 26a342c..0d19bc5 100755 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -422,7 +422,7 @@ ipcMain.handle( // Ensure options is an object with default values if missing const processingOptions = { - showTokenCount: options.showTokenCount !== false, // Default to true if not explicitly false + showTokenCount: options.showTokenCount === true, includeTreeView: options.includeTreeView === true, exportFormat: normalizeExportFormat(options.exportFormat), }; diff --git a/src/renderer/components/App.tsx b/src/renderer/components/App.tsx index 07e0b8c..8f68689 100755 --- a/src/renderer/components/App.tsx +++ b/src/renderer/components/App.tsx @@ -226,7 +226,7 @@ const App = () => { try { // Validate selected files before analysis const validFiles = selectedFiles.filter((file) => { - const withinRoot = file.startsWith(rootPath); + const withinRoot = isPathWithinRootBoundary(file); if (!withinRoot) { console.warn(`Skipping file outside current root directory: ${file}`); @@ -300,8 +300,43 @@ const App = () => { } }; - // Helper function for consistent path normalization (used by handleFolderSelect indirectly) - // We'll just use inline path normalization where needed + const normalizePathForBoundaryCheck = (inputPath: string): string => { + const normalizedSlashes = inputPath.replace(/\\/g, '/'); + const driveMatch = normalizedSlashes.match(/^[A-Za-z]:/); + const drivePrefix = driveMatch ? driveMatch[0].toLowerCase() : ''; + const pathWithoutDrive = drivePrefix ? normalizedSlashes.slice(2) : normalizedSlashes; + const hasLeadingSlash = pathWithoutDrive.startsWith('/'); + + const segments = pathWithoutDrive.split('/').filter((segment) => segment && segment !== '.'); + const resolvedSegments: string[] = []; + + for (const segment of segments) { + if (segment === '..') { + if (resolvedSegments.length > 0 && resolvedSegments[resolvedSegments.length - 1] !== '..') { + resolvedSegments.pop(); + } + continue; + } + + resolvedSegments.push(segment); + } + + return `${drivePrefix}${hasLeadingSlash ? '/' : ''}${resolvedSegments.join('/')}`; + }; + + const isPathWithinRootBoundary = (candidatePath: string): boolean => { + if (!candidatePath || !rootPath) { + return false; + } + + const normalizedRootPath = normalizePathForBoundaryCheck(rootPath); + const normalizedCandidatePath = normalizePathForBoundaryCheck(candidatePath); + + return ( + normalizedCandidatePath === normalizedRootPath || + normalizedCandidatePath.startsWith(`${normalizedRootPath}/`) + ); + }; // Method to reload and reprocess files with the latest content const handleRefreshProcessed = async () => { @@ -397,7 +432,7 @@ const App = () => { if (!filePath || !rootPath) return false; // Ensure the file is within the current root path - return filePath.startsWith(rootPath); + return isPathWithinRootBoundary(filePath); }; const handleFileSelect = (filePath: string, isSelected: boolean) => { @@ -419,7 +454,7 @@ const App = () => { const handleFolderSelect = (folderPath: string, isSelected: boolean) => { // Validate folder path before selection - if (isSelected && (!folderPath || !rootPath || !folderPath.startsWith(rootPath))) { + if (isSelected && !isPathWithinRootBoundary(folderPath)) { console.warn(`Attempted to select an invalid folder: ${folderPath}`); return; } @@ -454,7 +489,7 @@ const App = () => { for (const item of folder.children ?? []) { if (item.type === 'directory') { // Validate each folder is within current root - if (item.path.startsWith(rootPath)) { + if (isPathWithinRootBoundary(item.path)) { folders.push(item.path); folders = [...folders, ...getAllSubFolders(item)]; } @@ -473,7 +508,7 @@ const App = () => { for (const item of folder.children ?? []) { if (item.type === 'file') { // Validate each file is within current root - if (item.path.startsWith(rootPath)) { + if (isPathWithinRootBoundary(item.path)) { files.push(item.path); } } else if (item.type === 'directory') { diff --git a/tests/integration/main-process/handlers.test.ts b/tests/integration/main-process/handlers.test.ts index 032d0d6..8e2b4bf 100644 --- a/tests/integration/main-process/handlers.test.ts +++ b/tests/integration/main-process/handlers.test.ts @@ -475,6 +475,27 @@ describe('Main Process IPC Handlers', () => { expect(result.content).toContain(''); }); + test('should omit xml token attributes when showTokenCount is not explicitly enabled', async () => { + const rootPath = '/mock/repo'; + const filesInfo = [{ path: 'src/index.js', tokens: 100 }]; + const options = { + includeTreeView: true, + exportFormat: 'xml', + }; + + const handler = mockIpcHandlers['repo:process']; + const result = await handler(null, { + rootPath, + filesInfo, + treeView: '/ mock-repo\n └── src\n └── index.js', + options, + }); + + expect(result.exportFormat).toBe('xml'); + expect(result.content).toContain(''); + expect(result.content).not.toContain('tokens="100"'); + }); + test('should handle binary files correctly', async () => { // Setup const rootPath = '/mock/repo'; diff --git a/tests/unit/components/app.test.tsx b/tests/unit/components/app.test.tsx index a2666a2..7100377 100644 --- a/tests/unit/components/app.test.tsx +++ b/tests/unit/components/app.test.tsx @@ -91,6 +91,12 @@ jest.mock('../../../src/renderer/components/SourceTab', () => { > Select File +
); }; @@ -309,6 +315,19 @@ describe('App Component', () => { expect(localStorage.setItem).toHaveBeenCalledWith('rootPath', '/mock/directory'); }); + test('rejects prefix-collision file selection outside root path', () => { + localStorage.setItem('rootPath', '/mock/directory'); + render(); + + const tabElements = screen.getAllByRole('button'); + const sourceTab = tabElements.find((el) => el.textContent === 'Source'); + fireEvent.click(sourceTab); + + fireEvent.click(screen.getByTestId('mock-select-invalid-file-btn')); + + expect(screen.getByTestId('selected-files-count').textContent).toBe('0'); + }); + test('analyzes repository and switches to processed tab', async () => { // Setup localStorage.setItem('rootPath', '/mock/directory'); From ee2005ec38388178333cb9bdebdad8c25bf0eaa4 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Mon, 9 Feb 2026 14:57:21 +0000 Subject: [PATCH 08/16] fix: harden save/assets handlers and align token count behavior --- src/main/index.ts | 14 ++++-- src/renderer/components/App.tsx | 10 ++--- .../integration/main-process/handlers.test.ts | 45 +++++++++++++++++-- tests/unit/components/app.test.tsx | 4 +- 4 files changed, 59 insertions(+), 14 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 0d19bc5..d8f733f 100755 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -422,7 +422,7 @@ ipcMain.handle( // Ensure options is an object with default values if missing const processingOptions = { - showTokenCount: options.showTokenCount === true, + showTokenCount: options.showTokenCount !== false, // Default to true if not explicitly false includeTreeView: options.includeTreeView === true, exportFormat: normalizeExportFormat(options.exportFormat), }; @@ -533,7 +533,8 @@ ipcMain.handle( // Save output to file ipcMain.handle('fs:saveFile', async (_event, { content, defaultPath }: SaveFileOptions) => { - const defaultExtension = path.extname(defaultPath).toLowerCase(); + const safeDefaultPath = typeof defaultPath === 'string' ? defaultPath : ''; + const defaultExtension = safeDefaultPath ? path.extname(safeDefaultPath).toLowerCase() : ''; const filters = defaultExtension === '.xml' ? [ @@ -551,11 +552,11 @@ ipcMain.handle('fs:saveFile', async (_event, { content, defaultPath }: SaveFileO const { canceled, filePath } = mainWindow ? await dialog.showSaveDialog(mainWindow, { - defaultPath, + defaultPath: safeDefaultPath, filters, }) : await dialog.showSaveDialog({ - defaultPath, + defaultPath: safeDefaultPath, filters, }); @@ -587,6 +588,11 @@ ipcMain.handle('config:getDefault', async () => { ipcMain.handle('assets:getPath', (_event, assetName: string) => { try { const assetPath = path.join(ASSETS_DIR, assetName); + if (!isPathWithinRoot(ASSETS_DIR, assetPath)) { + console.warn(`Rejected asset path outside assets directory: ${assetName}`); + return null; + } + if (fs.existsSync(assetPath)) { return assetPath; } diff --git a/src/renderer/components/App.tsx b/src/renderer/components/App.tsx index 8f68689..d05d7fd 100755 --- a/src/renderer/components/App.tsx +++ b/src/renderer/components/App.tsx @@ -37,7 +37,7 @@ const App = () => { const [, setAnalysisResult] = useState(null); const [processedResult, setProcessedResult] = useState(null); const [processingOptions, setProcessingOptions] = useState({ - showTokenCount: false, + showTokenCount: true, includeTreeView: false, exportFormat: 'markdown', }); @@ -132,7 +132,7 @@ const App = () => { // Update processing options from config to maintain consistency setProcessingOptions({ - showTokenCount: config.show_token_count === true, + showTokenCount: config.show_token_count !== false, includeTreeView: config.include_tree_view === true, exportFormat: normalizeExportFormat(config.export_format), }); @@ -259,13 +259,13 @@ const App = () => { // Read options from config const options: ProcessingOptions = { - showTokenCount: false, + showTokenCount: true, includeTreeView: false, exportFormat: 'markdown', }; try { const config = (yaml.parse(configContent) || {}) as ConfigObject; - options.showTokenCount = config.show_token_count === true; + options.showTokenCount = config.show_token_count !== false; options.includeTreeView = config.include_tree_view === true; options.exportFormat = normalizeExportFormat(config.export_format); } catch (error) { @@ -371,7 +371,7 @@ const App = () => { const configStr = localStorage.getItem('configContent'); if (configStr) { const config = (yaml.parse(configStr) || {}) as ConfigObject; - options.showTokenCount = config.show_token_count === true; + options.showTokenCount = config.show_token_count !== false; options.includeTreeView = config.include_tree_view === true; options.exportFormat = normalizeExportFormat(config.export_format); } diff --git a/tests/integration/main-process/handlers.test.ts b/tests/integration/main-process/handlers.test.ts index 8e2b4bf..496d8f4 100644 --- a/tests/integration/main-process/handlers.test.ts +++ b/tests/integration/main-process/handlers.test.ts @@ -475,7 +475,7 @@ describe('Main Process IPC Handlers', () => { expect(result.content).toContain(''); }); - test('should omit xml token attributes when showTokenCount is not explicitly enabled', async () => { + test('should include xml token attributes when showTokenCount is not explicitly set', async () => { const rootPath = '/mock/repo'; const filesInfo = [{ path: 'src/index.js', tokens: 100 }]; const options = { @@ -492,8 +492,7 @@ describe('Main Process IPC Handlers', () => { }); expect(result.exportFormat).toBe('xml'); - expect(result.content).toContain(''); - expect(result.content).not.toContain('tokens="100"'); + expect(result.content).toContain(''); }); test('should handle binary files correctly', async () => { @@ -637,6 +636,46 @@ describe('Main Process IPC Handlers', () => { expect(result).toBeNull(); expect(fs.writeFileSync).not.toHaveBeenCalled(); }); + + test('should handle missing defaultPath safely', async () => { + const { dialog } = require('electron'); + dialog.showSaveDialog.mockResolvedValue({ + canceled: false, + filePath: '/mock/repo/output.md', + }); + + const handler = mockIpcHandlers['fs:saveFile']; + const result = await handler(null, { + content: '# output', + defaultPath: undefined, + }); + + const saveDialogCallArgs = dialog.showSaveDialog.mock.calls[0]; + const saveDialogOptions = saveDialogCallArgs[saveDialogCallArgs.length - 1]; + expect(saveDialogOptions.defaultPath).toBe(''); + expect(result).toBe('/mock/repo/output.md'); + expect(fs.writeFileSync).toHaveBeenCalledWith('/mock/repo/output.md', '# output'); + }); + }); + + describe('assets:getPath', () => { + test('should return path for valid assets', async () => { + const handler = mockIpcHandlers['assets:getPath']; + const result = await handler(null, 'icon.png'); + + expect(typeof result).toBe('string'); + expect(result).toContain('icon.png'); + }); + + test('should reject traversal paths outside assets directory', async () => { + const handler = mockIpcHandlers['assets:getPath']; + fs.existsSync.mockClear(); + + const result = await handler(null, '../../etc/passwd'); + + expect(result).toBeNull(); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); }); describe('tokens:countFiles', () => { diff --git a/tests/unit/components/app.test.tsx b/tests/unit/components/app.test.tsx index 7100377..551069d 100644 --- a/tests/unit/components/app.test.tsx +++ b/tests/unit/components/app.test.tsx @@ -501,7 +501,7 @@ describe('App Component', () => { }); }); - test('defaults showTokenCount to false when config omits show_token_count', async () => { + test('defaults showTokenCount to true when config omits show_token_count', async () => { localStorage.setItem('rootPath', '/mock/directory'); localStorage.setItem( 'configContent', @@ -524,7 +524,7 @@ describe('App Component', () => { expect(window.electronAPI.processRepository).toHaveBeenLastCalledWith( expect.objectContaining({ options: expect.objectContaining({ - showTokenCount: false, + showTokenCount: true, exportFormat: 'markdown', }), }) From 3fe130b265a8b8d78be800418422ac09616413b4 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Mon, 9 Feb 2026 15:05:29 +0000 Subject: [PATCH 09/16] fix: block assets protocol traversal and add coverage --- src/main/index.ts | 4 ++ src/renderer/components/App.tsx | 3 ++ .../integration/main-process/handlers.test.ts | 54 ++++++++++++++++++- 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/main/index.ts b/src/main/index.ts index d8f733f..aa2aa78 100755 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -87,6 +87,10 @@ app.whenReady().then(() => { protocol.registerFileProtocol('assets', (request, callback) => { const url = request.url.replace('assets://', ''); const assetPath = path.normalize(path.join(PUBLIC_ASSETS_DIR, url)); + if (!isPathWithinRoot(PUBLIC_ASSETS_DIR, assetPath)) { + callback({ error: -6 }); + return; + } callback({ path: assetPath }); }); diff --git a/src/renderer/components/App.tsx b/src/renderer/components/App.tsx index d05d7fd..31712da 100755 --- a/src/renderer/components/App.tsx +++ b/src/renderer/components/App.tsx @@ -314,6 +314,9 @@ const App = () => { if (segment === '..') { if (resolvedSegments.length > 0 && resolvedSegments[resolvedSegments.length - 1] !== '..') { resolvedSegments.pop(); + } else if (!hasLeadingSlash) { + // Preserve relative parent traversals so boundary checks can reject them. + resolvedSegments.push('..'); } continue; } diff --git a/tests/integration/main-process/handlers.test.ts b/tests/integration/main-process/handlers.test.ts index 496d8f4..c8e0659 100644 --- a/tests/integration/main-process/handlers.test.ts +++ b/tests/integration/main-process/handlers.test.ts @@ -4,6 +4,7 @@ const FAKE_GITHUB_TOKEN = ['ghp', 'AAAAAAAAAAAAAAAAAAAAAAAA'].join('_'); // Mock electron ipcMain const mockIpcHandlers = {}; +const mockProtocolHandlers = {}; const mockIpcMain = { handle: jest.fn((channel, handler) => { mockIpcHandlers[channel] = handler; @@ -32,7 +33,9 @@ jest.mock('electron', () => ({ showSaveDialog: jest.fn(), }, protocol: { - registerFileProtocol: jest.fn(), + registerFileProtocol: jest.fn((scheme, handler) => { + mockProtocolHandlers[scheme] = handler; + }), }, })); @@ -177,6 +180,34 @@ describe('Main Process IPC Handlers', () => { })); }); + describe('assets protocol', () => { + test('should reject traversal in assets protocol requests', async () => { + await Promise.resolve(); + const handler = mockProtocolHandlers['assets']; + expect(handler).toBeDefined(); + + const callback = jest.fn(); + handler({ url: 'assets://../../etc/passwd' }, callback); + + expect(callback).toHaveBeenCalledWith({ error: -6 }); + }); + + test('should resolve valid assets protocol requests', async () => { + await Promise.resolve(); + const handler = mockProtocolHandlers['assets']; + expect(handler).toBeDefined(); + + const callback = jest.fn(); + handler({ url: 'assets://icon.png' }, callback); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + path: expect.stringContaining('icon.png'), + }) + ); + }); + }); + describe('fs:getDirectoryTree', () => { test('should filter directory tree based on config', async () => { // Setup @@ -495,6 +526,27 @@ describe('Main Process IPC Handlers', () => { expect(result.content).toContain(''); }); + test('should omit xml token attributes when showTokenCount is false', async () => { + const rootPath = '/mock/repo'; + const filesInfo = [{ path: 'src/index.js', tokens: 100 }]; + const options = { + showTokenCount: false, + exportFormat: 'xml', + }; + + const handler = mockIpcHandlers['repo:process']; + const result = await handler(null, { + rootPath, + filesInfo, + treeView: '', + options, + }); + + expect(result.exportFormat).toBe('xml'); + expect(result.content).toContain(''); + expect(result.content).not.toContain('tokens="100"'); + }); + test('should handle binary files correctly', async () => { // Setup const rootPath = '/mock/repo'; From fc7c9afb0d7608e6689bc7395e9683c5410f51a0 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Mon, 9 Feb 2026 15:30:42 +0000 Subject: [PATCH 10/16] fix: harden root authorization lifecycle and add regression coverage --- src/main/index.ts | 71 +++++++++--- .../integration/main-process/handlers.test.ts | 106 +++++++++++++++++- .../main-process/xml-export-e2e.test.ts | 19 +++- 3 files changed, 178 insertions(+), 18 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index aa2aa78..91b8c2a 100755 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -35,6 +35,7 @@ const tokenCounter = new TokenCounter(); // Keep a global reference of the window object to avoid garbage collection let mainWindow: BrowserWindow | null = null; +let authorizedRootPath: string | null = null; const APP_ROOT = path.resolve(__dirname, '../../..'); const RENDERER_INDEX_PATH = path.join(APP_ROOT, 'src', 'renderer', 'index.html'); @@ -73,6 +74,7 @@ async function createWindow() { // Window closed event mainWindow.on('closed', () => { mainWindow = null; + authorizedRootPath = null; }); } @@ -99,6 +101,7 @@ app.whenReady().then(() => { // Quit when all windows are closed app.on('window-all-closed', () => { + authorizedRootPath = null; if (process.platform !== 'darwin') { app.quit(); } @@ -106,6 +109,7 @@ app.on('window-all-closed', () => { app.on('activate', () => { if (mainWindow === null) { + authorizedRootPath = null; createWindow(); } }); @@ -114,6 +118,19 @@ app.on('activate', () => { type FilterPatternBundle = string[] & { includePatterns?: string[]; includeExtensions?: string[] }; +const resolveAuthorizedPath = (candidatePath: string): string | null => { + if (!authorizedRootPath || !candidatePath) { + return null; + } + + const resolvedCandidatePath = path.resolve(candidatePath); + if (!isPathWithinRoot(authorizedRootPath, resolvedCandidatePath)) { + return null; + } + + return resolvedCandidatePath; +}; + // Select directory dialog ipcMain.handle('dialog:selectDirectory', async () => { const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow ?? undefined, { @@ -124,13 +141,21 @@ ipcMain.handle('dialog:selectDirectory', async () => { return null; } - return filePaths[0]; + const selectedPath = filePaths[0]; + authorizedRootPath = path.resolve(selectedPath); + return selectedPath; }); // Get directory tree ipcMain.handle( 'fs:getDirectoryTree', async (_event, dirPath: string, configContent?: string | null) => { + const authorizedDirPath = resolveAuthorizedPath(dirPath); + if (!authorizedDirPath) { + console.warn(`Rejected unauthorized directory tree request: ${dirPath}`); + return []; + } + // IMPORTANT: This function applies exclude patterns to the directory tree, // preventing node_modules, .git, and other large directories from being included // in the UI tree view. This is critical for performance with large repositories. @@ -169,7 +194,7 @@ ipcMain.handle( // Add gitignore patterns if enabled if (useGitignore) { - const gitignoreResult = gitignoreParser.parseGitignore(dirPath); + const gitignoreResult = gitignoreParser.parseGitignore(authorizedDirPath); if (gitignoreResult.excludePatterns && gitignoreResult.excludePatterns.length > 0) { excludePatterns = [...excludePatterns, ...gitignoreResult.excludePatterns]; } @@ -189,7 +214,7 @@ ipcMain.handle( // Use the shared shouldExclude function from filter-utils const localShouldExclude = (itemPath: string) => { - return shouldExclude(itemPath, dirPath, excludePatterns, config); + return shouldExclude(itemPath, authorizedDirPath, excludePatterns, config); }; const walkDirectory = (dir: string): DirectoryTreeItem[] => { @@ -247,7 +272,7 @@ ipcMain.handle( }; try { - return walkDirectory(dirPath); + return walkDirectory(authorizedDirPath); } catch (error) { console.error('Error getting directory tree:', error); return []; @@ -275,13 +300,18 @@ ipcMain.handle( { rootPath, configContent, selectedFiles }: AnalyzeRepositoryOptions ): Promise => { try { + const authorizedAnalyzeRoot = resolveAuthorizedPath(rootPath); + if (!authorizedAnalyzeRoot) { + 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(rootPath); + gitignorePatterns = gitignoreParser.parseGitignore(authorizedAnalyzeRoot); } // Create a file analyzer instance with the appropriate settings @@ -296,16 +326,16 @@ ipcMain.handle( let skippedBinaryFiles = 0; for (const filePath of selectedFiles) { - const resolvedFilePath = path.resolve(rootPath, filePath); + const resolvedFilePath = path.resolve(authorizedAnalyzeRoot, filePath); // Verify the file is within the current root path - if (!isPathWithinRoot(rootPath, resolvedFilePath)) { + if (!isPathWithinRoot(authorizedAnalyzeRoot, resolvedFilePath)) { console.warn(`Skipping file outside current root directory: ${filePath}`); continue; } // Use consistent path normalization - const relativePath = getRelativePath(resolvedFilePath, rootPath); + const relativePath = getRelativePath(resolvedFilePath, authorizedAnalyzeRoot); // For binary files, record them as skipped but don't prevent selection const binaryFile = isBinaryFile(resolvedFilePath); @@ -387,7 +417,7 @@ function generateTreeView(filesInfo: FileInfo[]): string { }); // Recursive function to print the tree - const printTree = (tree: PathTree, prefix = '', isLast = true): string => { + const printTree = (tree: PathTree, prefix = '', _isLast = true): string => { const entries = Object.entries(tree); let result = ''; @@ -395,11 +425,11 @@ function generateTreeView(filesInfo: FileInfo[]): string { const isLastItem = index === entries.length - 1; // Print current level - result += `${prefix}${isLast ? '└── ' : '├── '}${key}\n`; + result += `${prefix}${isLastItem ? '└── ' : '├── '}${key}\n`; // Print children if (value !== null) { - const newPrefix = `${prefix}${isLast ? ' ' : '│ '}`; + const newPrefix = `${prefix}${isLastItem ? ' ' : '│ '}`; result += printTree(value, newPrefix, isLastItem); } }); @@ -421,6 +451,11 @@ ipcMain.handle( { rootPath, filesInfo, treeView, options = {} }: ProcessRepositoryOptions ): Promise => { try { + const authorizedProcessRoot = resolveAuthorizedPath(rootPath); + if (!authorizedProcessRoot) { + throw new Error('Unauthorized root path. Please select the directory again.'); + } + const tokenCounter = new TokenCounter(); const contentProcessor = new ContentProcessor(tokenCounter); @@ -479,9 +514,9 @@ ipcMain.handle( const tokenCount = normalizeTokenCount(fileInfo.tokens); // Resolve and validate against root path to prevent traversal and prefix bypasses. - const fullPath = path.resolve(rootPath, filePath); + const fullPath = path.resolve(authorizedProcessRoot, filePath); - if (!isPathWithinRoot(rootPath, fullPath)) { + if (!isPathWithinRoot(authorizedProcessRoot, fullPath)) { console.warn(`Skipping file outside root directory: ${filePath}`); skippedFiles++; continue; @@ -618,15 +653,21 @@ ipcMain.handle( return { results: {}, stats: {} }; } + const authorizedTokensRoot = resolveAuthorizedPath(rootPath); + if (!authorizedTokensRoot) { + console.warn(`Rejected unauthorized token count request for root: ${rootPath}`); + return { results: {}, stats: {} }; + } + const results: Record = {}; const stats: Record = {}; // Process each file for (const filePath of filePaths) { try { - const resolvedFilePath = path.resolve(rootPath, filePath); + const resolvedFilePath = path.resolve(authorizedTokensRoot, filePath); - if (!isPathWithinRoot(rootPath, resolvedFilePath)) { + if (!isPathWithinRoot(authorizedTokensRoot, resolvedFilePath)) { console.warn(`Skipping file outside current root directory: ${filePath}`); results[filePath] = 0; continue; diff --git a/tests/integration/main-process/handlers.test.ts b/tests/integration/main-process/handlers.test.ts index c8e0659..28df602 100644 --- a/tests/integration/main-process/handlers.test.ts +++ b/tests/integration/main-process/handlers.test.ts @@ -116,9 +116,13 @@ jest.mock('../../../src/utils/content-processor', () => ({ require('../../../src/main/index'); describe('Main Process IPC Handlers', () => { - beforeEach(() => { + beforeEach(async () => { jest.clearAllMocks(); const { dialog } = require('electron'); + dialog.showOpenDialog.mockResolvedValue({ + canceled: false, + filePaths: ['/mock/repo'], + }); dialog.showSaveDialog.mockResolvedValue({ canceled: false, filePath: '/mock/repo/output.md', @@ -178,6 +182,11 @@ describe('Main Process IPC Handlers', () => { include_extensions: ['.js', '.jsx', '.json', '.log'], // Include .log extension exclude_patterns: ['**/node_modules/**'], })); + + const selectDirectoryHandler = mockIpcHandlers['dialog:selectDirectory']; + if (selectDirectoryHandler) { + await selectDirectoryHandler(null); + } }); describe('assets protocol', () => { @@ -271,6 +280,12 @@ describe('Main Process IPC Handlers', () => { // Verify expect(result).toEqual([]); }); + + test('should reject unauthorized directory tree requests', async () => { + const handler = mockIpcHandlers['fs:getDirectoryTree']; + const result = await handler(null, '/unauthorized/path', ''); + expect(result).toEqual([]); + }); }); describe('repo:analyze', () => { @@ -410,6 +425,17 @@ describe('Main Process IPC Handlers', () => { expect(result.filesInfo.find((file) => file.path === 'src/index.js')).toBeDefined(); expect(result.filesInfo.find((file) => file.path === 'src/secrets.js')).toBeUndefined(); }); + + test('should reject analysis for unauthorized root path', async () => { + const handler = mockIpcHandlers['repo:analyze']; + await expect( + handler(null, { + rootPath: '/etc', + configContent: '', + selectedFiles: ['/etc/passwd'], + }) + ).rejects.toThrow('Unauthorized root path'); + }); }); describe('repo:process', () => { @@ -451,6 +477,31 @@ describe('Main Process IPC Handlers', () => { expect(result.content).toContain('package.json'); }); + test('should generate tree connectors correctly when treeView is omitted', async () => { + const rootPath = '/mock/repo'; + const filesInfo = [ + { path: 'A/C.js', tokens: 10 }, + { path: 'B.js', tokens: 5 }, + ]; + const options = { + includeTreeView: true, + exportFormat: 'markdown', + }; + + const handler = mockIpcHandlers['repo:process']; + const result = await handler(null, { + rootPath, + filesInfo, + treeView: '', + options, + }); + + expect(result.content).toContain('## File Structure'); + expect(result.content).toContain('├── A'); + expect(result.content).toContain('│ └── C.js'); + expect(result.content).toContain('└── B.js'); + }); + test('should not include markdown file-contents heading when tree view is disabled', async () => { const rootPath = '/mock/repo'; const filesInfo = [{ path: 'src/index.js', tokens: 100 }]; @@ -624,6 +675,53 @@ describe('Main Process IPC Handlers', () => { expect(result.content).toContain('src/index.js'); expect(result.content).not.toContain('../repo-secrets/config.js'); }); + + test('should reject processing for unauthorized root path', async () => { + const handler = mockIpcHandlers['repo:process']; + await expect( + handler(null, { + rootPath: '/etc', + filesInfo: [{ path: 'passwd', tokens: 1 }], + treeView: '', + options: {}, + }) + ).rejects.toThrow('Unauthorized root path'); + }); + + test('should only allow the currently authorized root after re-selection', async () => { + const { dialog } = require('electron'); + const selectDirectoryHandler = mockIpcHandlers['dialog:selectDirectory']; + const processHandler = mockIpcHandlers['repo:process']; + + dialog.showOpenDialog.mockResolvedValue({ + canceled: false, + filePaths: ['/mock/repo-next'], + }); + await selectDirectoryHandler(null); + + await expect( + processHandler(null, { + rootPath: '/mock/repo-next', + filesInfo: [], + treeView: '', + options: {}, + }) + ).resolves.toEqual( + expect.objectContaining({ + processedFiles: 0, + skippedFiles: 0, + }) + ); + + await expect( + processHandler(null, { + rootPath: '/mock/repo', + filesInfo: [], + treeView: '', + options: {}, + }) + ).rejects.toThrow('Unauthorized root path'); + }); }); describe('fs:saveFile', () => { @@ -822,5 +920,11 @@ describe('Main Process IPC Handlers', () => { expect(result.results['../repo-secrets/secret.js']).toBe(0); expect(result.stats['../repo-secrets/secret.js']).toBeUndefined(); }); + + test('should reject token counting for unauthorized root path', async () => { + const handler = mockIpcHandlers['tokens:countFiles']; + const result = await handler(null, { rootPath: '/etc', filePaths: ['/etc/passwd'] }); + expect(result).toEqual({ results: {}, stats: {} }); + }); }); }); diff --git a/tests/integration/main-process/xml-export-e2e.test.ts b/tests/integration/main-process/xml-export-e2e.test.ts index a75a19c..e46aa41 100644 --- a/tests/integration/main-process/xml-export-e2e.test.ts +++ b/tests/integration/main-process/xml-export-e2e.test.ts @@ -9,10 +9,17 @@ const fs = require('fs'); describe('XML export end-to-end', () => { const mockIpcHandlers = {}; let tempRoot = ''; + let mockShowOpenDialog; + let mockShowSaveDialog; beforeEach(() => { jest.resetModules(); tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-code-fusion-xml-')); + Object.keys(mockIpcHandlers).forEach((key) => { + delete mockIpcHandlers[key]; + }); + mockShowOpenDialog = jest.fn(); + mockShowSaveDialog = jest.fn(); jest.doMock('electron', () => ({ app: { @@ -35,8 +42,8 @@ describe('XML export end-to-end', () => { }), }, dialog: { - showOpenDialog: jest.fn(), - showSaveDialog: jest.fn(), + showOpenDialog: mockShowOpenDialog, + showSaveDialog: mockShowSaveDialog, }, protocol: { registerFileProtocol: jest.fn(), @@ -63,7 +70,15 @@ describe('XML export end-to-end', () => { fs.writeFileSync(filePath, fileContent, 'utf-8'); const repoProcessHandler = mockIpcHandlers['repo:process']; + const selectDirectoryHandler = mockIpcHandlers['dialog:selectDirectory']; expect(repoProcessHandler).toBeDefined(); + expect(selectDirectoryHandler).toBeDefined(); + + mockShowOpenDialog.mockResolvedValue({ + canceled: false, + filePaths: [tempRoot], + }); + await selectDirectoryHandler(null); const result = await repoProcessHandler(null, { rootPath: tempRoot, From 82807f690a6edeb11a39b5b45ce9758c29d9086e Mon Sep 17 00:00:00 2001 From: Mehdi Date: Mon, 9 Feb 2026 15:35:21 +0000 Subject: [PATCH 11/16] fix: preserve authorized root across window lifecycle --- src/main/index.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 91b8c2a..cc5e49f 100755 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -74,7 +74,6 @@ async function createWindow() { // Window closed event mainWindow.on('closed', () => { mainWindow = null; - authorizedRootPath = null; }); } @@ -101,7 +100,6 @@ app.whenReady().then(() => { // Quit when all windows are closed app.on('window-all-closed', () => { - authorizedRootPath = null; if (process.platform !== 'darwin') { app.quit(); } @@ -109,7 +107,6 @@ app.on('window-all-closed', () => { app.on('activate', () => { if (mainWindow === null) { - authorizedRootPath = null; createWindow(); } }); From 075f66e6738ceef224757b9bf5274fb233f169e2 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Mon, 9 Feb 2026 15:39:39 +0000 Subject: [PATCH 12/16] test: make path mocks platform-stable for windows qa --- tests/integration/main-process/handlers.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/integration/main-process/handlers.test.ts b/tests/integration/main-process/handlers.test.ts index 28df602..cddfa69 100644 --- a/tests/integration/main-process/handlers.test.ts +++ b/tests/integration/main-process/handlers.test.ts @@ -45,8 +45,12 @@ jest.mock('path', () => { return { ...realPath, join: jest.fn().mockImplementation((...args) => args.join('/')), - normalize: jest.fn().mockImplementation((p) => p), - relative: jest.fn().mockImplementation((from, to) => realPath.relative(from, to)), + normalize: jest.fn().mockImplementation((p) => realPath.posix.normalize(p)), + resolve: jest.fn().mockImplementation((...args) => realPath.posix.resolve(...args)), + relative: jest.fn().mockImplementation((from, to) => realPath.posix.relative(from, to)), + isAbsolute: jest + .fn() + .mockImplementation((candidatePath) => realPath.posix.isAbsolute(candidatePath)), extname: jest.fn().mockImplementation((filePath) => { const parts = filePath.split('.'); return parts.length > 1 ? `.${parts[parts.length - 1]}` : ''; From bd48062a7fbeb533478325deca35118eb8d05639 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Mon, 9 Feb 2026 15:45:31 +0000 Subject: [PATCH 13/16] security: harden root boundary checks with realpath fallback --- src/main/index.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index cc5e49f..c62fb15 100755 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -282,8 +282,23 @@ const isPathWithinRoot = (rootPath: string, candidatePath: string): boolean => { return false; } - const resolvedRootPath = path.resolve(rootPath); - const resolvedCandidatePath = path.resolve(candidatePath); + const resolveForBoundaryCheck = (inputPath: string): string => { + const resolvedPath = path.resolve(inputPath); + const realpathFn = fs.realpathSync?.native ?? fs.realpathSync; + + if (typeof realpathFn === 'function') { + try { + return realpathFn(resolvedPath); + } catch { + return resolvedPath; + } + } + + return resolvedPath; + }; + + const resolvedRootPath = resolveForBoundaryCheck(rootPath); + const resolvedCandidatePath = resolveForBoundaryCheck(candidatePath); const relativePath = path.relative(resolvedRootPath, resolvedCandidatePath); return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); From bbabb4a6d3df55f902725bc0c4a6e3af759a061c Mon Sep 17 00:00:00 2001 From: Mehdi Date: Mon, 9 Feb 2026 16:32:36 +0000 Subject: [PATCH 14/16] update dev cleanup --- dev-setup.bat | 25 +++++++++++++++++++++++++ dev-setup.sh | 31 +++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 dev-setup.bat create mode 100755 dev-setup.sh diff --git a/dev-setup.bat b/dev-setup.bat new file mode 100644 index 0000000..5189f30 --- /dev/null +++ b/dev-setup.bat @@ -0,0 +1,25 @@ +@echo off + +if not "%OS%"=="Windows_NT" ( + echo This script must be run on Windows only. + exit /b 1 +) + +set APP_NAME=ai-code-fusion +set APP_DATA=%APPDATA%\%APP_NAME% + +echo === Cleaning app settings === +if exist "%APP_DATA%" ( + echo Removing %APP_DATA%... + rmdir /s /q "%APP_DATA%" +) else ( + echo No settings found at %APP_DATA%, skipping. +) + +echo. +echo === Installing dependencies === +call npm install + +echo. +echo === Starting in dev mode === +call npm run dev diff --git a/dev-setup.sh b/dev-setup.sh new file mode 100755 index 0000000..03a3bc1 --- /dev/null +++ b/dev-setup.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +# Detect OS and set app data path +case "$(uname -s)" in + Linux*) + APP_DATA="$HOME/.config/ai-code-fusion" + ;; + Darwin*) + APP_DATA="$HOME/Library/Application Support/ai-code-fusion" + ;; + *) + echo "Unsupported OS. Use dev-setup.bat on Windows." + exit 1 + ;; +esac + +echo "=== Cleaning app settings ===" +if [ -d "$APP_DATA" ]; then + echo "Removing $APP_DATA..." + rm -rf "$APP_DATA" +else + echo "No settings found at $APP_DATA, skipping." +fi + +echo "" +echo "=== Installing dependencies ===" +npm install + +echo "" +echo "=== Starting in dev mode ===" +npm run dev From 7751aab729a7ee1ff4c71105132b46e22ecc37be Mon Sep 17 00:00:00 2001 From: Mehdi Date: Mon, 9 Feb 2026 16:40:04 +0000 Subject: [PATCH 15/16] fix: disable process action during token calculation --- scripts/capture-ui-screenshot.js | 37 +++++++- src/renderer/components/SourceTab.tsx | 78 +++++++++------- tests/unit/components/source-tab.test.tsx | 108 ++++++++++++++++++++++ 3 files changed, 187 insertions(+), 36 deletions(-) create mode 100644 tests/unit/components/source-tab.test.tsx diff --git a/scripts/capture-ui-screenshot.js b/scripts/capture-ui-screenshot.js index 82595c8..831febc 100644 --- a/scripts/capture-ui-screenshot.js +++ b/scripts/capture-ui-screenshot.js @@ -330,6 +330,7 @@ const UI_SELECTORS = { secretFileEntry: `[title="${MOCK_SECRET_FILE_PATH}"]`, refreshFileListButton: 'button[title="Refresh the file list"]', fileTreeScrollContainer: '.file-tree .overflow-auto', + processSelectedFilesButton: '[data-testid="process-selected-files-button"]', }; async function setupMockElectronApi(page) { @@ -339,6 +340,7 @@ async function setupMockElectronApi(page) { localStorage.setItem('configContent', mockConfig); const cloneTree = (treeItems) => JSON.parse(JSON.stringify(treeItems)); + const delay = (durationMs) => new Promise((resolve) => window.setTimeout(resolve, durationMs)); window.electronAPI = { getDefaultConfig: async () => mockConfig, @@ -368,11 +370,14 @@ async function setupMockElectronApi(page) { processedFiles: 0, }, }), - countFilesTokens: async (files) => { + countFilesTokens: async (options) => { + const filePaths = Array.isArray(options?.filePaths) ? options.filePaths : []; const results = {}; const stats = {}; - for (const filePath of files) { + await delay(450); + + for (const filePath of filePaths) { results[filePath] = 120; stats[filePath] = { mtime: fixedMtime, size: 1024 }; } @@ -495,6 +500,34 @@ async function captureAppStateScreenshots(page) { }); }); + await runStep('Verify process button shows selecting state during token counting', async () => { + await page.waitForFunction((selector) => { + const button = document.querySelector(selector); + if (!(button instanceof HTMLButtonElement)) { + return false; + } + + return ( + button.disabled && + /selecting files\.\.\./i.test(button.textContent || '') && + Boolean(button.querySelector('svg.animate-spin')) + ); + }, UI_SELECTORS.processSelectedFilesButton); + }); + + await runStep('Verify process button re-enables after token counting completes', async () => { + await page.waitForFunction((selector) => { + const button = document.querySelector(selector); + if (!(button instanceof HTMLButtonElement)) { + return false; + } + + const buttonLabel = button.textContent || ''; + const hasSpinner = Boolean(button.querySelector('svg.animate-spin')); + return !button.disabled && /process selected files/i.test(buttonLabel) && !hasSpinner; + }, UI_SELECTORS.processSelectedFilesButton); + }); + await runStep('Capture selected file screenshot', async () => { await page.screenshot({ path: SCREENSHOTS.sourceSelected, fullPage: true }); }); diff --git a/src/renderer/components/SourceTab.tsx b/src/renderer/components/SourceTab.tsx index b7a93bb..58ee907 100755 --- a/src/renderer/components/SourceTab.tsx +++ b/src/renderer/components/SourceTab.tsx @@ -42,8 +42,8 @@ const updateTokenCache = ( }); }; -const getProcessButtonClass = (rootPath: string, hasSelection: boolean, isAnalyzing: boolean) => { - const isDisabled = !rootPath || !hasSelection || isAnalyzing; +const getProcessButtonClass = (rootPath: string, hasSelection: boolean, isBusy: boolean) => { + const isDisabled = !rootPath || !hasSelection || isBusy; const baseClass = 'inline-flex items-center border border-transparent px-5 py-2 text-sm font-medium text-white shadow-sm'; @@ -135,10 +135,17 @@ const SourceTab = ({ filePaths: fileBatch, }); - updateTokenCache(results, stats, setTokenCache); + const normalizedResults: CountFilesTokensResult['results'] = { ...results }; + for (const filePath of fileBatch) { + if (!Object.prototype.hasOwnProperty.call(normalizedResults, filePath)) { + normalizedResults[filePath] = 0; + } + } + + updateTokenCache(normalizedResults, stats, setTokenCache); const newTotal = selectedFiles.reduce((sum, filePath) => { - return sum + (results[filePath] || tokenCache[filePath]?.tokenCount || 0); + return sum + (normalizedResults[filePath] || tokenCache[filePath]?.tokenCount || 0); }, 0); setTotalTokens(newTotal); @@ -176,6 +183,10 @@ const SourceTab = ({ }; }, []); + const hasSelection = selectedFiles.length > 0 || selectedFolders.length > 0; + const isProcessBusy = isAnalyzing || isCalculating; + const isProcessDisabled = !rootPath || !hasSelection || isProcessBusy; + return (
@@ -295,28 +306,6 @@ const SourceTab = ({ Tokens {totalTokens.toLocaleString()} - {isCalculating && ( - - - - - )}
@@ -324,6 +313,7 @@ const SourceTab = ({