UNPKG

chainsafe

Version:

A CLI tool to automatically add optional chaining to TypeScript and JavaScript files

508 lines (425 loc) 14.9 kB
#!/usr/bin/env node const fs = require('fs').promises; const path = require('path'); const parser = require('@babel/parser'); const traverse = require('@babel/traverse').default; // Configuration const DEFAULT_CONFIG = { maxIterations: 5, maxFileSize: 10 * 1024 * 1024, // 10MB supportedExtensions: ['.js', '.jsx', '.ts', '.tsx'], ignoreDirectories: ['node_modules', '.git', 'dist', 'build'], ignoreFiles: ['.d.ts'], builtInGlobals: new Set([ 'Array', 'Object', 'String', 'Number', 'Boolean', 'Date', 'Math', 'JSON', 'RegExp', 'Error', 'Map', 'Set', 'WeakMap', 'WeakSet', 'Symbol', 'Promise', 'Proxy', 'Reflect', 'BigInt', 'Function', 'console', 'Buffer', 'process' ]) }; const HELP_TEXT = ` Optional Chaining Transformer ============================= A tool to automatically add optional chaining operators to JavaScript/TypeScript code. Usage: chainsafe <path> [options] Options: --preview Show changes without writing to files --help Show this help message --skip <names> Add names to skip list (comma-separated) --no-skip <names> Remove names from skip list (comma-separated) --skip-list Show current skip list --skip-none Don't skip any globals --skip-only <names> Only skip specified names --apply-only <names> Only apply to specified names --type ts|js Process only TypeScript or JavaScript files Examples: chainsafe src/ chainsafe file.js --preview chainsafe src/ --skip axios,lodash chainsafe src/ --apply-only axios`; class State { constructor(config = DEFAULT_CONFIG) { this.builtInGlobals = new Set(config.builtInGlobals); this.applyOnlySet = new Set(); this.skipOnlySet = new Set(); this.config = config; this.stats = { filesProcessed: 0, filesModified: 0, errors: 0, warnings: 0, startTime: Date.now() }; } shouldSkip(name) { if (!name) return false; if (this.applyOnlySet.size > 0) { return !this.applyOnlySet.has(name); } if (this.skipOnlySet.size > 0) { return this.skipOnlySet.has(name); } return this.builtInGlobals.has(name); } getExecutionTime() { return ((Date.now() - this.stats.startTime) / 1000).toFixed(2); } addWarning() { this.stats.warnings++; } } async function isBinaryFile(buffer) { const sample = buffer.slice(0, 4096); return sample.some(byte => byte === 0); } const getPlugins = (isTypeScript) => [ ...(isTypeScript ? ['typescript'] : []), // TypeScript plugin first if needed 'jsx', ['optionalChainingAssign', { version: '2023-07' }], 'classProperties', 'classPrivateProperties', 'classPrivateMethods', 'exportDefaultFrom', 'dynamicImport', 'objectRestSpread', ['decorators', { decoratorsBeforeExport: true }] ]; function validatePath(ast, filePath, state) { // Check for mixed imports/requires let hasImport = false; let hasRequire = false; traverse(ast, { ImportDeclaration() { hasImport = true; }, CallExpression(path) { if (path.node.callee.name === 'require') { hasRequire = true; } } }); if (hasImport && hasRequire) { console.warn(`⚠️ Warning: Mixed imports/requires in ${filePath}`); state.addWarning(); } } async function addOptionalChaining(code, filePath, state) { const isTypeScript = filePath.toLowerCase().endsWith('.ts') || filePath.toLowerCase().endsWith('.tsx'); const ast = parser.parse(code, { sourceType: 'module', plugins: getPlugins(isTypeScript), tokens: true, allowImportExportEverywhere: true, errorRecovery: true }); validatePath(ast, filePath, state); const insertPositions = new Set(); traverse(ast, { MemberExpression(path) { try { // Skip if already optional or no object if (path.node.optional || !path.node.object) return; // Handle identifiers first if (path.node.object.type === 'Identifier') { const name = path.node.object.name; // Direct checks first (process, builtins, skip list) if (name === 'process' || state.shouldSkip(name)) { return; } const binding = path.scope.getBinding(name); // Binding checks if (binding) { // Skip catch clause error parameters if (binding.path?.parentPath?.node?.type === 'CatchClause') return; // Skip imported bindings if (binding.path?.isImportSpecifier() || binding.path?.isImportDefaultSpecifier() || binding.path?.isImportNamespaceSpecifier()) { return; } // Skip enum access if (binding.path?.parent?.type === 'TSEnumDeclaration' || binding.path?.parent?.type === 'EnumDeclaration') { return; } // Skip constants and literals if (binding.constant && binding.path?.node?.init) { const init = binding.path.node.init; if (['StringLiteral', 'NumericLiteral', 'BooleanLiteral', 'ObjectExpression', 'ArrayExpression'].includes(init.type)) { return; } } // Skip if the binding name is in skip list if (state.shouldSkip(binding.path?.node?.name)) { return; } } } // Check parent chain let currentPath = path; let skipChaining = false; while (currentPath?.parentPath) { const parentNode = currentPath.parentPath.node; if (!parentNode) break; // Skip type-related constructs if (['TSTypeReference', 'TSQualifiedName', 'TSModuleDeclaration', 'TSNamespaceExportDeclaration'].includes(parentNode.type)) { skipChaining = true; break; } // Skip catch blocks if (parentNode.type === 'CatchClause') { skipChaining = true; break; } // Skip assignments and updates if ((parentNode.type === 'AssignmentExpression' && parentNode.left === currentPath.node) || (parentNode.type === 'CallExpression' && currentPath.node === parentNode.callee) || parentNode.type === 'UpdateExpression') { skipChaining = true; break; } // Skip destructuring and class members if (parentNode.type === 'ObjectPattern' || ['ClassProperty', 'ClassPrivateProperty', 'ClassMethod', 'ClassPrivateMethod'].includes(parentNode.type)) { skipChaining = true; break; } currentPath = currentPath.parentPath; } if (skipChaining) return; // Skip process.env access if (path.node.object.type === 'MemberExpression' && path.node.object.object?.name === 'process' && path.node.object.property?.name === 'env') { return; } // Handle 'this' expressions in class methods if (path.node.object.type === 'ThisExpression') { if (path.findParent(p => ['ClassMethod', 'ClassProperty', 'ClassPrivateProperty', 'ClassPrivateMethod'] .includes(p.node.type))) { return; } } // Check enum access in member expressions let rootObject = path.node.object; let isEnumAccess = false; while (rootObject?.type === 'MemberExpression') { if (rootObject.object?.type === 'Identifier') { const binding = path.scope.getBinding(rootObject.object.name); if (binding?.path?.parent?.type === 'TSEnumDeclaration' || binding?.path?.parent?.type === 'EnumDeclaration') { isEnumAccess = true; break; } } rootObject = rootObject.object; } if (isEnumAccess) return; // Add position for optional chaining if (path.node.property?.start !== undefined) { insertPositions.add(path.node.property.start - 1); } } catch (error) { error.phase = 'traverse'; error.expression = path.node?.type; error.location = { start: path.node?.start, end: path.node?.end }; throw error; } } }); // Apply transformations const positions = Array.from(insertPositions).sort((a, b) => b - a); let result = code; for (const pos of positions) { result = result.slice(0, pos) + (result.slice(pos, pos + 1) !== '.' ? '?.' : '?') + result.slice(pos); } return { code: result, hasChanges: positions.length > 0 }; } async function processFile(filePath, options = {}, state) { try { const stats = await fs.stat(filePath); if (stats.size > state.config.maxFileSize) { throw new Error(`File too large (>${state.config.maxFileSize / 1024 / 1024}MB)`); } if (options.fileType) { const isTypeScript = filePath.toLowerCase().endsWith('.ts') || filePath.toLowerCase().endsWith('.tsx'); if ((options.fileType === 'ts' && !isTypeScript) || (options.fileType === 'js' && isTypeScript)) { return; } } console.log(`Processing: ${filePath}`); const buffer = await fs.readFile(filePath); if (await isBinaryFile(buffer)) { console.log('Skipping binary file'); return; } let code = buffer.toString('utf-8'); const cleanCode = code.replace(/^\uFEFF|\uFFFE|\uEFBBBF/, ''); let previousCode = ''; let iteration = 1; let hasChanges = false; code = cleanCode; while (iteration <= state.config.maxIterations && code !== previousCode) { previousCode = code; const result = await addOptionalChaining(code, filePath, state); code = result.code; if (code === previousCode) { if (iteration > 1) { console.log(`✨ No more changes needed after iteration ${iteration}`); } break; } hasChanges = true; iteration++; } if (!hasChanges) { console.log('No changes needed'); return; } if (options.preview) { console.log('\nTransformed code:'); console.log(code); } else { await fs.writeFile(filePath, code); console.log('✅ File transformed successfully'); state.stats.filesModified++; } state.stats.filesProcessed++; } catch (error) { state.stats.errors++; error.filePath = filePath; throw error; } } const seen = new Set(); async function processDirectory(dirPath, options = {}, state) { const realPath = await fs.realpath(dirPath); if (seen.has(realPath)) return; seen.add(realPath); const entries = await fs.readdir(dirPath); for (const entry of entries) { const fullPath = path.normalize(path.join(dirPath, entry)); const stats = await fs.lstat(fullPath); if (stats.isSymbolicLink()) continue; if (stats.isDirectory() && !state.config.ignoreDirectories.includes(entry) && !entry.startsWith('.')) { await processDirectory(fullPath, options, state); } else if (state.config.supportedExtensions.includes(path.extname(fullPath).toLowerCase()) && !state.config.ignoreFiles.includes(path.basename(fullPath))) { await processFile(fullPath, options, state); } } } function parseNames(input) { return input ? input.split(',').map(name => name.trim()).filter(Boolean) : []; } function validateNames(names) { return names.every(name => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name)); } function parseOption(args, flag, errorMsg) { const index = args.indexOf(flag); if (index !== -1 && args[index + 1]) { const names = parseNames(args[index + 1]); if (!validateNames(names)) { console.error(errorMsg); process.exit(1); } return names; } return null; } async function main() { const args = process.argv.slice(2); if (args.length === 0 || args.includes('--help')) { console.log(HELP_TEXT); return; } const state = new State(); if (args.includes('--skip-list')) { console.log('\nCurrent skip list:'); console.log(Array.from(state.builtInGlobals).sort().join('\n')); return; } if (args.includes('--skip-none')) { console.log('🔧 Disabling all skips'); state.builtInGlobals.clear(); } // Parse options const skipNames = parseOption(args, '--skip', '❌ Invalid names provided for --skip'); if (skipNames) { skipNames.forEach(name => state.builtInGlobals.add(name)); } const noSkipNames = parseOption(args, '--no-skip', '❌ Invalid names provided for --no-skip'); if (noSkipNames) { noSkipNames.forEach(name => state.builtInGlobals.delete(name)); } const skipOnlyNames = parseOption(args, '--skip-only', '❌ Invalid names provided for --skip-only'); if (skipOnlyNames) { state.skipOnlySet = new Set(skipOnlyNames); state.builtInGlobals.clear(); } const applyOnlyNames = parseOption(args, '--apply-only', '❌ Invalid names provided for --apply-only'); if (applyOnlyNames) { state.applyOnlySet = new Set(applyOnlyNames); } if (state.skipOnlySet.size > 0 && state.applyOnlySet.size > 0) { console.error('❌ Error: --skip-only and --apply-only cannot be used together'); process.exit(1); } const typeIndex = args.indexOf('--type'); const fileType = typeIndex !== -1 ? args[typeIndex + 1] : null; if (fileType && !['ts', 'js'].includes(fileType)) { console.error('❌ Invalid file type. Use --type ts or --type js'); process.exit(1); } const inputPath = args[0]; const options = { preview: args.includes('--preview'), fileType }; try { const stats = await fs.stat(inputPath); console.log('\n🚀 Starting transformation...'); if (stats.isDirectory()) { await processDirectory(inputPath, options, state); } else { await processFile(inputPath, options, state); } console.log('\n✨ Processing complete!'); console.log('\nStatistics:'); console.log(`Files processed: ${state.stats.filesProcessed}`); console.log(`Files modified: ${state.stats.filesModified}`); console.log(`Errors encountered: ${state.stats.errors}`); console.log(`Warnings: ${state.stats.warnings}`); console.log(`Total time: ${state.getExecutionTime()}s`); } catch (error) { console.error('\n❌ Error:', error.message); if (error.phase) { console.error('Phase:', error.phase); console.error('Expression:', error.expression); console.error('Location:', error.location); } if (error.filePath) { console.error('File:', error.filePath); } process.exit(1); } } if (require.main === module) { main().catch(error => { console.error('Fatal error:', error); process.exit(1); }); } module.exports = { addOptionalChaining };