/ src / agents / utils / file-operations.js
file-operations.js
  1  /**
  2   * File Operations Module
  3   *
  4   * Provides safe file reading/writing with backup/restore capabilities and context awareness.
  5   * All operations include safety features:
  6   * - Automatic backups before editing
  7   * - Atomic writes (temp file → move)
  8   * - Path traversal prevention (whitelist: src/, tests/, docs/)
  9   * - Syntax validation before writing
 10   * - Diff generation for all edits
 11   *
 12   * @module agents/utils/file-operations
 13   */
 14  
 15  import fs from 'fs/promises';
 16  import fsSync from 'fs';
 17  import path from 'path';
 18  import { fileURLToPath } from 'url';
 19  import { ESLint } from 'eslint';
 20  import Logger from '../../utils/logger.js';
 21  
 22  const __filename = fileURLToPath(import.meta.url);
 23  const __dirname = path.dirname(__filename);
 24  const PROJECT_ROOT = path.resolve(__dirname, '../../..');
 25  const BACKUP_DIR = path.join(PROJECT_ROOT, '.backups');
 26  
 27  const logger = new Logger('FileOperations');
 28  
 29  // Whitelist of allowed directories for file operations
 30  const ALLOWED_DIRS = [
 31    path.join(PROJECT_ROOT, 'src'),
 32    path.join(PROJECT_ROOT, 'tests'),
 33    path.join(PROJECT_ROOT, 'docs'),
 34    path.join(PROJECT_ROOT, 'scripts'),
 35    path.join(PROJECT_ROOT, 'prompts'),
 36  ];
 37  
 38  // Blacklist of files that should never be modified
 39  const BLACKLISTED_FILES = ['.env', '.env.local', '.env.production', 'package-lock.json'];
 40  
 41  /**
 42   * Validate that a path is safe to operate on
 43   *
 44   * @param {string} filePath - Path to validate
 45   * @throws {Error} If path is outside allowed directories or blacklisted
 46   */
 47  function validatePath(filePath) {
 48    const absPath = path.resolve(PROJECT_ROOT, filePath);
 49    const relativePath = path.relative(PROJECT_ROOT, absPath);
 50  
 51    // Check for path traversal
 52    if (relativePath.startsWith('..')) {
 53      throw new Error(`Path traversal detected: ${filePath}`);
 54    }
 55  
 56    // Check if path is in an allowed directory
 57    const isAllowed = ALLOWED_DIRS.some(dir => absPath.startsWith(dir));
 58    if (!isAllowed) {
 59      throw new Error(
 60        `Path not in allowed directories: ${filePath}. Allowed: ${ALLOWED_DIRS.map(d => path.relative(PROJECT_ROOT, d)).join(', ')}`
 61      );
 62    }
 63  
 64    // Check if file is blacklisted
 65    const basename = path.basename(absPath);
 66    if (BLACKLISTED_FILES.includes(basename)) {
 67      throw new Error(`File is blacklisted: ${basename}`);
 68    }
 69  
 70    return absPath;
 71  }
 72  
 73  /**
 74   * Read a file and return its content with metadata
 75   *
 76   * @param {string} filePath - Path to file (relative to project root or absolute)
 77   * @returns {Promise<{content: string, path: string, size: number, lastModified: Date}>}
 78   * @throws {Error} If path is invalid or file cannot be read
 79   *
 80   * @example
 81   * const file = await readFile('src/utils/logger.js');
 82   * console.log(file.content, file.size, file.lastModified);
 83   */
 84  export async function readFile(filePath) {
 85    const absPath = validatePath(filePath);
 86  
 87    try {
 88      const content = await fs.readFile(absPath, 'utf8');
 89      const stats = await fs.stat(absPath);
 90  
 91      logger.info(`Read file: ${path.relative(PROJECT_ROOT, absPath)}`);
 92  
 93      return {
 94        content,
 95        path: absPath,
 96        size: stats.size,
 97        lastModified: stats.mtime,
 98      };
 99    } catch (error) {
100      logger.error(`Failed to read file ${absPath}: ${error.message}`);
101      throw new Error(`Failed to read file: ${error.message}`);
102    }
103  }
104  
105  /**
106   * Create a backup of a file
107   *
108   * @param {string} filePath - Path to file to backup
109   * @returns {Promise<string>} Path to backup file
110   * @throws {Error} If backup fails
111   *
112   * @example
113   * const backupPath = await backupFile('src/utils/logger.js');
114   * console.log(`Backup created: ${backupPath}`);
115   */
116  export async function backupFile(filePath) {
117    const absPath = validatePath(filePath);
118  
119    // Create backup directory if it doesn't exist
120    await fs.mkdir(BACKUP_DIR, { recursive: true });
121  
122    // Generate unique backup filename with timestamp
123    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
124    const relativePath = path.relative(PROJECT_ROOT, absPath);
125    const backupName = `${relativePath.replace(/\//g, '_')}.${timestamp}.backup`;
126    const backupPath = path.join(BACKUP_DIR, backupName);
127  
128    try {
129      // Ensure backup subdirectories exist
130      await fs.mkdir(path.dirname(backupPath), { recursive: true });
131  
132      // Copy file to backup location
133      await fs.copyFile(absPath, backupPath);
134  
135      logger.info(`Created backup: ${path.relative(PROJECT_ROOT, backupPath)}`);
136  
137      return backupPath;
138    } catch (error) {
139      logger.error(`Failed to create backup ${backupPath}: ${error.message}`);
140      throw new Error(`Failed to create backup: ${error.message}`);
141    }
142  }
143  
144  /**
145   * Restore a file from backup
146   *
147   * @param {string} backupPath - Path to backup file
148   * @returns {Promise<boolean>} True if restore succeeded
149   * @throws {Error} If restore fails
150   *
151   * @example
152   * await restoreBackup('/path/to/backup/file.js.2026-02-15.backup');
153   */
154  export async function restoreBackup(backupPath) {
155    const absBackupPath = path.resolve(backupPath);
156  
157    // Validate backup is in BACKUP_DIR
158    if (!absBackupPath.startsWith(BACKUP_DIR)) {
159      throw new Error('Backup path must be in .backups directory');
160    }
161  
162    try {
163      // Parse original path from backup filename
164      const backupFilename = path.basename(absBackupPath);
165      const originalPathPart = backupFilename.split('.').slice(0, -2).join('.');
166      const originalPath = path.join(PROJECT_ROOT, originalPathPart.replace(/_/g, '/'));
167  
168      // Validate original path
169      validatePath(originalPath);
170  
171      // Restore file
172      await fs.copyFile(absBackupPath, originalPath);
173  
174      logger.info(`Restored from backup: ${path.relative(PROJECT_ROOT, originalPath)}`);
175  
176      return true;
177    } catch (error) {
178      logger.error(`Failed to restore backup ${backupPath}: ${error.message}`);
179      throw new Error(`Failed to restore backup: ${error.message}`);
180    }
181  }
182  
183  /**
184   * Validate JavaScript syntax using ESLint
185   *
186   * @param {string} code - JavaScript code to validate
187   * @returns {Promise<{valid: boolean, errors: Array}>} Validation result
188   *
189   * @example
190   * const result = await validateJavaScript('const x = 1');
191   * if (!result.valid) {
192   *   console.error('Syntax errors:', result.errors);
193   * }
194   */
195  export async function validateJavaScript(code) {
196    try {
197      const eslint = new ESLint({
198        overrideConfigFile: true, // Don't use any config file
199        overrideConfig: [
200          {
201            languageOptions: {
202              ecmaVersion: 2024,
203              sourceType: 'module',
204              parserOptions: {
205                ecmaVersion: 2024,
206                sourceType: 'module',
207              },
208            },
209            rules: {},
210          },
211        ],
212      });
213  
214      const results = await eslint.lintText(code);
215      const errors = results[0].messages.filter(msg => msg.severity === 2); // Only fatal errors
216  
217      return {
218        valid: errors.length === 0,
219        errors: errors.map(e => ({
220          line: e.line,
221          column: e.column,
222          message: e.message,
223        })),
224      };
225    } catch (error) {
226      logger.error(`ESLint validation failed: ${error.message}`);
227      return {
228        valid: false,
229        errors: [{ message: error.message }],
230      };
231    }
232  }
233  
234  /**
235   * Generate a diff between two strings
236   *
237   * @param {string} oldContent - Original content
238   * @param {string} newContent - New content
239   * @returns {string} Unified diff format
240   */
241  function generateDiff(oldContent, newContent) {
242    const oldLines = oldContent.split('\n');
243    const newLines = newContent.split('\n');
244  
245    const diff = [];
246    diff.push('--- original');
247    diff.push('+++ modified');
248  
249    let i = 0;
250    let j = 0;
251  
252    while (i < oldLines.length || j < newLines.length) {
253      if (i >= oldLines.length) {
254        // Only new lines remain
255        diff.push(`+${newLines[j]}`);
256        j++;
257      } else if (j >= newLines.length) {
258        // Only old lines remain
259        diff.push(`-${oldLines[i]}`);
260        i++;
261      } else if (oldLines[i] === newLines[j]) {
262        // Lines match
263        diff.push(` ${oldLines[i]}`);
264        i++;
265        j++;
266      } else {
267        // Lines differ
268        diff.push(`-${oldLines[i]}`);
269        diff.push(`+${newLines[j]}`);
270        i++;
271        j++;
272      }
273    }
274  
275    return diff.join('\n');
276  }
277  
278  /**
279   * Write content to a file with automatic backup and atomic write
280   *
281   * @param {string} filePath - Path to file
282   * @param {string} content - Content to write
283   * @param {Object} options - Write options
284   * @param {boolean} options.backup - Create backup before writing (default: true)
285   * @param {boolean} options.validate - Validate JS syntax before writing (default: true for .js files)
286   * @returns {Promise<{success: boolean, backupPath?: string, diff?: string}>}
287   * @throws {Error} If write fails or validation fails
288   *
289   * @example
290   * const result = await writeFile('src/test.js', 'const x = 1;');
291   * console.log('Backup:', result.backupPath);
292   * console.log('Diff:', result.diff);
293   */
294  export async function writeFile(filePath, content, options = {}) {
295    const absPath = validatePath(filePath);
296    const { backup = true, validate = absPath.endsWith('.js') } = options;
297  
298    let backupPath;
299    let oldContent = '';
300  
301    try {
302      // Read existing content if file exists
303      try {
304        oldContent = await fs.readFile(absPath, 'utf8');
305      } catch (error) {
306        // File doesn't exist, that's okay
307        if (error.code !== 'ENOENT') {
308          throw error;
309        }
310      }
311  
312      // Create backup if requested and file exists
313      if (backup && oldContent) {
314        backupPath = await backupFile(absPath);
315      }
316  
317      // Validate JavaScript syntax
318      if (validate) {
319        const validation = await validateJavaScript(content);
320        if (!validation.valid) {
321          throw new Error(
322            `JavaScript syntax errors:\n${validation.errors.map(e => `  Line ${e.line}: ${e.message}`).join('\n')}`
323          );
324        }
325      }
326  
327      // Atomic write: write to temp file then move
328      const tempPath = `${absPath}.tmp`;
329      await fs.writeFile(tempPath, content, 'utf8');
330  
331      // Ensure parent directory exists
332      await fs.mkdir(path.dirname(absPath), { recursive: true });
333  
334      // Move temp file to final location (atomic operation)
335      await fs.rename(tempPath, absPath);
336  
337      // Generate diff
338      const diff = oldContent ? generateDiff(oldContent, content) : null;
339  
340      logger.info(`Wrote file: ${path.relative(PROJECT_ROOT, absPath)}`);
341  
342      return {
343        success: true,
344        backupPath,
345        diff,
346      };
347    } catch (error) {
348      logger.error(`Failed to write file ${absPath}: ${error.message}`);
349  
350      // Restore from backup if write failed
351      if (backupPath) {
352        try {
353          await restoreBackup(backupPath);
354          logger.info('Restored from backup after failed write');
355        } catch (restoreError) {
356          logger.error(`Failed to restore backup: ${restoreError.message}`);
357        }
358      }
359  
360      throw new Error(`Failed to write file: ${error.message}`);
361    }
362  }
363  
364  /**
365   * Edit a file with automatic backup and diff generation
366   *
367   * @param {string} filePath - Path to file
368   * @param {Object} changes - Changes to apply
369   * @param {string} changes.oldContent - Content to replace
370   * @param {string} changes.newContent - Replacement content
371   * @returns {Promise<{success: boolean, backupPath: string, diff: string}>}
372   * @throws {Error} If edit fails
373   *
374   * @example
375   * const result = await editFile('src/test.js', {
376   *   oldContent: 'const x = 1;',
377   *   newContent: 'const x = 2;'
378   * });
379   */
380  export async function editFile(filePath, changes) {
381    const absPath = validatePath(filePath);
382    const { oldContent, newContent } = changes;
383  
384    if (!oldContent || !newContent) {
385      throw new Error('Both oldContent and newContent are required');
386    }
387  
388    try {
389      // Read current content
390      const { content } = await readFile(absPath);
391  
392      // Find and replace oldContent
393      if (!content.includes(oldContent)) {
394        throw new Error('oldContent not found in file');
395      }
396  
397      const updatedContent = content.replace(oldContent, newContent);
398  
399      // Write updated content
400      const result = await writeFile(absPath, updatedContent);
401  
402      logger.info(`Edited file: ${path.relative(PROJECT_ROOT, absPath)}`);
403  
404      return result;
405    } catch (error) {
406      logger.error(`Failed to edit file ${absPath}: ${error.message}`);
407      throw new Error(`Failed to edit file: ${error.message}`);
408    }
409  }
410  
411  /**
412   * Get file context: imports, dependencies, related test files
413   *
414   * @param {string} filePath - Path to file
415   * @returns {Promise<{imports: string[], dependencies: string[], testFiles: string[]}>}
416   *
417   * @example
418   * const context = await getFileContext('src/utils/logger.js');
419   * console.log('Imports:', context.imports);
420   * console.log('Test files:', context.testFiles);
421   */
422  export async function getFileContext(filePath) {
423    const absPath = validatePath(filePath);
424  
425    try {
426      const { content } = await readFile(absPath);
427  
428      // Extract imports
429      const importRegex = /import\s+.*\s+from\s+['"](.+?)['"]/g;
430      const imports = [];
431      let match;
432      while ((match = importRegex.exec(content)) !== null) {
433        imports.push(match[1]);
434      }
435  
436      // Extract require statements
437      const requireRegex = /require\(['"](.+?)['"]\)/g;
438      while ((match = requireRegex.exec(content)) !== null) {
439        imports.push(match[1]);
440      }
441  
442      // Find dependencies (npm packages)
443      const dependencies = imports.filter(imp => !imp.startsWith('.') && !imp.startsWith('/'));
444  
445      // Find related test files
446      const testFiles = [];
447      const basename = path.basename(absPath, path.extname(absPath));
448      const testsDir = path.join(PROJECT_ROOT, 'tests');
449  
450      try {
451        const testDirFiles = await fs.readdir(testsDir);
452        const relatedTests = testDirFiles.filter(
453          file =>
454            file.includes(basename) &&
455            (file.endsWith('.test.js') || file.endsWith('.integration.test.js'))
456        );
457        testFiles.push(...relatedTests.map(f => path.join(testsDir, f)));
458      } catch (error) {
459        // Tests directory might not exist
460      }
461  
462      // Check for agent-specific tests
463      try {
464        const agentTestsDir = path.join(PROJECT_ROOT, 'tests/agents');
465        if (fsSync.existsSync(agentTestsDir)) {
466          const agentTestFiles = await fs.readdir(agentTestsDir);
467          const relatedAgentTests = agentTestFiles.filter(
468            file =>
469              file.includes(basename) &&
470              (file.endsWith('.test.js') || file.endsWith('.integration.test.js'))
471          );
472          testFiles.push(...relatedAgentTests.map(f => path.join(agentTestsDir, f)));
473        }
474      } catch (error) {
475        // Agent tests directory might not exist
476      }
477  
478      return {
479        imports,
480        dependencies,
481        testFiles,
482      };
483    } catch (error) {
484      logger.error(`Failed to get file context ${absPath}: ${error.message}`);
485      throw new Error(`Failed to get file context: ${error.message}`);
486    }
487  }
488  
489  /**
490   * List all backup files for a given file
491   *
492   * @param {string} filePath - Path to file
493   * @returns {Promise<string[]>} Array of backup file paths, sorted by date (newest first)
494   *
495   * @example
496   * const backups = await listBackups('src/utils/logger.js');
497   * console.log('Available backups:', backups);
498   */
499  export async function listBackups(filePath) {
500    const absPath = validatePath(filePath);
501    const relativePath = path.relative(PROJECT_ROOT, absPath);
502    const backupPrefix = relativePath.replace(/\//g, '_');
503  
504    try {
505      await fs.mkdir(BACKUP_DIR, { recursive: true });
506      const files = await fs.readdir(BACKUP_DIR);
507  
508      const backups = files
509        .filter(f => f.startsWith(backupPrefix) && f.endsWith('.backup'))
510        .map(f => path.join(BACKUP_DIR, f))
511        .sort()
512        .reverse(); // Newest first
513  
514      return backups;
515    } catch (error) {
516      logger.error(`Failed to list backups: ${error.message}`);
517      return [];
518    }
519  }
520  
521  /**
522   * Delete old backups, keeping only the most recent N backups
523   *
524   * @param {string} filePath - Path to file
525   * @param {number} keepCount - Number of backups to keep (default: 5)
526   * @returns {Promise<number>} Number of backups deleted
527   *
528   * @example
529   * const deleted = await cleanupBackups('src/utils/logger.js', 3);
530   * console.log(`Deleted ${deleted} old backups`);
531   */
532  export async function cleanupBackups(filePath, keepCount = 5) {
533    const backups = await listBackups(filePath);
534  
535    if (backups.length <= keepCount) {
536      return 0;
537    }
538  
539    const toDelete = backups.slice(keepCount);
540    let deleted = 0;
541  
542    for (const backup of toDelete) {
543      try {
544        await fs.unlink(backup);
545        deleted++;
546      } catch (error) {
547        logger.error(`Failed to delete backup ${backup}: ${error.message}`);
548      }
549    }
550  
551    logger.info(`Cleaned up ${deleted} old backups for ${path.relative(PROJECT_ROOT, filePath)}`);
552  
553    return deleted;
554  }
555  
556  export default {
557    readFile,
558    writeFile,
559    editFile,
560    backupFile,
561    restoreBackup,
562    validateJavaScript,
563    getFileContext,
564    listBackups,
565    cleanupBackups,
566  };