UNPKG

@neurolint/cli

Version:

NeuroLint CLI - Deterministic code fixing for TypeScript, JavaScript, React, and Next.js with 8-layer architecture including Security Forensics, Next.js 16, React Compiler, and Turbopack support

1,458 lines (1,256 loc) 95.5 kB
#!/usr/bin/env node /** * NeuroLint - Licensed under Apache License 2.0 * Copyright (c) 2025 NeuroLint * http://www.apache.org/licenses/LICENSE-2.0 */ /** * Layer 5: Next.js Fixes (AST-based) * Optimizes App Router with directives and imports using proper code parsing * Enhanced for Next.js 15.5 compatibility with Type Safe Routing * React 19 Integration: Handles ReactDOM.render, ReactDOM.hydrate, unmountComponentAtNode, findDOMNode */ const fs = require('fs').promises; const path = require('path'); const BackupManager = require('../backup-manager'); const ASTTransformer = require('../ast-transformer'); const { glob } = require('glob'); /** * React 19 DOM API Transformation Functions * Handles breaking changes in React 19 for ReactDOM APIs */ /** * Convert ReactDOM.render to createRoot (React 19) * ReactDOM.render is removed in React 19 */ function convertReactDOMRender(code) { let transformedCode = code; const changes = []; let rootCounter = 0; // Pattern: ReactDOM.render(<App />, container) // Convert to: createRoot(container).render(<App />) // Manual parsing to handle nested parentheses correctly const renderRegex = /ReactDOM\.render\s*\(/g; let match; while ((match = renderRegex.exec(code)) !== null) { const startPos = match.index + match[0].length; let depth = 1; let commaPos = -1; let pos = startPos; // Find the matching closing paren and the comma separator while (pos < code.length && depth > 0) { const char = code[pos]; if (char === '(') depth++; else if (char === ')') depth--; else if (char === ',' && depth === 1 && commaPos === -1) commaPos = pos; if (depth === 0) break; pos++; } if (commaPos === -1 || depth !== 0) continue; // Invalid pattern, skip const jsxElement = code.substring(startPos, commaPos).trim(); const container = code.substring(commaPos + 1, pos).trim(); const fullMatch = code.substring(match.index, pos + 1); // Check for trailing semicolon const hasSemicolon = code[pos + 1] === ';'; const matchWithSemicolon = hasSemicolon ? fullMatch + ';' : fullMatch; // Generate unique root variable name to avoid redeclaration errors const rootVarName = rootCounter === 0 ? 'root' : `root${rootCounter}`; rootCounter++; // Replace the render call with createRoot pattern const replacement = `const ${rootVarName} = createRoot(${container});\n${rootVarName}.render(${jsxElement});`; transformedCode = transformedCode.replace(matchWithSemicolon, replacement); changes.push({ type: 'react19-render', description: 'Converted ReactDOM.render to createRoot().render()', oldPattern: matchWithSemicolon, newPattern: replacement }); // Adjust regex lastIndex to continue after replacement renderRegex.lastIndex = match.index + replacement.length; } // Update imports if ReactDOM.render was converted and createRoot import doesn't exist if (changes.length > 0) { // Only add import if not already present const hasCreateRootImport = /import\s+{\s*[^}]*\bcreateRoot\b[^}]*}\s*from\s+['"]react-dom\/client['"]/.test(transformedCode); if (!hasCreateRootImport) { // Check if react-dom/client is already imported if (/import\s+{\s*([^}]+)\s*}\s*from\s+['"]react-dom\/client['"]/.test(transformedCode)) { // Add createRoot to existing react-dom/client imports transformedCode = transformedCode.replace( /import\s+{\s*([^}]+)\s*}\s*from\s+['"]react-dom\/client['"]/, (match, imports) => `import { ${imports.trim()}, createRoot } from 'react-dom/client'` ); } else { // Add new import line at the top after react-dom imports const reactDomImportMatch = transformedCode.match(/import\s+ReactDOM\s+from\s+['"]react-dom['"];?/); if (reactDomImportMatch) { const insertPosition = transformedCode.indexOf(reactDomImportMatch[0]) + reactDomImportMatch[0].length; transformedCode = transformedCode.slice(0, insertPosition) + '\nimport { createRoot } from \'react-dom/client\';' + transformedCode.slice(insertPosition); } else { // No react-dom import, add at beginning after other imports const importLines = transformedCode.match(/^import\s+.*$/gm) || []; if (importLines.length > 0) { const lastImportIndex = transformedCode.lastIndexOf(importLines[importLines.length - 1]); const insertPosition = lastImportIndex + importLines[importLines.length - 1].length; transformedCode = transformedCode.slice(0, insertPosition) + '\nimport { createRoot } from \'react-dom/client\';' + transformedCode.slice(insertPosition); } else { // No existing imports, add at the beginning transformedCode = 'import { createRoot } from \'react-dom/client\';\n' + transformedCode; } } } } } return { code: transformedCode, changes }; } /** * Convert ReactDOM.hydrate to hydrateRoot (React 19) * ReactDOM.hydrate is removed in React 19 */ function convertReactDOMHydrate(code) { let transformedCode = code; const changes = []; // Pattern: ReactDOM.hydrate(<App />, container) // Convert to: hydrateRoot(container, <App />) // Manual parsing to handle nested parentheses correctly const hydrateRegex = /ReactDOM\.hydrate\s*\(/g; let match; while ((match = hydrateRegex.exec(code)) !== null) { const startPos = match.index + match[0].length; let depth = 1; let commaPos = -1; let pos = startPos; // Find the matching closing paren and the comma separator while (pos < code.length && depth > 0) { const char = code[pos]; if (char === '(') depth++; else if (char === ')') depth--; else if (char === ',' && depth === 1 && commaPos === -1) commaPos = pos; if (depth === 0) break; pos++; } if (commaPos === -1 || depth !== 0) continue; // Invalid pattern, skip const jsxElement = code.substring(startPos, commaPos).trim(); const container = code.substring(commaPos + 1, pos).trim(); const fullMatch = code.substring(match.index, pos + 1); // Check for trailing semicolon const hasSemicolon = code[pos + 1] === ';'; const matchWithSemicolon = hasSemicolon ? fullMatch + ';' : fullMatch; // Replace the hydrate call with hydrateRoot pattern // NOTE: hydrateRoot takes (container, element) - order is swapped from hydrate! const replacement = `hydrateRoot(${container}, ${jsxElement});`; transformedCode = transformedCode.replace(matchWithSemicolon, replacement); changes.push({ type: 'react19-hydrate', description: 'Converted ReactDOM.hydrate to hydrateRoot()', oldPattern: matchWithSemicolon, newPattern: replacement }); // Adjust regex lastIndex to continue after replacement hydrateRegex.lastIndex = match.index + replacement.length; } // Update imports if ReactDOM.hydrate was converted and hydrateRoot import doesn't exist if (changes.length > 0) { // Only add import if not already present const hasHydrateRootImport = /import\s+{\s*[^}]*\bhydrateRoot\b[^}]*}\s*from\s+['"]react-dom\/client['"]/.test(transformedCode); if (!hasHydrateRootImport) { // Check if react-dom/client is already imported if (/import\s+{\s*([^}]+)\s*}\s*from\s+['"]react-dom\/client['"]/.test(transformedCode)) { // Add hydrateRoot to existing react-dom/client imports transformedCode = transformedCode.replace( /import\s+{\s*([^}]+)\s*}\s*from\s+['"]react-dom\/client['"]/, (match, imports) => `import { ${imports.trim()}, hydrateRoot } from 'react-dom/client'` ); } else { // Add new import line at the top after react-dom imports const reactDomImportMatch = transformedCode.match(/import\s+ReactDOM\s+from\s+['"]react-dom['"];?/); if (reactDomImportMatch) { const insertPosition = transformedCode.indexOf(reactDomImportMatch[0]) + reactDomImportMatch[0].length; transformedCode = transformedCode.slice(0, insertPosition) + '\nimport { hydrateRoot } from \'react-dom/client\';' + transformedCode.slice(insertPosition); } else { // No react-dom import, add at beginning after other imports const importLines = transformedCode.match(/^import\s+.*$/gm) || []; if (importLines.length > 0) { const lastImportIndex = transformedCode.lastIndexOf(importLines[importLines.length - 1]); const insertPosition = lastImportIndex + importLines[importLines.length - 1].length; transformedCode = transformedCode.slice(0, insertPosition) + '\nimport { hydrateRoot } from \'react-dom/client\';' + transformedCode.slice(insertPosition); } else { // No existing imports, add at the beginning transformedCode = 'import { hydrateRoot } from \'react-dom/client\';\n' + transformedCode; } } } } } return { code: transformedCode, changes }; } /** * Convert unmountComponentAtNode to root.unmount (React 19) * unmountComponentAtNode is removed in React 19 */ function convertUnmountComponentAtNode(code) { let transformedCode = code; const warnings = []; // Pattern: unmountComponentAtNode(container) // This requires manual migration since we need to store the root reference const unmountPattern = /unmountComponentAtNode\s*\(\s*([^)]+)\s*\)/g; let match; while ((match = unmountPattern.exec(code)) !== null) { const container = match[1].trim(); warnings.push({ type: 'react19-migration', severity: 'warning', message: `unmountComponentAtNode(${container}) requires manual migration to root.unmount()`, suggestion: `Store the createRoot(${container}) reference and call root.unmount() instead`, location: null, pattern: match[0] }); } return { code: transformedCode, warnings }; } /** * Detect findDOMNode usage (React 19) * findDOMNode is removed in React 19 */ function detectFindDOMNodeUsage(code) { const warnings = []; // Pattern: findDOMNode(component) or ReactDOM.findDOMNode(component) const findDOMNodePatterns = [ /findDOMNode\s*\(\s*([^)]+)\s*\)/g, /ReactDOM\.findDOMNode\s*\(\s*([^)]+)\s*\)/g ]; findDOMNodePatterns.forEach(pattern => { let match; while ((match = pattern.exec(code)) !== null) { const component = match[1].trim(); warnings.push({ type: 'react19-migration', severity: 'warning', message: `findDOMNode(${component}) is removed in React 19`, suggestion: 'Use refs with useRef() or createRef() to access DOM nodes directly', location: null, pattern: match[0] }); } }); return warnings; } /** * Convert ReactDOM test-utils imports to react imports (React 19) * react-dom/test-utils is removed in React 19; act moved to react package */ function convertReactDOMTestUtils(code) { let transformedCode = code; const changes = []; // Pattern: import { act } from 'react-dom/test-utils' const actOnlyPattern = /import\s*{\s*act\s*}\s*from\s*['"]react-dom\/test-utils['"];?/g; transformedCode = transformedCode.replace(actOnlyPattern, (match) => { const replacement = "import { act } from 'react';"; changes.push({ type: 'react19-test-utils', description: 'Converted react-dom/test-utils act import to react', oldPattern: match, newPattern: replacement }); return replacement; }); // Pattern: import { act, x, y } from 'react-dom/test-utils' const actWithOthersPattern = /import\s*{\s*act\s*,\s*([^}]+)}\s*from\s*['"]react-dom\/test-utils['"];?/g; transformedCode = transformedCode.replace(actWithOthersPattern, (match, others) => { const trimmedOthers = others.split(',').map(s => s.trim()).filter(Boolean).join(', '); const replacement = `import { act } from 'react';\nimport { ${trimmedOthers} } from 'react-dom/test-utils';`; changes.push({ type: 'react19-test-utils-mixed', description: 'Separated act import to react and kept remaining test-utils imports', oldPattern: match, newPattern: replacement }); return replacement; }); return { code: transformedCode, changes }; } /** * Apply all React 19 DOM API fixes */ function applyReact19DOMFixes(code, options = {}) { const { verbose = false } = options; let transformedCode = code; const fixes = []; const warnings = []; // 1. ReactDOM test-utils migration (React 19) if (transformedCode.includes('react-dom/test-utils')) { const testUtilsResult = convertReactDOMTestUtils(transformedCode); transformedCode = testUtilsResult.code; fixes.push(...testUtilsResult.changes); if (verbose && testUtilsResult.changes.length > 0) { testUtilsResult.changes.forEach(change => { process.stdout.write(`[INFO] ${change.description}\n`); }); } } // 2. ReactDOM.render conversion if (transformedCode.includes('ReactDOM.render')) { const renderResult = convertReactDOMRender(transformedCode); transformedCode = renderResult.code; fixes.push(...renderResult.changes); if (renderResult.changes.length > 0 && verbose) { renderResult.changes.forEach(change => { process.stdout.write(`[INFO] ${change.description}\n`); }); } } // 3. ReactDOM.hydrate conversion if (transformedCode.includes('ReactDOM.hydrate')) { const hydrateResult = convertReactDOMHydrate(transformedCode); transformedCode = hydrateResult.code; fixes.push(...hydrateResult.changes); if (hydrateResult.changes.length > 0 && verbose) { hydrateResult.changes.forEach(change => { process.stdout.write(`[INFO] ${change.description}\n`); }); } } // 4. unmountComponentAtNode detection and warnings if (transformedCode.includes('unmountComponentAtNode')) { const unmountResult = convertUnmountComponentAtNode(transformedCode); warnings.push(...unmountResult.warnings); if (unmountResult.warnings.length > 0 && verbose) { unmountResult.warnings.forEach(warning => { process.stdout.write(`[WARNING] ${warning.message}\n`); process.stdout.write(`[SUGGESTION] ${warning.suggestion}\n`); }); } } // 5. findDOMNode detection and warnings if (transformedCode.includes('findDOMNode')) { const findDOMNodeWarnings = detectFindDOMNodeUsage(transformedCode); warnings.push(...findDOMNodeWarnings); if (findDOMNodeWarnings.length > 0 && verbose) { findDOMNodeWarnings.forEach(warning => { process.stdout.write(`[WARNING] ${warning.message}\n`); process.stdout.write(`[SUGGESTION] ${warning.suggestion}\n`); }); } } // 6. Suggest migration to new React 19 static APIs const staticAPIResult = suggestStaticAPIMigration(transformedCode); warnings.push(...staticAPIResult.warnings); if (staticAPIResult.warnings.length > 0 && verbose) { staticAPIResult.warnings.forEach(suggestion => { process.stdout.write(`[SUGGESTION] ${suggestion.message}\n`); process.stdout.write(`[RECOMMENDATION] ${suggestion.suggestion}\n`); }); } return { code: transformedCode, fixes, warnings, hasReact19Changes: fixes.length > 0 || warnings.length > 0 }; } async function isRegularFile(filePath) { try { const stat = await fs.stat(filePath); return stat.isFile(); } catch { return false; } } /** * Type Safe Routing Transformer for Next.js 15.5 * Implements comprehensive type-safe routing with AST-based transformations */ class TypeSafeRoutingTransformer { constructor() { this.routePatterns = { page: /export\s+default\s+function\s+(\w+)\s*\(\s*\{[^}]*\}\s*\)/g, layout: /export\s+default\s+function\s+(\w+)\s*\(\s*\{[^}]*\}\s*\)/g, loading: /export\s+default\s+function\s+(\w+)\s*\(\s*\{[^}]*\}\s*\)/g, error: /export\s+default\s+function\s+(\w+)\s*\(\s*\{[^}]*\}\s*\)/g }; this.routeFilePatterns = [ 'app/**/page.tsx', 'app/**/page.ts', 'app/**/layout.tsx', 'app/**/layout.ts', 'app/**/loading.tsx', 'app/**/error.tsx', 'app/**/not-found.tsx' ]; } /** * Extract route parameters from file path */ extractRouteParams(filePath) { const routeSegments = filePath.split('/'); const params = {}; for (const segment of routeSegments) { if (segment.startsWith('[') && segment.endsWith(']')) { const paramName = segment.slice(1, -1); // Handle catch-all routes if (paramName.startsWith('...')) { params[paramName.slice(3)] = 'string[]'; } else { params[paramName] = 'string'; } } } return params; } /** * Generate TypeScript interface name from file path */ getInterfaceName(filePath) { const fileName = path.basename(filePath, path.extname(filePath)); const routePath = filePath.replace(/^.*?app\//, '').replace(/\/[^\/]+$/, ''); const routeName = routePath.split('/').map(segment => { if (segment.startsWith('[') && segment.endsWith(']')) { return segment.slice(1, -1).replace('...', ''); } return segment; }).join('_'); // Generate deterministic interface name with hash for uniqueness const baseName = `${routeName}_${fileName}_Props`; const hash = this.generateHash(filePath); return `${baseName}_${hash}`; } /** * Generate deterministic hash for file path */ generateHash(str) { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32-bit integer } return Math.abs(hash).toString(36).substring(0, 6); } /** * Generate TypeScript interfaces for route parameters */ generateRouteTypes(filePath, routeParams) { const interfaceName = this.getInterfaceName(filePath); const paramTypes = this.inferParamTypes(routeParams); return `interface ${interfaceName} { params: ${paramTypes}; searchParams: { [key: string]: string | string[] | undefined }; }`; } /** * Infer parameter types from route structure */ inferParamTypes(routeParams) { if (Object.keys(routeParams).length === 0) { return 'Record<string, string>'; } const types = []; for (const [key, value] of Object.entries(routeParams)) { if (value === 'string[]') { types.push(`${key}: string[]`); } else if (value === 'number') { types.push(`${key}: number`); } else { types.push(`${key}: string`); } } return `{ ${types.join('; ')} }`; } /** * Transform route components with type safety */ transformRouteComponent(code, filePath) { try { // Handle edge cases if (!code || typeof code !== 'string') { return { code: code || '', changes: [], warnings: ['No code to transform'] }; } const routeParams = this.extractRouteParams(filePath); const interfaceCode = this.generateRouteTypes(filePath, routeParams); const interfaceName = this.getInterfaceName(filePath); // Check if already has type-safe routing if (code.includes(`interface ${interfaceName}`) || code.includes('params:') && code.includes('searchParams:')) { return { code, changes: [], warnings: ['Type-safe routing already implemented'] }; } // Check if code has export default function if (!code.includes('export default function')) { return { code, changes: [], warnings: ['No export default function found'] }; } // Add interface before component const interfaceInsertion = `\n${interfaceCode}\n\n`; // Transform function signature const transformedCode = this.transformFunctionSignature(code, interfaceName); return { code: interfaceInsertion + transformedCode, changes: [{ type: 'type-safe-routing', description: `Added TypeScript interface for ${filePath}`, location: { line: 1 } }] }; } catch (error) { throw new Error(`Type Safe Routing transformation failed: ${error.message}`); } } /** * Transform function signature to use type-safe props */ transformFunctionSignature(code, interfaceName) { // More robust pattern to match export default function with destructured props const functionPattern = /(export\s+default\s+function\s+(\w+)\s*\(\s*\{[^}]*\}\s*\))/g; return code.replace(functionPattern, (match, fullMatch, functionName) => { if (!functionName) return match; // Handle different function signature variations const hasParams = fullMatch.includes('params'); const hasSearchParams = fullMatch.includes('searchParams'); // Preserve existing props if they exist let props = '{ params, searchParams }'; if (hasParams && !hasSearchParams) { props = '{ params, searchParams }'; } else if (!hasParams && hasSearchParams) { props = '{ params, searchParams }'; } else if (hasParams && hasSearchParams) { // Keep existing props but ensure they're in the right order props = '{ params, searchParams }'; } // Replace with type-safe signature return `export default function ${functionName}(${props}: ${interfaceName})`; }); } /** * Validate Type Safe Routing transformation */ validateTransformation(before, after, filePath) { const validation = { success: true, errors: [], warnings: [] }; try { // Check TypeScript syntax const parser = require('@babel/parser'); parser.parse(after, { sourceType: 'module', plugins: ['typescript', 'jsx'] }); } catch (error) { validation.success = false; validation.errors.push(`TypeScript syntax error: ${error.message}`); } // Verify interface generation const interfaceName = this.getInterfaceName(filePath); if (!after.includes(`interface ${interfaceName}`)) { validation.warnings.push('No TypeScript interface generated'); } // Check for interface name conflicts const interfaceMatches = after.match(/interface\s+(\w+)/g); if (interfaceMatches && interfaceMatches.length > 1) { const interfaceNames = interfaceMatches.map(match => match.replace('interface ', '')); const duplicates = interfaceNames.filter((name, index) => interfaceNames.indexOf(name) !== index); if (duplicates.length > 0) { validation.warnings.push(`Potential interface name conflicts: ${duplicates.join(', ')}`); } } // Check for proper function signature if (!after.includes('params:') || !after.includes('searchParams:')) { validation.warnings.push('Missing required route props'); } // Verify no breaking changes if (before.includes('export default') && !after.includes('export default')) { validation.success = false; validation.errors.push('Export default declaration lost'); } // Check for syntax integrity const beforeBrackets = (before.match(/\{/g) || []).length; const afterBrackets = (after.match(/\{/g) || []).length; const beforeBraces = (before.match(/\}/g) || []).length; const afterBraces = (after.match(/\}/g) || []).length; if (beforeBrackets !== afterBrackets || beforeBraces !== afterBraces) { validation.warnings.push('Bracket/brace count mismatch - potential syntax issue'); } return validation; } } /** * Next.js 15.5 File Discovery and Processing System * Implements intelligent file discovery for route components with comprehensive processing */ class NextJS15FileDiscoverer { constructor() { this.routePatterns = [ 'app/**/page.tsx', 'app/**/page.ts', 'app/**/layout.tsx', 'app/**/layout.ts', 'app/**/loading.tsx', 'app/**/error.tsx', 'app/**/not-found.tsx' ]; // Use dynamic require with fallback try { this.glob = require('glob'); } catch (error) { console.warn('[WARNING] glob package not found, using fallback file discovery'); this.glob = null; } } /** * Discover all route components in project */ async discoverRouteFiles(projectPath, options = {}) { const files = []; // Enhanced exclusion patterns to match CLI defaults const defaultExclusions = [ '**/node_modules/**', '**/dist/**', '**/.next/**', '**/build/**', '**/.build/**', '**/out/**', '**/.out/**', '**/coverage/**', '**/.nyc_output/**', '**/.jest/**', '**/test-results/**', '**/.git/**', '**/.vscode/**', '**/.idea/**', '**/.vs/**', '**/.cache/**', '**/cache/**', '**/.parcel-cache/**', '**/.eslintcache', '**/.stylelintcache', '**/.neurolint/**', '**/states-*.json', '**/*.backup-*', '**/*.backup' ]; const exclusions = options.exclude || defaultExclusions; if (!this.glob) { // Fallback: use fs-based discovery return await this.discoverFilesFallback(projectPath, exclusions); } for (const pattern of this.routePatterns) { try { const matches = await this.glob(pattern, { cwd: projectPath, absolute: true, ignore: exclusions }); files.push(...matches); } catch (error) { console.warn(`[WARNING] Failed to discover files with pattern ${pattern}: ${error.message}`); } } return files.filter(file => this.isValidRouteFile(file)); } /** * Fallback file discovery using fs */ async discoverFilesFallback(projectPath, exclusions = []) { const files = []; try { await this.scanDirectory(projectPath, files, exclusions); } catch (error) { console.warn(`[WARNING] Fallback file discovery failed: ${error.message}`); } return files.filter(file => this.isValidRouteFile(file)); } /** * Recursively scan directory for route files */ async scanDirectory(dirPath, files, exclusions = []) { try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); const relativePath = path.relative(process.cwd(), fullPath); // Check if this path should be excluded const shouldExclude = exclusions.some(exclusion => { const pattern = exclusion .replace(/\*\*/g, '.*') .replace(/\*/g, '[^/]*') .replace(/\./g, '\\.'); const regex = new RegExp(pattern); return regex.test(relativePath.replace(/\\/g, '/')); }); if (shouldExclude) { continue; } if (entry.isDirectory()) { // Skip common build and dependency directories const skipDirs = ['node_modules', '.next', 'dist', 'build', 'out', 'coverage', '.git', '.vscode', '.idea', '.cache']; if (!skipDirs.includes(entry.name)) { await this.scanDirectory(fullPath, files, exclusions); } } else if (entry.isFile()) { // Check if file matches route patterns if (this.matchesRoutePattern(relativePath)) { files.push(fullPath); } } } } catch (error) { // Skip directories that can't be read } } /** * Check if file path matches route patterns */ matchesRoutePattern(filePath) { const normalizedPath = filePath.replace(/\\/g, '/'); // Normalize for Windows return this.routePatterns.some(pattern => { const regexPattern = pattern .replace(/\*\*/g, '.*') // Convert glob to regex .replace(/\*/g, '[^/]*') .replace(/\./g, '\\.'); const regex = new RegExp(`^${regexPattern}$`); return regex.test(normalizedPath); }); } /** * Validate route file for transformation */ async isValidRouteFile(filePath) { try { const content = await fs.readFile(filePath, 'utf8'); // Skip files that already have type-safe routing const hasInterface = /interface\s+\w+Props/.test(content); const hasTypeSafeProps = content.includes('params:') && content.includes('searchParams:'); const hasExportDefault = content.includes('export default'); return hasExportDefault && !hasInterface && !hasTypeSafeProps; } catch (error) { console.warn(`[WARNING] Failed to validate file ${filePath}: ${error.message}`); return false; } } /** * Generate overall categorization across all migration features */ generateOverallCategorization(results) { const overallStats = { 'Successfully migrated': { count: 0, percentage: '0.0', description: 'Files that were successfully updated for Next.js 15.5 compatibility' }, 'Skipped (no migration needed)': { count: 0, percentage: '0.0', description: 'Files that don\'t require Next.js 15.5 specific changes' }, 'Skipped (already compatible)': { count: 0, percentage: '0.0', description: 'Files that already have Next.js 15.5 features implemented' }, 'Skipped (not applicable)': { count: 0, percentage: '0.0', description: 'Files that are not relevant for Next.js 15.5 migration (configs, assets, etc.)' }, 'Skipped (third-party)': { count: 0, percentage: '0.0', description: 'Third-party files that should not be modified' }, 'Failed to process': { count: 0, percentage: '0.0', description: 'Files that encountered errors during processing' } }; let totalFiles = 0; // Aggregate stats from all features for (const result of Object.values(results)) { if (result.report && result.report.categorized) { for (const [category, data] of Object.entries(result.report.categorized)) { if (overallStats[category]) { overallStats[category].count += data.count; } totalFiles += data.count; } } } // Calculate percentages if (totalFiles > 0) { for (const category of Object.keys(overallStats)) { overallStats[category].percentage = ((overallStats[category].count / totalFiles) * 100).toFixed(1); } } return overallStats; } /** * Determine why a file is being skipped */ async determineSkipReason(filePath) { try { const content = await fs.readFile(filePath, 'utf8'); const fileName = path.basename(filePath); const fileExt = path.extname(filePath); // Check if it's a third-party file if (filePath.includes('node_modules/') || filePath.includes('.next/') || filePath.includes('dist/')) { return 'third-party'; } // Check if it's not a route file if (!fileName.match(/^(page|layout|loading|error|not-found)\.(tsx|ts|jsx|js)$/)) { return 'not-applicable'; } // Check if already has type-safe routing const hasInterface = /interface\s+\w+Props/.test(content); const hasTypeSafeProps = content.includes('params:') && content.includes('searchParams:'); if (hasInterface || hasTypeSafeProps) { return 'already-compatible'; } // Check if no export default if (!content.includes('export default')) { return 'no-migration-needed'; } // Check if it's a configuration file if (fileName.match(/\.(config|rc|json)$/)) { return 'not-applicable'; } // Check if it's an asset file if (fileName.match(/\.(png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot|mp4|webm|mp3|wav|pdf|zip|tar|gz)$/)) { return 'not-applicable'; } // Default case return 'no-migration-needed'; } catch (error) { return 'error-reading-file'; } } /** * Process route files with progress reporting and comprehensive categorization */ async processRouteFiles(files, options = {}) { const results = []; const { verbose = false, dryRun = false } = options; const transformer = new TypeSafeRoutingTransformer(); for (let i = 0; i < files.length; i++) { const file = files[i]; if (verbose) { process.stdout.write(`[PROCESSING] ${path.basename(file)} (${i + 1}/${files.length})\n`); } try { // First check if file is valid for migration const isValid = await this.isValidRouteFile(file); if (!isValid) { // Determine skip reason const skipReason = await this.determineSkipReason(file); results.push({ file, success: false, skipReason, type: 'type-safe-routing' }); if (verbose) { console.log(`[SKIPPED] ${path.basename(file)}: ${skipReason}`); } continue; } const content = await fs.readFile(file, 'utf8'); const result = await this.transformRouteFile(file, content, transformer, { dryRun, verbose }); results.push(result); } catch (error) { results.push({ file, success: false, error: error.message, type: 'type-safe-routing' }); if (verbose) { console.error(`[ERROR] Failed to process ${file}: ${error.message}`); } } } return results; } /** * Transform individual route file with comprehensive validation */ async transformRouteFile(filePath, content, transformer, options = {}) { const { dryRun = false, verbose = false } = options; try { // Transform the content const transformation = transformer.transformRouteComponent(content, filePath); // Validate transformation const validation = transformer.validateTransformation(content, transformation.code, filePath); if (!validation.success) { throw new Error(`Validation failed: ${validation.errors.join(', ')}`); } // Apply changes if not in dry-run mode let backupPath = null; if (!dryRun) { try { const backupManager = new BackupManager({ backupDir: '.neurolint-backups', maxBackups: 10 }); const backupResult = await backupManager.createBackup(filePath, 'layer-5-nextjs-route-transform'); if (backupResult.success) { backupPath = backupResult.backupPath; // Write transformed content await fs.writeFile(filePath, transformation.code); if (verbose) { process.stdout.write(`[SUCCESS] Transformed ${path.basename(filePath)}\n`); process.stdout.write(`[INFO] Centralized backup: ${path.basename(backupPath)}\n`); } } else if (verbose) { process.stderr.write(`Warning: Could not create backup: ${backupResult.error}\n`); } } catch (e) { if (verbose) process.stderr.write(`Warning: Backup creation failed: ${e.message}\n`); // Fallback: still write transformed content to not block migration await fs.writeFile(filePath, transformation.code); } } return { file: filePath, success: true, changes: transformation.changes, warnings: validation.warnings, type: 'type-safe-routing', backupPath: backupPath }; } catch (error) { throw new Error(`Route file transformation failed: ${error.message}`); } } /** * Generate comprehensive migration report with proper categorization */ generateMigrationReport(results) { // Categorize results properly const successful = results.filter(r => r.success); const skipped = results.filter(r => !r.success && r.skipReason); const failed = results.filter(r => !r.success && !r.skipReason); // Calculate totals const totalChanges = successful.reduce((sum, r) => sum + (r.changes?.length || 0), 0); const totalWarnings = successful.reduce((sum, r) => sum + (r.warnings?.length || 0), 0); // Categorize skipped files by reason const skippedByReason = {}; skipped.forEach(r => { const reason = r.skipReason; if (!skippedByReason[reason]) { skippedByReason[reason] = []; } skippedByReason[reason].push(r); }); // Calculate percentages const totalFiles = results.length; const successfulCount = successful.length; const skippedCount = skipped.length; const failedCount = failed.length; const summary = { totalFiles, successful: successfulCount, skipped: skippedCount, failed: failedCount, totalChanges, totalWarnings, successRate: totalFiles > 0 ? ((successfulCount / totalFiles) * 100).toFixed(1) : '0.0', skipRate: totalFiles > 0 ? ((skippedCount / totalFiles) * 100).toFixed(1) : '0.0', failureRate: totalFiles > 0 ? ((failedCount / totalFiles) * 100).toFixed(1) : '0.0' }; // Detailed breakdown const details = { successful: successful.map(r => ({ file: path.basename(r.file), changes: r.changes?.length || 0, warnings: r.warnings?.length || 0, type: r.type || 'unknown' })), skipped: Object.entries(skippedByReason).map(([reason, files]) => ({ reason, count: files.length, percentage: totalFiles > 0 ? ((files.length / totalFiles) * 100).toFixed(1) : '0.0', files: files.map(r => path.basename(r.file)) })), failed: failed.map(r => ({ file: path.basename(r.file), error: r.error, type: r.type || 'unknown' })) }; return { summary, details, // Add categorized summary for easy reporting categorized: { 'Successfully migrated': { count: successfulCount, percentage: summary.successRate, description: 'Files that were successfully updated for Next.js 15.5 compatibility' }, 'Skipped (no migration needed)': { count: skippedByReason['no-migration-needed']?.length || 0, percentage: totalFiles > 0 ? (((skippedByReason['no-migration-needed']?.length || 0) / totalFiles) * 100).toFixed(1) : '0.0', description: 'Files that don\'t require Next.js 15.5 specific changes' }, 'Skipped (already compatible)': { count: skippedByReason['already-compatible']?.length || 0, percentage: totalFiles > 0 ? (((skippedByReason['already-compatible']?.length || 0) / totalFiles) * 100).toFixed(1) : '0.0', description: 'Files that already have Next.js 15.5 features implemented' }, 'Skipped (not applicable)': { count: skippedByReason['not-applicable']?.length || 0, percentage: totalFiles > 0 ? (((skippedByReason['not-applicable']?.length || 0) / totalFiles) * 100).toFixed(1) : '0.0', description: 'Files that are not relevant for Next.js 15.5 migration (configs, assets, etc.)' }, 'Skipped (third-party)': { count: skippedByReason['third-party']?.length || 0, percentage: totalFiles > 0 ? (((skippedByReason['third-party']?.length || 0) / totalFiles) * 100).toFixed(1) : '0.0', description: 'Third-party files that should not be modified' }, 'Failed to process': { count: failedCount, percentage: summary.failureRate, description: 'Files that encountered errors during processing' } } }; } } /** * Enhanced Server Actions wrapper for Next.js 15.5 */ function enhanceServerActions(code) { const changes = []; // Pattern to match Server Actions - more robust const serverActionPattern = /'use server';\s*export\s+(?:async\s+)?function\s+(\w+)\s*\(/g; let match; while ((match = serverActionPattern.exec(code)) !== null) { const functionName = match[1]; const startIndex = match.index; // Find the function body let braceCount = 0; let inFunction = false; let functionStart = -1; let functionEnd = -1; for (let i = startIndex; i < code.length; i++) { if (code[i] === '{') { if (!inFunction) { inFunction = true; functionStart = i; } braceCount++; } else if (code[i] === '}') { braceCount--; if (inFunction && braceCount === 0) { functionEnd = i + 1; break; } } } if (functionStart !== -1 && functionEnd !== -1) { const beforeFunction = code.substring(0, functionStart); const functionBody = code.substring(functionStart + 1, functionEnd - 1); const afterFunction = code.substring(functionEnd); // Check if already has proper error handling - more intelligent detection const hasTryCatch = functionBody.includes('try {') && functionBody.includes('catch'); const hasErrorBoundary = functionBody.includes('ErrorBoundary') || functionBody.includes('error boundary'); const hasReturnError = functionBody.includes('return {') && functionBody.includes('error:'); // Only enhance if no proper error handling exists if (!hasTryCatch && !hasErrorBoundary && !hasReturnError) { // Add enhanced error handling wrapper with Next.js 15.5 patterns const enhancedBody = ` try { // Enhanced error handling for Next.js 15.5 const result = await (async () => { ${functionBody} })(); return { success: true, data: result }; } catch (error) { console.error(\`Server Action \${functionName} failed:\`, error); return { success: false, error: error.message || 'Unknown error occurred', timestamp: new Date().toISOString() }; }`; code = beforeFunction + '{' + enhancedBody + '}' + afterFunction; changes.push({ description: `Enhanced Server Action ${functionName} with Next.js 15.5 error handling`, location: { line: code.substring(0, startIndex).split('\n').length } }); } } } return { code, changes }; } /** * Enhanced Metadata API for Next.js 15.5 */ function enhanceMetadataAPI(code) { const changes = []; // Pattern to match generateMetadata function const metadataPattern = /export\s+async\s+function\s+generateMetadata\s*\(\s*\{[^}]*\}\s*\)\s*\{/g; let match; while ((match = metadataPattern.exec(code)) !== null) { const startIndex = match.index; // Find the function body let braceCount = 0; let inFunction = false; let functionStart = -1; let functionEnd = -1; for (let i = startIndex; i < code.length; i++) { if (code[i] === '{') { if (!inFunction) { inFunction = true; functionStart = i; } braceCount++; } else if (code[i] === '}') { braceCount--; if (inFunction && braceCount === 0) { functionEnd = i + 1; break; } } } if (functionStart !== -1 && functionEnd !== -1) { const functionBody = code.substring(functionStart + 1, functionEnd - 1); // Check if already has enhanced typing if (!functionBody.includes('Props') && !functionBody.includes('Record<string, string>')) { // Add enhanced typing const enhancedSignature = `export async function generateMetadata({ params }: { params: Record<string, string> }) {`; const beforeFunction = code.substring(0, startIndex); const afterFunction = code.substring(functionEnd); code = beforeFunction + enhancedSignature + '{' + functionBody + '}' + afterFunction; changes.push({ description: 'Enhanced generateMetadata with Next.js 15.5 typing', location: { line: code.substring(0, startIndex).split('\n').length } }); } } } return { code, changes }; } /** * Detect and warn about Next.js 15.5 deprecations */ function detectDeprecations(code) { const warnings = []; // Check for legacyBehavior if (code.includes('legacyBehavior')) { warnings.push({ type: 'deprecation', message: 'legacyBehavior is deprecated in Next.js 15.5. Consider using the new Link behavior.', recommendation: 'Remove legacyBehavior prop from Link components' }); } // Check for next lint usage if (code.includes('next lint')) { warnings.push({ type: 'deprecation', message: 'next lint is deprecated. Use eslint directly or configure in next.config.js', recommendation: 'Replace "next lint" with "eslint" in package.json scripts' }); } // Check for old metadata patterns if (code.includes('export const metadata =')) { warnings.push({ type: 'deprecation', message: 'export const metadata is deprecated. Use generateMetadata function instead.', recommendation: 'Convert to async generateMetadata function' }); } return warnings; } /** * Configure Turbopack for Next.js 15.5 */ function configureTurbopack(code) { const changes = []; // Pattern to match next.config.js const nextConfigPattern = /(module\.exports\s*=\s*\{[\s\S]*?\})/g; let match; while ((match = nextConfigPattern.exec(code)) !== null) { const configBlock = match[1]; // Check if Turbopack is already configured if (!configBlock.includes('turbo') && !configBlock.includes('turbopack')) { // Add Turbopack configuration const enhancedConfig = configBlock.replace( /(\})\s*$/, ` experimental: { turbo: { rules: { '*.svg': { loaders: ['@svgr/webpack'], as: '*.js' } } } } }` ); code = code.replace(configBlock, enhancedConfig); changes.push({ description: 'Added Turbopack configuration for Next.js 15.5', location: { line: code.substring(0, match.index).split('\n').length } }); } } return { code, changes }; } /** * Suggest caching optimizations for Next.js 15.5 */ function suggestCachingOptimizations(code) { const suggestions = []; // Pattern to match fetch calls without caching const fetchPattern = /fetch\s*\(\s*['"`][^'"`]+['"`]\s*(?:,\s*\{[^}]*\})?\s*\)/g; let match; while ((match = fetchPattern.exec(code)) !== null) { const fetchCall = match[0]; // Check if already has cache configuration if (!fetchCall.includes('cache:') && !fetchCall.includes('force-cache')) { suggestions.push({ type: 'caching', message: 'Consider adding cache: "force-cache" for static data fetching', location: { line: code.substring(0, match.index).split('\n').length }, recommendation: `Add cache option: fetch(url, { cache: 'force-cache' })` }); } } return suggestions; } function applyRegexFallbacks(input, filePath) { let code = input; const changes = []; // Fix corrupted nested import braces like: // import {\n import { useState } from 'react';\n } from 'react'; code = code.replace(/import\s*\{\s*\n\s*import\s*\{/g, 'import {'); // Normalize multi-line named import spacing: ensure `import { A, B } from "x";` code = code.replace(/import\s*\{([\s\S]*?)\}\s*from\s*['"]([^'"]+)['"];?/g, (m, names, src) => { const flat = names.split(/\s|,/).filter(Boolean).join(', '); changes.push({ description: 'Normalized named import formatting', location: {} }); // Use double quotes for 'react' imports to match test expectations const quote = src === 'react' ? '"' : "'"; return `import { ${flat} } from ${quote}${src}${quote};`; }); // Ensure 'use client' at very top for TSX/JSX files with useState/useEffect or interactive hooks const isReactFile = filePath && /\.(tsx?|jsx?)$/.test(filePath); const hasClientHooks = /\buse(State|Effect|Ref|Callback|Memo|Context|Reducer|ImperativeHandle|LayoutEffect|DebugValue|Id|Transition|DeferredValue|SyncExternalStore|InsertionEffect)\b/.test(code); const hasEventHandlers = /\bon[A-Z]\w+\s*=/.test(code); // onClick, onChange, etc. const needsUseClient = isReactFile && (hasClientHooks || hasEventHandlers); if (needsUseClient) { const hasUseClient = /['"]use client['"];?/.test(code); let withoutDirectives = code.replace(/^['"]use client['"];?\s*/m, ''); if (!hasUseClient || withoutDirectives !== code) { changes.push({ description: "Placed 'use client' directive at top", location: { line: 1 } }); code = `'use client';\n` + withoutDirectives; } } // Fix misplaced 'use client' directive - ensure it's at the very beginning if (/['"]use client['"];?/.test(code)) { const withoutDirectives = code.replace(/^['"]use client['"];?\s*/m, ''); if (withoutDirectives !== code) { changes.push({ description: "Fixed 'use client' directive placement", location: { line: 1 } }); code = `'use client';\n` + withoutDirectives; } } // Add missing React import when hooks are used and no import React present if (/\buse(State|Effect)\b/.test(code) && !/import\s+React\s+from\s+['"]react['"]/.test(code)) { // Place after use client if present, else at top const insertAfter =