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

825 lines (696 loc) 27.1 kB
#!/usr/bin/env node /** * NeuroLint - Licensed under Apache License 2.0 * Copyright (c) 2025 NeuroLint * http://www.apache.org/licenses/LICENSE-2.0 */ /** * Next.js 16 Migration Script * * Handles comprehensive migration to Next.js 16 including: * - middleware.ts → proxy.ts rename * - experimental.ppr → Cache Components migration * - Function export update (middleware → proxy) * - Async params/searchParams updates * - New caching APIs (updateTag, refresh, cacheLife) */ const fs = require('fs').promises; const path = require('path'); const BackupManager = require('../backup-manager'); const ora = require('../simple-ora'); class NextJS16Migrator { constructor(options = {}) { this.verbose = options.verbose || false; this.dryRun = options.dryRun || false; this.format = options.format || 'text'; this.changes = []; this.backupManager = new BackupManager({ backupDir: '.neurolint-backups', maxBackups: 10, verbose: this.verbose }); this.backupSession = []; this.createdFiles = []; this.renamedFiles = []; } log(message, level = 'info') { if (this.format === 'json') return; if (this.verbose || level === 'error' || level === 'warning') { const prefix = level === 'error' ? '[ERROR]' : level === 'warning' ? '[WARNING]' : level === 'success' ? '[SUCCESS]' : '[INFO]'; console.log(`${prefix} ${message}`); } } outputJSON(data) { console.log(JSON.stringify(data, null, 2)); } formatError(error) { if (this.verbose) { return error.stack || error.message; } return error.message; } /** * Collect all files that will be modified by the migration */ async collectFilesToModify(projectPath) { const filesToBackup = []; const possibleMiddlewarePaths = [ path.join(projectPath, 'middleware.ts'), path.join(projectPath, 'middleware.js'), path.join(projectPath, 'src', 'middleware.ts'), path.join(projectPath, 'src', 'middleware.js') ]; for (const middlewarePath of possibleMiddlewarePaths) { const exists = await fs.access(middlewarePath).then(() => true).catch(() => false); if (exists) { filesToBackup.push(middlewarePath); break; } } const configPaths = [ path.join(projectPath, 'next.config.js'), path.join(projectPath, 'next.config.mjs'), path.join(projectPath, 'next.config.ts') ]; for (const configPath of configPaths) { const exists = await fs.access(configPath).then(() => true).catch(() => false); if (exists) { filesToBackup.push(configPath); break; } } const sourceFiles = await this.findSourceFiles(projectPath); for (const filePath of sourceFiles) { try { const content = await fs.readFile(filePath, 'utf8'); if (this.fileNeedsModification(content)) { filesToBackup.push(filePath); } } catch { // Skip files that can't be read } } return [...new Set(filesToBackup)]; } /** * Check if a file needs modification based on content analysis */ fileNeedsModification(content) { const needsUseCacheDirective = this.shouldAddUseCacheDirective(content) && !content.includes("'use cache'") && !content.includes('"use cache"'); const hasUnstableCache = content.includes('unstable_cache'); const hasRevalidateTag = content.includes('revalidateTag(') && !content.includes('cacheLife'); const hasManualInvalidation = content.includes('fetch') && content.includes('POST') && !content.includes('updateTag') && content.match(/(?:mutate|invalidate|refresh).*cache/i); const hasSyncParams = content.match(/\(\s*{\s*params\s*(?:,\s*searchParams\s*)?\}\s*\)/); const hasSyncCookies = content.match(/const\s+\w+\s*=\s*cookies\(\)/) && !content.includes('await cookies()'); const hasSyncHeaders = content.match(/const\s+\w+\s*=\s*headers\(\)/) && !content.includes('await headers()'); return needsUseCacheDirective || hasUnstableCache || hasRevalidateTag || hasManualInvalidation || hasSyncParams || hasSyncCookies || hasSyncHeaders; } /** * Create backups for all files that will be modified */ async createBackupSession(filesToBackup) { this.backupSession = []; for (const filePath of filesToBackup) { try { const backupResult = await this.backupManager.createBackup(filePath, 'migrate-nextjs-16'); if (backupResult.success) { this.backupSession.push({ originalPath: filePath, backupPath: backupResult.backupPath, timestamp: backupResult.timestamp }); this.log(`Backed up: ${path.relative(process.cwd(), filePath)}`, 'info'); } } catch (error) { this.log(`Warning: Could not backup ${filePath}: ${error.message}`, 'warning'); } } return this.backupSession; } /** * Restore all files from the backup session and clean up artifacts */ async rollbackAll() { let restored = 0; let cleaned = 0; const errors = []; for (const createdFile of this.createdFiles) { try { await fs.unlink(createdFile); cleaned++; this.log(`Deleted created file: ${path.relative(process.cwd(), createdFile)}`, 'info'); } catch (error) { if (error.code !== 'ENOENT') { errors.push({ file: createdFile, error: `Failed to delete: ${error.message}` }); } } } for (const rename of this.renamedFiles) { try { const renamedExists = await fs.access(rename.to).then(() => true).catch(() => false); if (renamedExists) { await fs.rename(rename.to, rename.from); cleaned++; this.log(`Restored rename: ${path.relative(process.cwd(), rename.to)}${path.relative(process.cwd(), rename.from)}`, 'info'); } } catch (error) { errors.push({ file: rename.from, error: `Failed to undo rename: ${error.message}` }); } } for (const backup of this.backupSession) { try { const result = await this.backupManager.restoreFromBackup(backup.backupPath, backup.originalPath); if (result.success) { restored++; this.log(`Restored: ${path.relative(process.cwd(), backup.originalPath)}`, 'info'); } else { errors.push({ file: backup.originalPath, error: result.error }); } } catch (error) { errors.push({ file: backup.originalPath, error: error.message }); } } return { success: errors.length === 0, restored, cleaned, total: this.backupSession.length + this.createdFiles.length + this.renamedFiles.length, errors }; } /** * Main migration entry point */ async migrate(projectPath = process.cwd()) { this.changes = []; this.backupSession = []; this.createdFiles = []; this.renamedFiles = []; const spinner = this.format !== 'json' ? ora('Starting Next.js 16 migration...').start() : null; try { if (this.dryRun) { spinner?.succeed('Running in dry-run mode - no files will be modified'); } spinner?.succeed('Analyzing project...'); const collectSpinner = this.format !== 'json' ? ora('Collecting files to modify...').start() : null; const filesToModify = await this.collectFilesToModify(projectPath); collectSpinner?.succeed(`Found ${filesToModify.length} files to process`); if (!this.dryRun && filesToModify.length > 0) { const backupSpinner = this.format !== 'json' ? ora('Creating backup session...').start() : null; await this.backupManager.initialize(); await this.createBackupSession(filesToModify); backupSpinner?.succeed(`Created ${this.backupSession.length} backups`); } const migrateSpinner = this.format !== 'json' ? ora('Running migrations...').start() : null; await this.migrateMiddlewareToProxy(projectPath); await this.migratePPRToCacheComponents(projectPath); await this.updateNextConfig(projectPath); await this.migrateCachingAPIs(projectPath); await this.updateAsyncAPIs(projectPath); migrateSpinner?.succeed(`Migration complete! ${this.changes.length} changes made.`); const result = { success: true, dryRun: this.dryRun, changes: this.changes, summary: this.generateSummary(), backups: this.backupSession.length }; if (this.format === 'json') { this.outputJSON(result); } return result; } catch (error) { spinner?.fail(`Migration failed: ${this.formatError(error)}`); const hasChangesToRollback = !this.dryRun && (this.backupSession.length > 0 || this.createdFiles.length > 0 || this.renamedFiles.length > 0); if (hasChangesToRollback) { const rollbackSpinner = this.format !== 'json' ? ora('Rolling back changes...').start() : null; const rollbackResult = await this.rollbackAll(); if (rollbackResult.success) { rollbackSpinner?.succeed(`Rolled back ${rollbackResult.restored} files, cleaned ${rollbackResult.cleaned} artifacts`); } else { rollbackSpinner?.fail(`Rollback completed with errors`); if (rollbackResult.errors.length > 0) { for (const err of rollbackResult.errors) { this.log(`Failed to restore ${err.file}: ${err.error}`, 'error'); } } } } const errorResult = { success: false, error: this.formatError(error), dryRun: this.dryRun, rolledBack: this.backupSession.length > 0 }; if (this.format === 'json') { this.outputJSON(errorResult); } throw error; } } /** * Migrate middleware.ts to proxy.ts */ async migrateMiddlewareToProxy(projectPath) { this.log('Checking for middleware.ts...', 'info'); const possiblePaths = [ path.join(projectPath, 'middleware.ts'), path.join(projectPath, 'middleware.js'), path.join(projectPath, 'src', 'middleware.ts'), path.join(projectPath, 'src', 'middleware.js') ]; for (const middlewarePath of possiblePaths) { try { const exists = await fs.access(middlewarePath).then(() => true).catch(() => false); if (!exists) continue; const content = await fs.readFile(middlewarePath, 'utf8'); const ext = path.extname(middlewarePath); const dir = path.dirname(middlewarePath); const proxyPath = path.join(dir, `proxy${ext}`); let newContent = content; newContent = newContent.replace( /export\s+(async\s+)?function\s+middleware\s*\(/g, 'export $1function proxy(' ); newContent = newContent.replace( /export\s+default\s+middleware/g, 'export default proxy' ); if (!newContent.includes('export const runtime')) { const runtimeDeclaration = `\n// Next.js 16 requires explicit runtime declaration\nexport const runtime = "nodejs";\n\n`; newContent = runtimeDeclaration + newContent; } const migrationComment = `/**\n * Migrated from middleware.ts to proxy.ts for Next.js 16\n * The proxy.ts file makes the app's network boundary explicit\n * and runs on the Node.js runtime.\n */\n\n`; newContent = migrationComment + newContent; if (!this.dryRun) { await fs.writeFile(proxyPath, newContent, 'utf8'); this.createdFiles.push(proxyPath); this.log(`Created ${proxyPath}`, 'success'); const backupPath = `${middlewarePath}.backup`; await fs.rename(middlewarePath, backupPath); this.renamedFiles.push({ from: middlewarePath, to: backupPath }); this.log(`Backed up original to ${backupPath}`, 'info'); } this.changes.push({ type: 'middleware_to_proxy', from: middlewarePath, to: proxyPath, description: 'Migrated middleware.ts to proxy.ts for Next.js 16' }); this.log('Successfully migrated middleware to proxy', 'success'); return; } catch (error) { continue; } } this.log('No middleware.ts file found (this is OK if not using middleware)', 'info'); } /** * Migrate experimental.ppr to Cache Components */ async migratePPRToCacheComponents(projectPath) { this.log('Checking for experimental.ppr configuration...', 'info'); const configPaths = [ path.join(projectPath, 'next.config.js'), path.join(projectPath, 'next.config.mjs'), path.join(projectPath, 'next.config.ts') ]; for (const configPath of configPaths) { try { const exists = await fs.access(configPath).then(() => true).catch(() => false); if (!exists) continue; const content = await fs.readFile(configPath, 'utf8'); if (content.includes('experimental.ppr') || content.match(/experimental\s*:\s*{[^}]*ppr\s*:/)) { let newContent = content; newContent = newContent.replace( /experimental:\s*{\s*ppr:\s*['"]?(?:true|incremental)['"]?\s*,?\s*}/g, 'experimental: {}' ); newContent = newContent.replace( /ppr:\s*['"]?(?:true|incremental)['"]?\s*,?/g, '' ); newContent = newContent.replace( /experimental:\s*{\s*}/g, '' ); const cacheComponentsConfig = ` // Next.js 16: Cache Components replace experimental.ppr // Use 'use cache' directive in components for explicit caching experimental: { // Cache Components are now the default caching model dynamicIO: true, // Enable dynamic data fetching improvements },`; if (newContent.includes('module.exports')) { newContent = newContent.replace( /(const\s+nextConfig\s*=\s*{)/, `$1${cacheComponentsConfig}` ); } else if (newContent.includes('export default')) { newContent = newContent.replace( /(export\s+default\s+{)/, `$1${cacheComponentsConfig}` ); } const migrationComment = `/**\n * Next.js 16 Migration:\n * - Removed experimental.ppr (deprecated)\n * - Cache Components are now the default\n * - Use 'use cache' directive in components for explicit caching\n * - See: https://nextjs.org/docs/app/api-reference/directives/use-cache\n */\n\n`; newContent = migrationComment + newContent; if (!this.dryRun) { await fs.writeFile(configPath, newContent, 'utf8'); this.log(`Updated ${configPath}`, 'success'); } this.changes.push({ type: 'ppr_to_cache_components', file: configPath, description: 'Migrated experimental.ppr to Cache Components model' }); this.log('Successfully migrated PPR to Cache Components', 'success'); return; } } catch (error) { continue; } } this.log('No experimental.ppr configuration found', 'info'); } /** * Update next.config for Next.js 16 compatibility */ async updateNextConfig(projectPath) { this.log('Updating next.config for Next.js 16...', 'info'); const configPaths = [ path.join(projectPath, 'next.config.js'), path.join(projectPath, 'next.config.mjs'), path.join(projectPath, 'next.config.ts') ]; for (const configPath of configPaths) { try { const exists = await fs.access(configPath).then(() => true).catch(() => false); if (!exists) continue; const content = await fs.readFile(configPath, 'utf8'); let newContent = content; let modified = false; if (!newContent.includes('turbopackFileSystemCacheForDev') && !newContent.includes('experimental')) { const turbopackConfig = ` experimental: { // Enable Turbopack filesystem caching for faster rebuilds turbopackFileSystemCacheForDev: true, },`; newContent = newContent.replace( /(const\s+nextConfig\s*=\s*{)/, `$1${turbopackConfig}` ); modified = true; } if (newContent.includes('images: {') && newContent.includes('domains:')) { newContent = newContent.replace( /domains:\s*\[.*?\],?\s*/gs, '// domains is deprecated in Next.js 16. Use remotePatterns instead.\n ' ); modified = true; } if (modified && !this.dryRun) { await fs.writeFile(configPath, newContent, 'utf8'); this.log(`Updated ${configPath} for Next.js 16 compatibility`, 'success'); this.changes.push({ type: 'config_update', file: configPath, description: 'Updated next.config for Next.js 16 compatibility' }); } return; } catch (error) { continue; } } } /** * Migrate old caching APIs to new Next.js 16 APIs */ async migrateCachingAPIs(projectPath) { this.log('Scanning for old caching API usage...', 'info'); const files = await this.findSourceFiles(projectPath); for (const filePath of files) { try { const content = await fs.readFile(filePath, 'utf8'); let newContent = content; let modified = false; const shouldAddUseCache = this.shouldAddUseCacheDirective(content); if (shouldAddUseCache && !content.includes("'use cache'") && !content.includes('"use cache"')) { newContent = this.addUseCacheDirective(newContent); modified = true; this.log(`Added 'use cache' directive to ${path.basename(filePath)}`, 'success'); } if (content.includes('unstable_cache')) { newContent = newContent.replace( /unstable_cache\(/g, '/* MIGRATED: Use "use cache" directive instead */ unstable_cache(' ); modified = true; } if (content.includes('revalidateTag(') && !content.includes('cacheLife')) { const cacheLifeComment = ` // Next.js 16: Consider using cacheLife() in your cache configuration // Example: // 'use cache'; // export const revalidate = cacheLife('hours'); // // Then call: revalidateTag('your-tag') `; if (!content.includes('Consider using cacheLife')) { newContent = cacheLifeComment + newContent; modified = true; } } if (content.includes('fetch') && content.includes('POST') && !content.includes('updateTag')) { const hasManualInvalidation = content.match(/(?:mutate|invalidate|refresh).*cache/i); if (hasManualInvalidation) { const updateTagComment = ` // Next.js 16: Consider using updateTag() for read-your-writes consistency // import { updateTag } from 'next/cache' // updateTag('${path.basename(filePath, path.extname(filePath))}') `; if (!content.includes('Consider using updateTag')) { newContent = updateTagComment + newContent; modified = true; } } } if (modified && !this.dryRun) { await fs.writeFile(filePath, newContent, 'utf8'); this.changes.push({ type: 'caching_api_migration', file: filePath, description: 'Updated caching APIs for Next.js 16' }); } } catch (error) { continue; } } if (this.changes.filter(c => c.type === 'caching_api_migration').length > 0) { this.log('Migrated caching APIs', 'success'); } } /** * Check if component should have 'use cache' directive */ shouldAddUseCacheDirective(content) { if (content.includes("'use client'") || content.includes('"use client"')) { return false; } const hasFetch = content.includes('await fetch') || content.includes('fetch('); const hasDatabase = content.match(/(?:prisma|db|database)\./i); const hasAsyncComponent = content.match(/export\s+(?:default\s+)?async\s+function/); return hasFetch || hasDatabase || hasAsyncComponent; } /** * Add 'use cache' directive to file */ addUseCacheDirective(content) { const lines = content.split('\n'); let insertIndex = 0; for (let i = 0; i < lines.length; i++) { if (lines[i].trim().startsWith('import ') || lines[i].trim().startsWith('const ') || lines[i].trim().startsWith('type ') || lines[i].trim() === '') { insertIndex = i + 1; } else { break; } } lines.splice(insertIndex, 0, '', "'use cache';", ''); return lines.join('\n'); } /** * Update async request APIs (params, searchParams, cookies, headers) */ async updateAsyncAPIs(projectPath) { this.log('Updating async request APIs...', 'info'); const files = await this.findSourceFiles(projectPath); for (const filePath of files) { try { const content = await fs.readFile(filePath, 'utf8'); let newContent = content; let modified = false; if (content.match(/\(\s*{\s*params\s*(?:,\s*searchParams\s*)?\}\s*\)/)) { newContent = this.convertSyncParamsToAsync(newContent); modified = true; this.log(`Converted sync params to async in ${path.basename(filePath)}`, 'success'); } const cookiesMatch = content.match(/const\s+(\w+)\s*=\s*cookies\(\)/g); const headersMatch = content.match(/const\s+(\w+)\s*=\s*headers\(\)/g); if ((cookiesMatch && !content.includes('await cookies()')) || (headersMatch && !content.includes('await headers()'))) { if (cookiesMatch && !content.includes('await cookies()')) { newContent = newContent.replace( /const\s+(\w+)\s*=\s*cookies\(\)/g, 'const $1 = await cookies()' ); modified = true; this.log(`Added await to cookies() in ${path.basename(filePath)}`, 'success'); } if (headersMatch && !content.includes('await headers()')) { newContent = newContent.replace( /const\s+(\w+)\s*=\s*headers\(\)/g, 'const $1 = await headers()' ); modified = true; this.log(`Added await to headers() in ${path.basename(filePath)}`, 'success'); } if (!newContent.includes('cookies() and headers() are now async')) { const lines = newContent.split('\n'); let insertIndex = 0; for (let i = 0; i < lines.length; i++) { if (lines[i].trim().startsWith('import ') || lines[i].trim().startsWith('type ') || lines[i].trim() === '' || lines[i].trim().startsWith("'use cache'") || lines[i].trim().startsWith('"use cache"')) { insertIndex = i + 1; } else { break; } } lines.splice(insertIndex, 0, '', '// Next.js 16: cookies() and headers() are now async', ''); newContent = lines.join('\n'); } } if (modified && newContent.includes('await') && !content.match(/export\s+(?:default\s+)?async\s+function/)) { newContent = this.ensureFunctionIsAsync(newContent); } if (modified && !this.dryRun) { await fs.writeFile(filePath, newContent, 'utf8'); this.changes.push({ type: 'async_api_update', file: filePath, description: 'Converted params/searchParams/cookies/headers to async APIs' }); } } catch (error) { continue; } } } /** * Convert sync params destructuring to async */ convertSyncParamsToAsync(content) { let newContent = content; newContent = newContent.replace( /(export\s+default\s+)(function\s+(\w+))\s*\(\s*{\s*params\s*(,\s*searchParams\s*)?\}\s*\)/g, '$1async $2(props)' ); newContent = newContent.replace( /const\s+{([^}]+)}\s*=\s*params(?!\.)/g, 'const {$1} = await props.params' ); newContent = newContent.replace( /const\s+{([^}]+)}\s*=\s*searchParams(?!\.)/g, 'const {$1} = await props.searchParams' ); if (!newContent.includes('params and searchParams are now async')) { const lines = newContent.split('\n'); let insertIndex = 0; for (let i = 0; i < lines.length; i++) { if (lines[i].trim().startsWith('import ') || lines[i].trim().startsWith('type ') || lines[i].trim() === '') { insertIndex = i + 1; } else { break; } } lines.splice(insertIndex, 0, '', '// Next.js 16: params and searchParams are now async', ''); newContent = lines.join('\n'); } return newContent; } /** * Ensure function is async */ ensureFunctionIsAsync(content) { let newContent = content.replace( /export\s+default\s+function(?!\s+async)/g, 'export default async function' ); newContent = newContent.replace( /export\s+function\s+(?!async)/g, 'export async function ' ); return newContent; } /** * Find all source files in the project */ async findSourceFiles(projectPath) { const files = []; const extensions = ['.ts', '.tsx', '.js', '.jsx']; const ignoreDirs = ['node_modules', '.next', 'dist', 'build', '.git']; async function scan(dir) { try { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { if (!ignoreDirs.includes(entry.name)) { await scan(fullPath); } } else if (entry.isFile()) { const ext = path.extname(entry.name); if (extensions.includes(ext)) { files.push(fullPath); } } } } catch (error) { // Skip inaccessible directories } } await scan(projectPath); return files; } /** * Generate migration summary */ generateSummary() { const summary = { total: this.changes.length, byType: {} }; this.changes.forEach(change => { summary.byType[change.type] = (summary.byType[change.type] || 0) + 1; }); return summary; } } module.exports = NextJS16Migrator;