Lors de l’analyse de grandes bases de code, charger tous les fichiers en mémoire d’un coup entraîne un échec. En collectant d’abord uniquement les chemins et stats, en complétant l’analyse de structure, puis en découpant par budget d’octets pour lire/parser/libérer uniquement les fichiers nécessaires séquentiellement, vous pouvez garder l’utilisation mémoire prévisible.
Code
import { promises as fs } from 'node:fs'import { join, extname } from 'node:path'
interface FileEntry { path: string size: number}
interface ParsedFile { path: string content: string ast?: unknown // AST ou résultat d'analyse}
interface WalkOptions { /** Extensions à parser (ex: ['.ts', '.js']) */ parseableExtensions: string[] /** Motifs à ignorer (ex: node_modules, .git) */ ignorePatterns: RegExp[] /** Octets maximum par chunk */ chunkBudget: number /** Taille maximum d'un fichier unique (ignore les fichiers dépassant) */ maxFileSize: number}
/** * Phase 1 : Scanner uniquement les chemins et tailles */async function scanPaths( rootPath: string, options: WalkOptions): Promise<FileEntry[]> { const entries: FileEntry[] = []
async function walk(dir: string): Promise<void> { const items = await fs.readdir(dir, { withFileTypes: true })
for (const item of items) { const fullPath = join(dir, item.name)
// Vérifier les motifs à ignorer (jugement précoce pour l'efficacité mémoire) if (options.ignorePatterns.some((pattern) => pattern.test(fullPath))) { continue }
if (item.isDirectory()) { await walk(fullPath) } else if (item.isFile()) { const stat = await fs.stat(fullPath)
// Vérification de taille (exclure les énormes fichiers tôt) if (stat.size > options.maxFileSize) { continue }
entries.push({ path: fullPath, size: stat.size }) } } }
await walk(rootPath) return entries}
/** * Phase 2 : Analyse de structure et filtrage */function analyzeStructure( entries: FileEntry[], options: WalkOptions): FileEntry[] { // Filtrer par extension const parseableFiles = entries.filter((entry) => { const ext = extname(entry.path) return options.parseableExtensions.includes(ext) })
// Trier par taille (traiter les petits fichiers d'abord donne des résultats précoces) return parseableFiles.toSorted((a, b) => a.size - b.size)}
/** * Phase 3 : Découper par budget d'octets et lire/parser séquentiellement */async function parseWithBudget( entries: FileEntry[], options: WalkOptions, parseFile: (content: string, path: string) => unknown): Promise<ParsedFile[]> { const results: ParsedFile[] = [] let currentBudget = 0 let chunk: FileEntry[] = []
for (const entry of entries) { // Traiter le chunk quand le budget est dépassé if (currentBudget + entry.size > options.chunkBudget) { // Traiter le chunk actuel const parsed = await processChunk(chunk, parseFile) results.push(...parsed)
// Réinitialiser (permet au GC de récupérer la mémoire) chunk = [] currentBudget = 0 }
chunk.push(entry) currentBudget += entry.size }
// Traiter le dernier chunk if (chunk.length > 0) { const parsed = await processChunk(chunk, parseFile) results.push(...parsed) }
return results}
/** * Lecture et parsing en batch des fichiers dans le chunk */async function processChunk( chunk: FileEntry[], parseFile: (content: string, path: string) => unknown): Promise<ParsedFile[]> { const results: ParsedFile[] = []
// Lecture parallèle (exécution concurrente dans le chunk pour l'efficacité I/O) const readPromises = chunk.map(async (entry) => { try { const content = await fs.readFile(entry.path, 'utf-8') const ast = parseFile(content, entry.path) return { path: entry.path, content, ast } } catch (error) { console.warn(`Failed to parse ${entry.path}:`, error) return null } })
const parsed = await Promise.all(readPromises)
for (const result of parsed) { if (result) { results.push(result) } }
return results}
/** * Interface unifiée */export async function walkRepository( rootPath: string, options: WalkOptions, parseFile: (content: string, path: string) => unknown): Promise<ParsedFile[]> { // Phase 1 : Scanner les chemins et tailles const entries = await scanPaths(rootPath, options)
// Phase 2 : Analyse de structure const parseableFiles = analyzeStructure(entries, options)
// Phase 3 : Parser séquentiellement par budget d'octets const results = await parseWithBudget(parseableFiles, options, parseFile)
return results}Utilisation
import { parse } from '@typescript-eslint/typescript-estree'
// Analyser un dépôt TypeScriptconst results = await walkRepository( './my-project', { parseableExtensions: ['.ts', '.tsx', '.js', '.jsx'], ignorePatterns: [ /node_modules/, /\.git/, /dist/, /build/ ], chunkBudget: 50 * 1024 * 1024, // 50MB maxFileSize: 10 * 1024 * 1024 // 10MB }, (content, path) => { // Appliquer un parser AST return parse(content, { filePath: path, jsx: true }) })
console.log(`Parsed ${results.length} files`)Comment ça marche
- Phase 1 (Scan) : Parcourir récursivement les répertoires avec
fs.readdiretfs.stat, en collectant uniquement path et size. Le contenu des fichiers n’est pas lu. - Phase 2 (Analyse de structure) : Extraire les fichiers parsables des métadonnées collectées et optimiser l’ordre de traitement (par taille, priorité, etc.).
- Phase 3 (Découpage + Parsing) : Diviser les fichiers en chunks ne dépassant pas le budget d’octets, puis read → parse → free chaque chunk.
- Lecture parallèle : Les fichiers d’un chunk sont lus simultanément avec
Promise.allpour réduire le temps d’attente I/O. - Libération mémoire : Réinitialiser les variables après traitement du chunk pour que le GC puisse récupérer la mémoire.
Avantages
- Contrôle mémoire : Éviter de lire tous les fichiers d’un coup ; définir une limite supérieure avec le budget d’octets
- Filtrage précoce : Exclure les fichiers inutiles en utilisant uniquement les stats, réduisant I/O
- Suivi de progression : Rapporter la progression par chunk, facilitant la mise à jour UI ou la gestion d’annulation
- Efficacité I/O : Optimiser le temps d’attente disque avec lecture parallèle dans les chunks
Précautions
Pour déterminer “quels fichiers prioriser” dans l’analyse de structure de Phase 2 nécessite une certaine connaissance du domaine. Par exemple, lire package.json ou les fichiers de configuration d’abord peut informer la stratégie de traitement pour les autres fichiers. De plus, chunkBudget et maxFileSize nécessitent un ajustement selon les limites mémoire de l’environnement.
Applications
- Indexeur de code : Extraire et indexer les définitions de fonction/type dans le dépôt
- Analyseur statique : Appliquer en masse les règles de lint à une grande base de code
- Outil de migration basé sur AST : Appliquer du refactoring automatique à toute la base de code
- Linter en masse : Vérifier des dizaines de milliers de fichiers sans échec mémoire
- Ingestion de grand dépôt : Analyser les changements dans de grands dépôts en CI/CD
hsb.horse