@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,036 lines (898 loc) • 40.1 kB
JavaScript
#!/usr/bin/env node
/**
* NeuroLint - Licensed under Apache License 2.0
* Copyright (c) 2025 NeuroLint
* http://www.apache.org/licenses/LICENSE-2.0
*/
const fs = require('fs').promises;
const path = require('path');
const BackupManager = require('../backup-manager');
const ASTTransformer = require('../ast-transformer');
const t = require('@babel/types');
const parser = require('@babel/parser');
/**
* Layer 4: Hydration and SSR Fixes (AST-based)
* - Add window/document guards using proper code parsing
* - Add mounted state for theme providers
* - Fix hydration mismatches in useEffect
* - Handles nested parentheses correctly via AST
*/
/**
* Validate syntax using Babel parser
* Returns true if code is valid JavaScript/TypeScript, false otherwise
*/
function validateSyntax(code) {
try {
parser.parse(code, {
sourceType: 'module',
plugins: ['typescript', 'jsx'],
allowImportExportEverywhere: true,
strictMode: false
});
return true;
} catch (error) {
return false;
}
}
/**
* Regex-based fallback for hydration guards
* Used when AST transformation fails
*/
function applyRegexHydrationFallbacks(input) {
let code = input;
const changes = [];
// Simple regex fallback for localStorage access
const localStoragePattern = /localStorage\.(getItem|setItem|removeItem|clear)\s*\(/g;
let match;
let lsCount = 0;
while ((match = localStoragePattern.exec(input)) !== null) {
lsCount++;
}
if (lsCount > 0) {
// Wrap localStorage calls with typeof window check (simple fallback)
code = code.replace(
/localStorage\.(getItem|setItem|removeItem|clear)\s*\([^)]*\)/g,
(match) => `(typeof window !== "undefined" ? ${match} : null)`
);
changes.push({
type: 'storage-guard-fallback',
description: `Added SSR guard for ${lsCount} localStorage calls (regex fallback)`,
location: null
});
}
// Simple regex fallback for sessionStorage access
const sessionStoragePattern = /sessionStorage\.(getItem|setItem|removeItem|clear)\s*\(/g;
let ssCount = 0;
while ((match = sessionStoragePattern.exec(input)) !== null) {
ssCount++;
}
if (ssCount > 0) {
code = code.replace(
/sessionStorage\.(getItem|setItem|removeItem|clear)\s*\([^)]*\)/g,
(match) => `(typeof window !== "undefined" ? ${match} : null)`
);
changes.push({
type: 'storage-guard-fallback',
description: `Added SSR guard for ${ssCount} sessionStorage calls (regex fallback)`,
location: null
});
}
// Simple regex fallback for window.matchMedia, window.location, etc.
const windowApiPattern = /window\.(matchMedia|location|navigator|innerWidth|innerHeight)\b/g;
let windowCount = 0;
while ((match = windowApiPattern.exec(input)) !== null) {
windowCount++;
}
if (windowCount > 0) {
code = code.replace(
/window\.(matchMedia|location|navigator|innerWidth|innerHeight)([^;{]*)/g,
(match, prop, rest) => `(typeof window !== "undefined" ? window.${prop}${rest} : null)`
);
changes.push({
type: 'window-guard-fallback',
description: `Added SSR guard for ${windowCount} window API calls (regex fallback)`,
location: null
});
}
// Simple regex fallback for document access
const documentApiPattern = /document\.(querySelector|getElementById|body|documentElement)\b/g;
let docCount = 0;
while ((match = documentApiPattern.exec(input)) !== null) {
docCount++;
}
if (docCount > 0) {
code = code.replace(
/document\.(querySelector|getElementById|querySelectorAll|getElementsByClassName|getElementsByTagName|body|documentElement)([^;{]*)/g,
(match, method, rest) => `(typeof document !== "undefined" ? document.${method}${rest} : null)`
);
changes.push({
type: 'document-guard-fallback',
description: `Added SSR guard for ${docCount} document API calls (regex fallback)`,
location: null
});
}
return { code, changes };
}
async function isRegularFile(filePath) {
try {
const stat = await fs.stat(filePath);
return stat.isFile();
} catch {
return false;
}
}
/**
* Check if a node is already wrapped in SSR guard
* Only accepts guards with !== operator and "undefined" literal
*/
function isAlreadyGuarded(path, guardType = 'window') {
let parent = path.parentPath;
// Traverse up to find conditional expression or if statement
while (parent) {
// Check for ternary operator: typeof window !== "undefined" ? ... : null
if (t.isConditionalExpression(parent.node)) {
const test = parent.node.test;
if (t.isBinaryExpression(test) &&
test.operator === '!==' &&
t.isUnaryExpression(test.left) &&
test.left.operator === 'typeof' &&
t.isStringLiteral(test.right, { value: 'undefined' })) {
const arg = test.left.argument;
if (t.isIdentifier(arg)) {
if (guardType === 'window' && arg.name === 'window') return true;
if (guardType === 'document' && arg.name === 'document') return true;
}
}
}
// Check for if statement: if (typeof window !== "undefined") { ... }
if (t.isIfStatement(parent.node)) {
const test = parent.node.test;
if (t.isBinaryExpression(test) &&
test.operator === '!==' &&
t.isUnaryExpression(test.left) &&
test.left.operator === 'typeof' &&
t.isStringLiteral(test.right, { value: 'undefined' })) {
const arg = test.left.argument;
if (t.isIdentifier(arg)) {
if (guardType === 'window' && arg.name === 'window') return true;
if (guardType === 'document' && arg.name === 'document') return true;
}
}
}
parent = parent.parentPath;
}
return false;
}
/**
* Wrap an expression with SSR guard
*/
function wrapWithSSRGuard(expression, guardType = 'window') {
// Create: typeof window !== "undefined" ? expression : null
return t.conditionalExpression(
t.binaryExpression(
'!==',
t.unaryExpression('typeof', t.identifier(guardType)),
t.stringLiteral('undefined')
),
expression,
t.nullLiteral()
);
}
/**
* Extract the root global identifier from a member expression tree
* Walks the full tree regardless of depth
* Returns 'window', 'document', or null
*/
function getRootGlobalName(node) {
if (t.isIdentifier(node)) {
return (node.name === 'window' || node.name === 'document') ? node.name : null;
}
if (t.isMemberExpression(node)) {
return getRootGlobalName(node.object);
}
return null;
}
/**
* Main AST-based hydration transform
*/
async function transform(code, options = {}) {
const { dryRun = false, verbose = false, filePath = process.cwd() } = options;
const results = [];
let changeCount = 0;
let updatedCode = code;
try {
// Create centralized backup
const existsAsFile = await isRegularFile(filePath);
if (existsAsFile && !dryRun) {
try {
const backupManager = new BackupManager({
backupDir: '.neurolint-backups',
maxBackups: 10
});
const backupResult = await backupManager.createBackup(filePath, 'layer-4-hydration');
if (backupResult.success) {
results.push({ type: 'backup', file: filePath, success: true, backupPath: backupResult.backupPath });
if (verbose) process.stdout.write(`Created centralized backup: ${path.basename(backupResult.backupPath)}\n`);
} else {
if (verbose) process.stderr.write(`Warning: Could not create backup: ${backupResult.error}\n`);
}
} catch (error) {
if (verbose) process.stderr.write(`Warning: Backup creation failed: ${error.message}\n`);
}
}
// Check for empty input
if (!code.trim()) {
results.push({ type: 'empty', file: filePath, success: false, error: 'No changes were made' });
return {
success: false,
code,
originalCode: code,
changeCount: 0,
results
};
}
// File type check
const fileExt = path.extname(filePath).slice(1);
if (!['ts', 'tsx', 'js', 'jsx'].includes(fileExt)) {
return {
success: true,
code,
originalCode: code,
changeCount: 0,
results
};
}
// Create AST transformer
const transformer = new ASTTransformer();
const changes = [];
// Define AST visitors for Layer 4 hydration fixes
const visitors = {
// Handle localStorage, sessionStorage calls
MemberExpression(path) {
// Check for localStorage/sessionStorage method calls
if (t.isIdentifier(path.node.object)) {
const objName = path.node.object.name;
const propName = path.node.property.name;
// localStorage.getItem, setItem, removeItem, etc.
if ((objName === 'localStorage' || objName === 'sessionStorage') &&
['getItem', 'setItem', 'removeItem', 'clear'].includes(propName)) {
// Only wrap if it's part of a call expression
if (t.isCallExpression(path.parent) && path.parent.callee === path.node) {
// Check if already guarded
if (!isAlreadyGuarded(path.parentPath, 'window')) {
// Wrap the entire CallExpression
const callExpr = path.parentPath.node;
const guarded = wrapWithSSRGuard(callExpr, 'window');
path.parentPath.replaceWith(guarded);
changes.push({
type: 'storage-guard',
description: `Added SSR guard for ${objName}.${propName}()`,
location: path.node.loc
});
changeCount++;
if (verbose) {
process.stdout.write(`[INFO] Added SSR guard for ${objName}.${propName}()\n`);
}
}
}
}
// window.matchMedia, window.location, etc.
if (objName === 'window' &&
['matchMedia', 'location', 'navigator', 'innerWidth', 'innerHeight', 'scrollY', 'scrollX'].includes(propName)) {
// Don't guard addEventListener/removeEventListener here (handled separately)
if (['addEventListener', 'removeEventListener'].includes(propName)) {
return;
}
if (!isAlreadyGuarded(path, 'window')) {
// Find the top of the member/call chain
let topPath = path;
while (topPath.parentPath) {
const parent = topPath.parent;
// Continue climbing if parent is MemberExpression and we're the object
if (t.isMemberExpression(parent) && parent.object === topPath.node) {
topPath = topPath.parentPath;
continue;
}
// Continue climbing if parent is CallExpression and we're the callee
if (t.isCallExpression(parent) && parent.callee === topPath.node) {
topPath = topPath.parentPath;
continue;
}
// Otherwise stop climbing
break;
}
// Check if this chain is the LHS of an assignment or part of an update expression
let currentPath = topPath;
let needsStatementGuard = false;
// Check for assignment LHS using parentKey (not object identity)
if (currentPath.parentPath && currentPath.parentPath.isAssignmentExpression() &&
currentPath.key === 'left') {
needsStatementGuard = true;
}
// Check for update expression (++, --) using parentKey
if (currentPath.parentPath && currentPath.parentPath.isUpdateExpression() &&
currentPath.key === 'argument') {
needsStatementGuard = true;
}
if (needsStatementGuard) {
// Find the enclosing statement
let statementPath = currentPath;
while (statementPath && !t.isStatement(statementPath.node)) {
statementPath = statementPath.parentPath;
}
if (statementPath && t.isExpressionStatement(statementPath.node)) {
// Preserve comments from original
const originalLeading = statementPath.node.leadingComments;
const originalTrailing = statementPath.node.trailingComments;
// Create a fresh expression statement (not cloned) to avoid comment issues
const newStatement = t.expressionStatement(
t.cloneNode(statementPath.node.expression, /*deep*/ true, /*withoutLoc*/ false)
);
// Preserve trailing comments intelligently
if (originalTrailing && originalTrailing.length > 0) {
if (statementPath.node.loc) {
// With location data, preserve same-line comments
const statementEndLine = statementPath.node.loc.end.line;
const sameLineComments = originalTrailing.filter(comment =>
comment.loc && comment.loc.start.line === statementEndLine
);
if (sameLineComments.length > 0) {
newStatement.trailingComments = sameLineComments;
}
} else {
// Without location data, preserve all trailing comments (safe fallback)
newStatement.trailingComments = originalTrailing;
}
}
// Wrap the entire statement in if (typeof window !== "undefined")
const ifStatement = t.ifStatement(
t.binaryExpression(
'!==',
t.unaryExpression('typeof', t.identifier('window'), true),
t.stringLiteral('undefined')
),
t.blockStatement([newStatement])
);
// Attach leading comments to the if statement
if (originalLeading) {
ifStatement.leadingComments = originalLeading;
}
statementPath.replaceWith(ifStatement);
changes.push({
type: 'window-guard-statement',
description: `Wrapped window.${propName} assignment/update in SSR guard`,
location: path.node.loc
});
changeCount++;
if (verbose) {
process.stdout.write(`[INFO] Wrapped window.${propName} assignment/update in SSR guard\n`);
}
}
return;
}
// For read operations, wrap the entire chain
const guarded = wrapWithSSRGuard(topPath.node, 'window');
topPath.replaceWith(guarded);
changes.push({
type: 'window-guard',
description: `Added SSR guard for window.${propName} chain`,
location: path.node.loc
});
changeCount++;
if (verbose) {
process.stdout.write(`[INFO] Added SSR guard for window.${propName} chain\n`);
}
}
}
// document.querySelector, document.getElementById, etc.
if (objName === 'document' &&
['querySelector', 'querySelectorAll', 'getElementById', 'getElementsByClassName',
'getElementsByTagName', 'body', 'documentElement', 'head'].includes(propName)) {
if (!isAlreadyGuarded(path, 'document')) {
// Find the top of the member/call chain
let topPath = path;
while (topPath.parentPath) {
const parent = topPath.parent;
// Continue climbing if parent is MemberExpression and we're the object
if (t.isMemberExpression(parent) && parent.object === topPath.node) {
topPath = topPath.parentPath;
continue;
}
// Continue climbing if parent is CallExpression and we're the callee
if (t.isCallExpression(parent) && parent.callee === topPath.node) {
topPath = topPath.parentPath;
continue;
}
// Otherwise stop climbing
break;
}
// Check if this chain is the LHS of an assignment or part of an update expression
let currentPath = topPath;
let needsStatementGuard = false;
// Check for assignment LHS using parentKey (not object identity)
if (currentPath.parentPath && currentPath.parentPath.isAssignmentExpression() &&
currentPath.key === 'left') {
needsStatementGuard = true;
}
// Check for update expression (++, --) using parentKey
if (currentPath.parentPath && currentPath.parentPath.isUpdateExpression() &&
currentPath.key === 'argument') {
needsStatementGuard = true;
}
if (needsStatementGuard) {
// Find the enclosing statement
let statementPath = currentPath;
while (statementPath && !t.isStatement(statementPath.node)) {
statementPath = statementPath.parentPath;
}
if (statementPath && t.isExpressionStatement(statementPath.node)) {
// Preserve comments from original
const originalLeading = statementPath.node.leadingComments;
const originalTrailing = statementPath.node.trailingComments;
// Create a fresh expression statement (not cloned) to avoid comment issues
const newStatement = t.expressionStatement(
t.cloneNode(statementPath.node.expression, /*deep*/ true, /*withoutLoc*/ false)
);
// Preserve trailing comments intelligently
if (originalTrailing && originalTrailing.length > 0) {
if (statementPath.node.loc) {
// With location data, preserve same-line comments
const statementEndLine = statementPath.node.loc.end.line;
const sameLineComments = originalTrailing.filter(comment =>
comment.loc && comment.loc.start.line === statementEndLine
);
if (sameLineComments.length > 0) {
newStatement.trailingComments = sameLineComments;
}
} else {
// Without location data, preserve all trailing comments (safe fallback)
newStatement.trailingComments = originalTrailing;
}
}
// Wrap the entire statement in if (typeof document !== "undefined")
const ifStatement = t.ifStatement(
t.binaryExpression(
'!==',
t.unaryExpression('typeof', t.identifier('document'), true),
t.stringLiteral('undefined')
),
t.blockStatement([newStatement])
);
// Attach leading comments to the if statement
if (originalLeading) {
ifStatement.leadingComments = originalLeading;
}
statementPath.replaceWith(ifStatement);
changes.push({
type: 'document-guard-statement',
description: `Wrapped document.${propName} assignment/update in SSR guard`,
location: path.node.loc
});
changeCount++;
if (verbose) {
process.stdout.write(`[INFO] Wrapped document.${propName} assignment/update in SSR guard\n`);
}
}
return;
}
// For read operations, wrap the entire chain
const guarded = wrapWithSSRGuard(topPath.node, 'document');
topPath.replaceWith(guarded);
changes.push({
type: 'document-guard',
description: `Added SSR guard for document.${propName} chain`,
location: path.node.loc
});
changeCount++;
if (verbose) {
process.stdout.write(`[INFO] Added SSR guard for document.${propName} chain\n`);
}
}
}
}
},
// Handle assignments and updates to window/document properties
AssignmentExpression(path) {
const left = path.node.left;
// Debug: log all assignments
if (verbose) {
console.log('[DEBUG] Found AssignmentExpression');
}
// Check if LHS starts with window or document
const startsWithGlobal = (node) => {
if (t.isIdentifier(node)) {
return node.name === 'window' || node.name === 'document';
}
if (t.isMemberExpression(node)) {
return startsWithGlobal(node.object);
}
return false;
};
if (startsWithGlobal(left)) {
if (verbose) {
console.log('[DEBUG] Assignment starts with global');
}
// Use helper to extract root global name regardless of depth
const globalName = getRootGlobalName(left);
if (globalName && !isAlreadyGuarded(path, globalName)) {
// Find the enclosing statement
let statementPath = path;
while (statementPath && !t.isStatement(statementPath.node)) {
statementPath = statementPath.parentPath;
}
if (statementPath && t.isExpressionStatement(statementPath.node)) {
// Preserve comments from original
const originalLeading = statementPath.node.leadingComments;
const originalTrailing = statementPath.node.trailingComments;
// Create a fresh expression statement (not cloned) to avoid comment issues
const newStatement = t.expressionStatement(
t.cloneNode(statementPath.node.expression, /*deep*/ true, /*withoutLoc*/ false)
);
// Preserve trailing comments intelligently
if (originalTrailing && originalTrailing.length > 0) {
if (statementPath.node.loc) {
// With location data, preserve same-line comments
const statementEndLine = statementPath.node.loc.end.line;
const sameLineComments = originalTrailing.filter(comment =>
comment.loc && comment.loc.start.line === statementEndLine
);
if (sameLineComments.length > 0) {
newStatement.trailingComments = sameLineComments;
}
} else {
// Without location data, preserve all trailing comments (safe fallback)
newStatement.trailingComments = originalTrailing;
}
}
// Wrap in if (typeof global !== "undefined")
const ifStatement = t.ifStatement(
t.binaryExpression(
'!==',
t.unaryExpression('typeof', t.identifier(globalName), true),
t.stringLiteral('undefined')
),
t.blockStatement([newStatement])
);
// Attach leading comments to the if statement
if (originalLeading) {
ifStatement.leadingComments = originalLeading;
}
statementPath.replaceWith(ifStatement);
statementPath.skip(); // Skip the newly created if statement to prevent re-visiting
changes.push({
type: `${globalName}-guard-assignment`,
description: `Wrapped ${globalName} assignment in SSR guard`,
location: path.node.loc
});
changeCount++;
if (verbose) {
process.stdout.write(`[INFO] Wrapped ${globalName} assignment in SSR guard\n`);
}
}
}
}
},
// Handle update expressions (++, --) on window/document properties
UpdateExpression(path) {
const argument = path.node.argument;
// Check if argument starts with window or document
const startsWithGlobal = (node) => {
if (t.isIdentifier(node)) {
return node.name === 'window' || node.name === 'document';
}
if (t.isMemberExpression(node)) {
return startsWithGlobal(node.object);
}
return false;
};
if (startsWithGlobal(argument)) {
// Use helper to extract root global name regardless of depth
const globalName = getRootGlobalName(argument);
if (globalName && !isAlreadyGuarded(path, globalName)) {
// Find the enclosing statement
let statementPath = path;
while (statementPath && !t.isStatement(statementPath.node)) {
statementPath = statementPath.parentPath;
}
if (statementPath && t.isExpressionStatement(statementPath.node)) {
// Preserve comments from original
const originalLeading = statementPath.node.leadingComments;
const originalTrailing = statementPath.node.trailingComments;
// Create a fresh expression statement (not cloned) to avoid comment issues
const newStatement = t.expressionStatement(
t.cloneNode(statementPath.node.expression, /*deep*/ true, /*withoutLoc*/ false)
);
// Preserve trailing comments intelligently
if (originalTrailing && originalTrailing.length > 0) {
if (statementPath.node.loc) {
// With location data, preserve same-line comments
const statementEndLine = statementPath.node.loc.end.line;
const sameLineComments = originalTrailing.filter(comment =>
comment.loc && comment.loc.start.line === statementEndLine
);
if (sameLineComments.length > 0) {
newStatement.trailingComments = sameLineComments;
}
} else {
// Without location data, preserve all trailing comments (safe fallback)
newStatement.trailingComments = originalTrailing;
}
}
// Wrap in if (typeof global !== "undefined")
const ifStatement = t.ifStatement(
t.binaryExpression(
'!==',
t.unaryExpression('typeof', t.identifier(globalName), true),
t.stringLiteral('undefined')
),
t.blockStatement([newStatement])
);
// Attach leading comments to the if statement
if (originalLeading) {
ifStatement.leadingComments = originalLeading;
}
statementPath.replaceWith(ifStatement);
changes.push({
type: `${globalName}-guard-update`,
description: `Wrapped ${globalName} update in SSR guard`,
location: path.node.loc
});
changeCount++;
if (verbose) {
process.stdout.write(`[INFO] Wrapped ${globalName} update in SSR guard\n`);
}
// Skip further processing
path.skip();
}
}
}
},
// Handle useEffect with addEventListener that needs cleanup
CallExpression(path) {
// Look for useEffect calls
if (t.isIdentifier(path.node.callee, { name: 'useEffect' })) {
const effectCallback = path.node.arguments[0];
if (t.isArrowFunctionExpression(effectCallback) || t.isFunctionExpression(effectCallback)) {
let body = effectCallback.body;
let normalizedToBlock = false;
// Normalize concise arrow callbacks to block statements
// But preserve cleanup functions (arrow/function expressions)
if (!t.isBlockStatement(body)) {
// Check if it's already a cleanup function (returns arrow/function)
if (t.isArrowFunctionExpression(body) || t.isFunctionExpression(body)) {
// Keep it as a cleanup return - wrap in return statement
const blockBody = t.blockStatement([
t.returnStatement(body)
]);
effectCallback.body = blockBody;
body = blockBody;
} else {
// It's an expression, convert to expression statement
const blockBody = t.blockStatement([
t.expressionStatement(body)
]);
effectCallback.body = blockBody;
body = blockBody;
normalizedToBlock = true;
}
}
// Track if we found addEventListener without cleanup
let hasAddEventListener = false;
let hasRemoveEventListener = false;
let hasReturnCleanup = false;
// Check body for addEventListener
const checkBody = (node) => {
if (t.isBlockStatement(node)) {
node.body.forEach(statement => {
// Check for addEventListener
if (t.isExpressionStatement(statement) &&
t.isCallExpression(statement.expression)) {
const call = statement.expression;
if (t.isMemberExpression(call.callee)) {
const method = call.callee.property;
if (t.isIdentifier(method, { name: 'addEventListener' })) {
hasAddEventListener = true;
}
}
}
// Check for return cleanup function
if (t.isReturnStatement(statement) &&
(t.isArrowFunctionExpression(statement.argument) ||
t.isFunctionExpression(statement.argument))) {
hasReturnCleanup = true;
// Check if cleanup has removeEventListener
const cleanupBody = statement.argument.body;
if (t.isBlockStatement(cleanupBody)) {
cleanupBody.body.forEach(cleanupStmt => {
if (t.isExpressionStatement(cleanupStmt) &&
t.isCallExpression(cleanupStmt.expression)) {
const call = cleanupStmt.expression;
if (t.isMemberExpression(call.callee) &&
t.isIdentifier(call.callee.property, { name: 'removeEventListener' })) {
hasRemoveEventListener = true;
}
}
});
}
}
});
}
};
checkBody(body);
// If we have addEventListener but no cleanup, add cleanup function
if (hasAddEventListener && !hasReturnCleanup && t.isBlockStatement(body)) {
// Collect all addEventListener calls to generate cleanup
const addEventListeners = [];
body.body.forEach(statement => {
if (t.isExpressionStatement(statement) &&
t.isCallExpression(statement.expression)) {
const call = statement.expression;
if (t.isMemberExpression(call.callee) &&
t.isIdentifier(call.callee.property, { name: 'addEventListener' })) {
// Store the object (e.g., window), event name, handler, and options (if any)
addEventListeners.push({
object: call.callee.object,
eventName: call.arguments[0],
handler: call.arguments[1],
options: call.arguments[2] || null
});
}
}
});
// Generate cleanup function with removeEventListener calls
if (addEventListeners.length > 0) {
const cleanupStatements = addEventListeners.map(listener => {
// Build removeEventListener arguments (include options if present)
const removeArgs = [t.cloneNode(listener.eventName), t.cloneNode(listener.handler)];
if (listener.options) {
removeArgs.push(t.cloneNode(listener.options));
}
return t.expressionStatement(
t.callExpression(
t.memberExpression(
t.cloneNode(listener.object),
t.identifier('removeEventListener')
),
removeArgs
)
);
});
// Create return statement with cleanup function
const returnCleanup = t.returnStatement(
t.arrowFunctionExpression(
[],
t.blockStatement(cleanupStatements)
)
);
// Add cleanup to the end of useEffect body
body.body.push(returnCleanup);
changes.push({
type: 'event-listener-cleanup',
description: `Added removeEventListener cleanup for ${addEventListeners.length} event listener(s)`,
location: path.node.loc
});
changeCount++;
if (verbose) {
process.stdout.write(`[INFO] Added removeEventListener cleanup for ${addEventListeners.length} event listener(s)\n`);
}
}
}
}
}
}
};
// Apply AST transformations
// Following orchestration pattern: AST-first with validation, fallback to regex
let astSucceeded = false;
let astValidationFailed = false;
try {
const result = transformer.transform(code, visitors, { filename: filePath });
// Validate AST transformation output
const isValid = validateSyntax(result.code);
if (isValid) {
// AST succeeded and output is valid - accept changes
updatedCode = result.code;
astSucceeded = true;
results.push(...changes.map(c => ({
type: 'hydration',
file: filePath,
success: true,
changes: 1,
details: c.description
})));
if (verbose && changes.length > 0) {
process.stdout.write(`[INFO] AST-based hydration transformations: ${changes.length} changes (validated)\n`);
}
} else {
// Validation failed - revert to original state
astValidationFailed = true;
updatedCode = code;
changeCount = 0;
changes.length = 0;
if (verbose) {
process.stderr.write(`[WARNING] AST transformation produced invalid syntax - reverted to original\n`);
}
}
} catch (error) {
// AST transformation failed - will try regex fallback below
if (verbose) {
process.stderr.write(`[WARNING] AST transformation failed: ${error.message}\n`);
}
}
// If AST failed or validation failed, try regex fallback
if (!astSucceeded) {
if (verbose) {
process.stdout.write(`[INFO] Attempting regex fallback for hydration transformations\n`);
}
const beforeRegex = code;
const regexResult = applyRegexHydrationFallbacks(code);
// Validate regex fallback output
const regexMadeChanges = regexResult.code !== beforeRegex;
const regexOutputValid = validateSyntax(regexResult.code);
if (regexMadeChanges && regexOutputValid) {
// Regex produced valid changes - accept them
updatedCode = regexResult.code;
changeCount = regexResult.changes.length;
results.push(...regexResult.changes.map(c => ({
type: 'hydration-fallback',
file: filePath,
success: true,
changes: 1,
details: c.description
})));
if (verbose) {
process.stdout.write(`[INFO] Regex fallback succeeded with ${regexResult.changes.length} changes (validated)\n`);
}
} else if (regexMadeChanges && !regexOutputValid) {
// Regex produced INVALID code - REJECT and revert
updatedCode = beforeRegex;
changeCount = 0;
if (verbose) {
process.stderr.write(`[ERROR] Regex fallback produced invalid syntax - rejected changes\n`);
}
} else {
// Regex made no changes - keep original
updatedCode = beforeRegex;
changeCount = 0;
if (verbose) {
process.stdout.write(`[INFO] Regex fallback made no changes\n`);
}
}
}
// Write changes if not dry-run
if (dryRun) {
return {
success: true,
code: updatedCode,
originalCode: code,
changeCount,
results
};
}
if (changeCount > 0 && existsAsFile) {
await fs.writeFile(filePath, updatedCode);
results.push({ type: 'write', file: filePath, success: true, changes: changeCount });
}
if (verbose && changeCount > 0) {
process.stdout.write(`Layer 4 applied ${changeCount} changes to ${path.basename(filePath)}\n`);
}
return {
success: results.every(r => r.success !== false),
code: updatedCode,
originalCode: code,
changeCount,
results
};
} catch (error) {
if (verbose) process.stderr.write(`Layer 4 failed: ${error.message}\n`);
results.push({ type: 'error', file: filePath, success: false, error: error.message });
return {
success: false,
code,
originalCode: code,
changeCount: 0,
results
};
}
}
module.exports = { transform };