// tools/audit-unused.cjs /* Audit "unused": - Fichiers injoignables depuis des entrypoints - Exports non référencés - Imports orphelins ⚠️ Static analysis best-effort: si tu fais des require dynamiques, DI, ou evals, ajoute un @keep pour ignorer un export: // @keep:export NomExport */ const fs = require('fs'); const path = require('path'); const ROOT = process.cwd().replace(/\\/g, '/'); const EXTS = ['.js', '.cjs', '.mjs', '.jsx', '.html', '.htm']; const IGNORE_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.next', '.vercel']); // Exclusion patterns for files that are normally "unreachable" but should be kept const EXCLUSION_PATTERNS = [ /^tests\//, // Test files /^tools\//, // Development tools /\.test\./, // Test files anywhere /\.spec\./, // Spec files /^scripts\//, // Build/deploy scripts /^docs?\//, // Documentation /^public\//, // Static files served by Express /^reports\//, // Generated reports /^cache\//, // Cache directory /^configs\//, // Configuration files /^logs\//, // Log files /code\.js$/, // Generated bundle /\.original\./, // Backup/original files /\.refactored\./, // Refactored versions kept for reference ]; function getEntrypoints() { const entrypoints = [ 'lib/test-manual.js', 'lib/Main.js', ]; // Add package.json main entry try { const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); if (pkg.main) { entrypoints.push(pkg.main); } // Add npm scripts that reference files if (pkg.scripts) { for (const [scriptName, command] of Object.entries(pkg.scripts)) { // Extract file references from npm scripts const fileRefs = command.match(/node\s+([^\s]+\.js)/g); if (fileRefs) { fileRefs.forEach(ref => { const file = ref.replace(/^node\s+/, ''); if (!file.startsWith('-') && !file.includes('*')) { entrypoints.push(file); } }); } } } } catch (e) { // package.json not found or invalid, continue with default entrypoints } return [...new Set(entrypoints)].map(p => path.resolve(ROOT, p)); } const ENTRYPOINTS = getEntrypoints(); const files = []; (function walk(dir) { for (const name of fs.readdirSync(dir, { withFileTypes: true })) { if (name.isDirectory()) { if (!IGNORE_DIRS.has(name.name)) walk(path.join(dir, name.name)); } else { const ext = path.extname(name.name); if (EXTS.includes(ext)) files.push(path.join(dir, name.name)); } } })(ROOT); const byNorm = new Map(); for (const f of files) byNorm.set(path.normalize(f), f); function resolveImport(fromFile, spec) { // ignore packages ('openai', 'undici', etc.) if (!spec.startsWith('.') && !spec.startsWith('/')) return null; // Handle special cases for static files if (spec.match(/\.(html|css|png|jpg|svg|json)$/)) { // For static files, try direct resolution first const base = path.resolve(path.dirname(fromFile), spec); const n = path.normalize(base); if (byNorm.has(n)) return byNorm.get(n); // Try common static directories const staticDirs = ['public', 'tools', 'assets', 'static']; for (const dir of staticDirs) { const staticPath = path.resolve(ROOT, dir, path.basename(spec)); const n = path.normalize(staticPath); if (byNorm.has(n)) return byNorm.get(n); } return null; } // Handle JS files const base = path.resolve(path.dirname(fromFile), spec); const candidates = [ base, base + '.js', base + '.cjs', base + '.mjs', base + '.jsx', path.join(base, 'index.js'), path.join(base, 'index.cjs'), path.join(base, 'index.mjs'), ]; for (const c of candidates) { const n = path.normalize(c); if (byNorm.has(n)) return byNorm.get(n); } return null; } const RE = { // imports (more comprehensive) require: /require\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g, importFrom: /import\s+[^'"]+\s+from\s+['"`]([^'"`]+)['"`]/g, importOnly: /import\s+['"`]([^'"`]+)['"`]\s*;?/g, // Additional require patterns (anywhere in code, including functions) requireAnywhere: /require\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g, requireWithDestructure: /(?:const|let|var)\s*{\s*[^}]*}\s*=\s*require\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g, // Dynamic imports dynamicImport: /import\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g, // exports CJS moduleExportsObj: /module\.exports\s*=\s*{([\s\S]*?)}/g, moduleExportsAssign: /module\.exports\.(\w+)\s*=/g, exportsAssign: /exports\.(\w+)\s*=/g, // exports ESM exportNamed: /export\s+(?:const|let|var|function|class)\s+(\w+)/g, exportList: /export\s*{\s*([^}]+)\s*}/g, // usage of members // const { a, b } = require('./m'); import { a } from './m' destructured: /(?:const|let|var|import)\s*{([^}]+)}\s*=\s*(?:require\(|from\s+['"`])/g, // require('./m').foo OR (await import('./m')).foo dotUse: /(?:require\(\s*['"`][^'"`]+['"`]\s*\)|import\(\s*['"`][^'"`]+['"`]\s*\))\.(\w+)/g, keep: /@keep:export\s+(\w+)/g, }; function parseFile(file) { const txt = fs.readFileSync(file, 'utf8'); const ext = path.extname(file).toLowerCase(); const imports = []; const exports = new Set(); const keep = new Set(); // Handle HTML files if (ext === '.html' || ext === '.htm') { // Extract script src references const scriptSrcRE = /]+src\s*=\s*['"`]([^'"`]+)['"`][^>]*>/gi; let m; while ((m = scriptSrcRE.exec(txt))) { const src = m[1]; if (src.startsWith('./') || src.startsWith('../') || (!src.startsWith('http') && !src.startsWith('//'))) { imports.push(src); } } // HTML files don't have exports in the traditional sense return { file, txt, imports, exports: [], keep: [] }; } // Handle JS files - comprehensive import detection for (const rx of [RE.require, RE.importFrom, RE.importOnly, RE.requireAnywhere, RE.requireWithDestructure, RE.dynamicImport]) { let m; while ((m = rx.exec(txt))) imports.push(m[1]); } // Template literal imports (basic detection) const templateImportRE = /(?:require|import)\s*\(\s*`([^`]+)`/g; let m; while ((m = templateImportRE.exec(txt))) { // Only add if it looks like a static path (no ${} interpolation) if (!m[1].includes('${')) { imports.push(m[1]); } } // Special case: require inside function calls (common pattern) const conditionalRequireRE = /(?:if|when|case|function|=>|\{)\s*[^}]*require\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g; while ((m = conditionalRequireRE.exec(txt))) { imports.push(m[1]); } // Static file references in Express/servers // res.sendFile(path.join(__dirname, 'file.html')) const sendFileRE = /(?:sendFile|readFile|createReadStream)\s*\(\s*(?:path\.join\s*\([^)]*?,\s*['"`]([^'"`]+)['"`]|['"`]([^'"`]+)['"`])/g; while ((m = sendFileRE.exec(txt))) { const filePath = m[1] || m[2]; if (filePath && (filePath.endsWith('.html') || filePath.endsWith('.css') || filePath.endsWith('.js') || filePath.endsWith('.json'))) { // Convert relative path to current directory structure imports.push('./' + filePath.replace(/^\.\//, '')); } } // File references in strings (href, src, etc.) const fileRefRE = /(?:href|src|action|data-src|url)\s*=\s*['"`]\.?\/([^'"`]+\.(?:html|css|js|json|png|jpg|svg))['"`]/g; while ((m = fileRefRE.exec(txt))) { imports.push('./' + m[1]); } // Path.join patterns with file extensions const pathJoinRE = /path\.join\s*\([^)]*?,\s*['"`]([^'"`]*\.(?:html|css|js|json))['"`]/g; while ((m = pathJoinRE.exec(txt))) { imports.push('./' + m[1].replace(/^\.\//, '')); } // CJS object export while ((m = RE.moduleExportsObj.exec(txt))) { const inside = m[1]; inside.split(',').forEach(p => { const n = (p.split(':')[0] || '').trim().replace(/\s+/g, ''); if (n) exports.add(n); }); } while ((m = RE.moduleExportsAssign.exec(txt))) exports.add(m[1]); while ((m = RE.exportsAssign.exec(txt))) exports.add(m[1]); // ESM named while ((m = RE.exportNamed.exec(txt))) exports.add(m[1]); while ((m = RE.exportList.exec(txt))) { m[1].split(',').forEach(s => { const n = s.split(' as ')[0].trim(); if (n) exports.add(n); }); } while ((m = RE.keep.exec(txt))) keep.add(m[1]); return { file, txt, imports, exports: [...exports], keep: [...keep] }; } const parsed = files.map(parseFile); // Build edges const edges = new Map(); // from -> Set(to) const importMap = new Map(); // from -> [{raw, resolved}] for (const p of parsed) { const tos = new Set(); const details = []; for (const spec of p.imports) { const resolved = resolveImport(p.file, spec); if (resolved) tos.add(resolved); details.push({ raw: spec, resolved }); } edges.set(p.file, tos); importMap.set(p.file, details); } // Reachability from entrypoints const reachable = new Set(); function dfs(start) { const stack = [start]; while (stack.length) { const f = stack.pop(); if (!byNorm.has(path.normalize(f))) continue; if (reachable.has(f)) continue; reachable.add(f); const tos = edges.get(f) || new Set(); tos.forEach(t => stack.push(t)); } } ENTRYPOINTS.forEach(dfs); // Usage of exports (very simple heuristic) const exportIndex = new Map(); // file -> Set(exportName) parsed.forEach(p => exportIndex.set(p.file, new Set(p.exports))); const used = new Map(); // file -> Set(usedName) for (const p of parsed) { const txt = p.txt; // destructured usage let m; while ((m = RE.destructured.exec(txt))) { const names = m[1].split(',').map(s => s.split(':')[0].trim()); names.forEach(n => { if (!n) return; // mark globally; we can't know which file exactly (static pass), will map later by imports }); } // dot usage; we can't resolve to a file reliably; will do name-only aggregation } function collectImportedNames(file) { // Return map: targetFile -> Set(names) const res = new Map(); const txt = fs.readFileSync(file, 'utf8'); // import {a,b} from './x' const importNamed = /import\s*{([^}]+)}\s*from\s*['"`]([^'"`]+)['"`]/g; let m; while ((m = importNamed.exec(txt))) { const names = m[1].split(',').map(s => s.split(' as ')[0].trim()).filter(Boolean); const target = resolveImport(file, m[2]); if (target) { if (!res.has(target)) res.set(target, new Set()); names.forEach(n => res.get(target).add(n)); } } // const {a,b} = require('./x') const reqNamed = /(?:const|let|var)\s*{([^}]+)}\s*=\s*require\(\s*['"`]([^'"`]+)['"`]\s*\)/g; while ((m = reqNamed.exec(txt))) { const names = m[1].split(',').map(s => s.split(':')[0].trim()).filter(Boolean); const target = resolveImport(file, m[2]); if (target) { if (!res.has(target)) res.set(target, new Set()); names.forEach(n => res.get(target).add(n)); } } // require('./x').foo const reqDot = /require\(\s*['"`]([^'"`]+)['"`]\s*\)\.(\w+)/g; while ((m = reqDot.exec(txt))) { const target = resolveImport(file, m[1]); const name = m[2]; if (target) { if (!res.has(target)) res.set(target, new Set()); res.get(target).add(name); } } // (await import('./x')).foo OR import('./x').then(m=>m.foo) const impDot = /import\(\s*['"`]([^'"`]+)['"`]\s*\)\.(\w+)/g; while ((m = impDot.exec(txt))) { const target = resolveImport(file, m[1]); const name = m[2]; if (target) { if (!res.has(target)) res.set(target, new Set()); res.get(target).add(name); } } return res; } const usageByTarget = new Map(); // targetFile -> Set(namesUsed) for (const p of parsed) { const m = collectImportedNames(p.file); for (const [target, names] of m) { if (!usageByTarget.has(target)) usageByTarget.set(target, new Set()); names.forEach(n => usageByTarget.get(target).add(n)); } } function rel(p) { return path.relative(ROOT, p).replace(/\\/g, '/'); } const report = { entrypoints: ENTRYPOINTS.filter(fs.existsSync).map(rel), unreachableFiles: [], brokenImports: [], unusedExports: [], }; function isExcluded(filePath) { const relPath = rel(filePath); return EXCLUSION_PATTERNS.some(pattern => pattern.test(relPath)); } for (const f of files) { if (!reachable.has(f) && !ENTRYPOINTS.includes(f) && !isExcluded(f)) { report.unreachableFiles.push(rel(f)); } } for (const [from, list] of importMap) { for (const { raw, resolved } of list) { if ((raw.startsWith('.') || raw.startsWith('/')) && !resolved) { report.brokenImports.push({ from: rel(from), spec: raw }); } } } for (const p of parsed) { const exp = exportIndex.get(p.file); if (!exp || exp.size === 0) continue; const usedSet = usageByTarget.get(p.file) || new Set(); const keepMarks = new Set(p.keep || []); const unused = [...exp].filter(n => !usedSet.has(n) && !keepMarks.has(n)); if (unused.length > 0) { report.unusedExports.push({ file: rel(p.file), unused }); } } console.log('=== ENHANCED UNUSED AUDIT REPORT ==='); console.log(''); console.log('Entrypoints:', report.entrypoints); console.log(''); // Show excluded patterns console.log('— Exclusion patterns (files automatically kept):'); EXCLUSION_PATTERNS.forEach(pattern => console.log(` • ${pattern.source}`)); console.log(''); console.log('— Unreachable files (dead):'); if (report.unreachableFiles.length === 0) console.log(' ✔ none'); else report.unreachableFiles.sort().forEach(f => console.log(' •', f)); console.log(''); console.log('— Broken relative imports:'); if (report.brokenImports.length === 0) console.log(' ✔ none'); else report.brokenImports.forEach(i => console.log(` • ${i.from} -> ${i.spec}`)); console.log(''); console.log('— Unused exports:'); if (report.unusedExports.length === 0) console.log(' ✔ none'); else report.unusedExports .sort((a,b)=>a.file.localeCompare(b.file)) .forEach(r => console.log(` • ${r.file}: ${r.unused.join(', ')}`)); console.log(''); console.log('Tips:'); console.log(' • Add "@keep:export Name" comment to protect exports from false positives'); console.log(' • Test/tool files are automatically excluded from "unreachable" reports'); console.log(' • HTML files are now supported (script src detection)');