@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,302 lines (1,165 loc) • 65.2 kB
JavaScript
/**
* NeuroLint - AST-based Transformation Engine
* Replaces regex-based transformations with proper code parsing and manipulation
*
* Copyright (c) 2025 NeuroLint
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const t = require('@babel/types');
class ASTTransformer {
constructor() {
this.parserOptions = {
sourceType: 'module',
plugins: [
'jsx',
'typescript',
'decorators-legacy',
'classProperties',
'objectRestSpread'
]
};
}
/**
* Parse code into AST
*/
parseCode(code, filename = 'unknown') {
try {
return parser.parse(code, {
...this.parserOptions,
filename
});
} catch (error) {
throw new Error(`Syntax error: ${error.message}`);
}
}
/**
* Generate code from AST
*/
generateCode(ast, options = {}) {
try {
return generate(ast, {
retainLines: false,
retainFunctionParens: true,
compact: false,
comments: true,
...options
});
} catch (error) {
throw new Error(`Failed to generate code: ${error.message}`);
}
}
/**
* Transform code using AST visitors
*/
transform(code, visitors, options = {}) {
let ast;
try {
ast = this.parseCode(code, options.filename);
} catch (error) {
throw error;
}
const changes = [];
traverse(ast, {
...visitors,
// Track changes
enter(path) {
if (path.node._changed) {
changes.push({
type: path.node.type,
location: path.node.loc,
description: path.node._changeDescription
});
}
}
});
const result = this.generateCode(ast, options);
return {
code: result.code,
changes,
ast
};
}
/**
* Layer 2: Pattern Fixes (AST-based)
*/
transformPatterns(code, options = {}) {
const changes = [];
try {
const visitors = {
// Fix HTML entities in string literals
StringLiteral(path) {
const value = path.node.value;
const originalValue = value;
let newValue = value;
// Replace HTML entities
const entityMap = {
'"': '"',
'&': '&',
'<': '<',
'>': '>',
''': "'",
' ': ' '
};
let hasChanges = false;
for (const [entity, replacement] of Object.entries(entityMap)) {
if (newValue.includes(entity)) {
newValue = newValue.replace(new RegExp(entity, 'g'), replacement);
hasChanges = true;
}
}
if (hasChanges) {
path.node.value = newValue;
path.node._changed = true;
path.node._changeDescription = `Fixed HTML entities: ${originalValue} → ${newValue}`;
changes.push({
type: 'StringLiteral',
location: path.node.loc,
description: `Fixed HTML entities: ${originalValue} → ${newValue}`
});
}
},
// Replace console.log and alert with proper AST transformations
CallExpression(path) {
// Helper function to check if console.log is the entire body of an arrow function
const isArrowFunctionBody = () => {
const parent = path.parent;
const parentPath = path.parentPath;
// Check if parent is ArrowFunctionExpression and this CallExpression is the entire body
if (t.isArrowFunctionExpression(parent) && parent.body === path.node) {
return true;
}
// Also check if parent is ExpressionStatement inside ArrowFunctionExpression body
if (t.isExpressionStatement(parent) && parentPath && parentPath.parent) {
const grandParent = parentPath.parent;
if (t.isBlockStatement(grandParent) &&
grandParent.body.length === 1 &&
grandParent.body[0] === parent) {
const arrowPath = parentPath.parentPath;
if (arrowPath && t.isArrowFunctionExpression(arrowPath.parent)) {
return true;
}
}
}
return false;
};
// Handle console.log (including all variants: log, info, warn, error, debug)
if (t.isMemberExpression(path.node.callee)) {
if (t.isIdentifier(path.node.callee.object, { name: 'console' }) &&
t.isIdentifier(path.node.callee.property) &&
['log', 'info', 'warn', 'error', 'debug'].includes(path.node.callee.property.name)) {
const methodName = path.node.callee.property.name;
const location = path.node.loc;
// Get arguments as string for comment
const args = path.node.arguments.map(arg => {
try {
return generate(arg).code;
} catch {
return '...';
}
}).join(', ');
// Check if this is an expression-bodied arrow function
if (isArrowFunctionBody()) {
// Find the arrow function and convert expression body to block body with comment
if (t.isArrowFunctionExpression(path.parent)) {
// Direct arrow body: () => console.log()
// Replace with empty block statement with comment
const comment = ` [NeuroLint] Removed console.${methodName}: ${args}`;
const emptyBlock = t.blockStatement([]);
path.parentPath.node.body = emptyBlock;
path.parentPath.addComment('trailing', comment);
changes.push({
type: 'ArrowFunctionExpression',
location: location,
description: `Replaced console.${methodName} in arrow function body with empty block {}`
});
return;
}
}
// For other contexts, replace with comment
if (t.isExpressionStatement(path.parent)) {
// Replace the entire expression statement with an EmptyStatement carrying the comment
const commentText = `[NeuroLint] Removed console.${methodName}: ${args}`;
const emptyStatement = t.emptyStatement();
emptyStatement.leadingComments = [{
type: 'CommentLine',
value: ` ${commentText}`
}];
path.parentPath.replaceWith(emptyStatement);
changes.push({
type: 'ExpressionStatement',
location: location,
description: `Removed console.${methodName} statement (added comment)`
});
} else {
// For expression contexts, we need to maintain syntax but add comment
// Replace with a comment expression (use undefined to maintain valid syntax)
const commentText = `[NeuroLint] Removed console.${methodName}: ${args}`;
const replacement = t.identifier('undefined');
replacement.leadingComments = [{
type: 'CommentLine',
value: ` ${commentText}`
}];
path.replaceWith(replacement);
changes.push({
type: 'CallExpression',
location: location,
description: `Replaced console.${methodName} with undefined (added comment)`
});
}
return;
}
}
// Handle alert
if (t.isIdentifier(path.node.callee, { name: 'alert' })) {
const location = path.node.loc;
// Get arguments as string for comment
const args = path.node.arguments.map(arg => {
try {
return generate(arg).code;
} catch {
return '...';
}
}).join(', ');
// Check if this is an expression-bodied arrow function
if (isArrowFunctionBody()) {
if (t.isArrowFunctionExpression(path.parent)) {
// Replace with empty block statement with comment
const comment = ` [NeuroLint] Replace with toast notification: ${args}`;
path.parentPath.node.body = t.blockStatement([]);
path.parentPath.addComment('trailing', comment);
changes.push({
type: 'ArrowFunctionExpression',
location: location,
description: 'Replaced alert in arrow function body with empty block {}'
});
return;
}
}
// For other contexts, replace with comment
if (t.isExpressionStatement(path.parent)) {
const commentText = `[NeuroLint] Replace with toast notification: ${args}`;
const emptyStatement = t.emptyStatement();
emptyStatement.leadingComments = [{
type: 'CommentLine',
value: ` ${commentText}`
}];
path.parentPath.replaceWith(emptyStatement);
changes.push({
type: 'ExpressionStatement',
location: location,
description: 'Removed alert statement (added comment)'
});
} else {
const commentText = `[NeuroLint] Replace with toast notification: ${args}`;
const replacement = t.identifier('undefined');
replacement.leadingComments = [{
type: 'CommentLine',
value: ` ${commentText}`
}];
path.replaceWith(replacement);
changes.push({
type: 'CallExpression',
location: location,
description: 'Replaced alert with undefined (added comment)'
});
}
}
// Handle confirm
if (t.isIdentifier(path.node.callee, { name: 'confirm' })) {
const location = path.node.loc;
const args = path.node.arguments.map(arg => {
try {
return generate(arg).code;
} catch {
return '...';
}
}).join(', ');
if (isArrowFunctionBody()) {
if (t.isArrowFunctionExpression(path.parent)) {
const comment = ` [NeuroLint] Replace with dialog: ${args}`;
path.parentPath.node.body = t.blockStatement([]);
path.parentPath.addComment('trailing', comment);
changes.push({
type: 'ArrowFunctionExpression',
location: location,
description: 'Replaced confirm in arrow function body with empty block {}'
});
return;
}
}
if (t.isExpressionStatement(path.parent)) {
const commentText = `[NeuroLint] Replace with dialog: ${args}`;
const emptyStatement = t.emptyStatement();
emptyStatement.leadingComments = [{
type: 'CommentLine',
value: ` ${commentText}`
}];
path.parentPath.replaceWith(emptyStatement);
changes.push({
type: 'ExpressionStatement',
location: location,
description: 'Removed confirm statement (added comment)'
});
} else {
const commentText = `[NeuroLint] Replace with dialog: ${args}`;
const replacement = t.identifier('undefined');
replacement.leadingComments = [{
type: 'CommentLine',
value: ` ${commentText}`
}];
path.replaceWith(replacement);
changes.push({
type: 'CallExpression',
location: location,
description: 'Replaced confirm with undefined (added comment)'
});
}
}
// Handle prompt
if (t.isIdentifier(path.node.callee, { name: 'prompt' })) {
const location = path.node.loc;
const args = path.node.arguments.map(arg => {
try {
return generate(arg).code;
} catch {
return '...';
}
}).join(', ');
if (isArrowFunctionBody()) {
if (t.isArrowFunctionExpression(path.parent)) {
const comment = ` [NeuroLint] Replace with dialog: ${args}`;
path.parentPath.node.body = t.blockStatement([]);
path.parentPath.addComment('trailing', comment);
changes.push({
type: 'ArrowFunctionExpression',
location: location,
description: 'Replaced prompt in arrow function body with empty block {}'
});
return;
}
}
if (t.isExpressionStatement(path.parent)) {
const commentText = `[NeuroLint] Replace with dialog: ${args}`;
const emptyStatement = t.emptyStatement();
emptyStatement.leadingComments = [{
type: 'CommentLine',
value: ` ${commentText}`
}];
path.parentPath.replaceWith(emptyStatement);
changes.push({
type: 'ExpressionStatement',
location: location,
description: 'Removed prompt statement (added comment)'
});
} else {
const commentText = `[NeuroLint] Replace with dialog: ${args}`;
const replacement = t.identifier('undefined');
replacement.leadingComments = [{
type: 'CommentLine',
value: ` ${commentText}`
}];
path.replaceWith(replacement);
changes.push({
type: 'CallExpression',
location: location,
description: 'Replaced prompt with undefined (added comment)'
});
}
}
},
// Replace var with const/let
VariableDeclaration(path) {
if (path.node.kind === 'var') {
// Determine if it should be const or let
const isConst = path.node.declarations.every(decl =>
!decl.init ||
(t.isLiteral(decl.init) && !t.isRegExpLiteral(decl.init)) ||
t.isObjectExpression(decl.init) ||
t.isArrayExpression(decl.init)
);
path.node.kind = isConst ? 'const' : 'let';
path.node._changed = true;
path.node._changeDescription = `Replaced var with ${isConst ? 'const' : 'let'}`;
changes.push({
type: 'VariableDeclaration',
location: path.node.loc,
description: `Replaced var with ${isConst ? 'const' : 'let'}`
});
}
}
};
const result = this.transform(code, visitors, options);
return {
code: result.code,
changes: changes,
success: true
};
} catch (error) {
return {
code: code,
changes: [],
success: false,
error: error.message
};
}
}
/**
* Layer 3: Component Fixes (AST-based)
*/
transformComponents(code, options = {}) {
const changes = [];
try {
const visitors = {
// Add missing key props to map functions
CallExpression(path) {
if (t.isMemberExpression(path.node.callee) &&
t.isIdentifier(path.node.callee.property, { name: 'map' })) {
const callback = path.node.arguments[0];
let paramName = null;
let useIndex = false;
let needsIndexParam = false;
let foundStableProperty = false;
if (callback) {
if (t.isArrowFunctionExpression(callback) || t.isFunctionExpression(callback)) {
const params = callback.params;
if (params && params.length > 0) {
const firstParam = params[0];
if (t.isIdentifier(firstParam)) {
paramName = firstParam.name;
} else if (t.isObjectPattern(firstParam)) {
// Try to find a stable property (id, key, _id, etc.)
const stableProps = ['id', 'key', '_id', 'uid'];
let foundProp = null;
for (const prop of firstParam.properties) {
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
if (stableProps.includes(prop.key.name)) {
foundProp = t.isIdentifier(prop.value) ? prop.value.name : prop.key.name;
break;
}
}
}
if (foundProp) {
paramName = foundProp;
foundStableProperty = true;
} else {
// No stable property found, use index
if (params.length > 1) {
// Handle both plain identifiers and assignment patterns (default params)
const secondParam = params[1];
if (t.isIdentifier(secondParam)) {
paramName = secondParam.name;
useIndex = true;
} else if (t.isAssignmentPattern(secondParam) && t.isIdentifier(secondParam.left)) {
// Handle default parameters like `idx = 0`
paramName = secondParam.left.name;
useIndex = true;
} else {
paramName = 'index';
useIndex = true;
needsIndexParam = true;
}
} else {
paramName = 'index';
useIndex = true;
needsIndexParam = true;
}
}
} else if (t.isArrayPattern(firstParam)) {
// For array destructuring, use index
if (params.length > 1) {
// Handle both plain identifiers and assignment patterns (default params)
const secondParam = params[1];
if (t.isIdentifier(secondParam)) {
paramName = secondParam.name;
useIndex = true;
} else if (t.isAssignmentPattern(secondParam) && t.isIdentifier(secondParam.left)) {
// Handle default parameters like `idx = 0`
paramName = secondParam.left.name;
useIndex = true;
} else {
paramName = 'index';
useIndex = true;
needsIndexParam = true;
}
} else {
paramName = 'index';
useIndex = true;
needsIndexParam = true;
}
} else if (t.isAssignmentPattern(firstParam)) {
// Handle first param with default value (item = {})
// Extract the actual parameter name
if (t.isIdentifier(firstParam.left)) {
paramName = firstParam.left.name;
// Check if there's a second param
if (params.length > 1) {
const secondParam = params[1];
if (t.isIdentifier(secondParam)) {
paramName = secondParam.name;
useIndex = true;
} else if (t.isAssignmentPattern(secondParam) && t.isIdentifier(secondParam.left)) {
paramName = secondParam.left.name;
useIndex = true;
} else {
// No valid second param, add index
useIndex = true;
needsIndexParam = true;
paramName = 'index';
}
} else {
// Only one param with default, add index
useIndex = true;
needsIndexParam = true;
paramName = 'index';
}
}
}
}
}
}
// Fall back to 'index' if no param name found
if (!paramName) {
paramName = 'index';
useIndex = true;
needsIndexParam = true;
}
// Add index parameter to callback if needed
if (needsIndexParam && callback && (t.isArrowFunctionExpression(callback) || t.isFunctionExpression(callback))) {
callback.params.push(t.identifier('index'));
}
// Look for JSX elements inside the map function
path.traverse({
JSXElement(jsxPath) {
// Check if key prop already exists
const hasKey = jsxPath.node.openingElement.attributes.some(attr =>
t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: 'key' })
);
if (!hasKey) {
// Add key prop using the actual parameter name or index
let keyExpression;
if (useIndex || foundStableProperty) {
// For index or stable property from destructured params, just use the value
keyExpression = t.identifier(paramName);
} else {
// For normal params (item, todo, etc.), use param.id || param
keyExpression = t.logicalExpression(
'||',
t.memberExpression(
t.identifier(paramName),
t.identifier('id')
),
t.identifier(paramName)
);
}
const keyAttribute = t.jsxAttribute(
t.jsxIdentifier('key'),
t.jsxExpressionContainer(keyExpression)
);
jsxPath.node.openingElement.attributes.push(keyAttribute);
jsxPath.node._changed = true;
jsxPath.node._changeDescription = 'Added key prop to map function';
changes.push({
type: 'JSXElement',
location: jsxPath.node.loc,
description: 'Added key prop to map function'
});
}
}
});
}
},
// Combined JSXElement visitor for all component fixes
JSXElement(path) {
const componentName = path.node.openingElement.name.name;
// Add missing type props to Input components
if (t.isJSXIdentifier(path.node.openingElement.name, { name: 'Input' })) {
const attributes = path.node.openingElement.attributes;
const hasType = attributes.some(attr =>
t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: 'type' })
);
if (!hasType) {
const typeAttribute = t.jsxAttribute(
t.jsxIdentifier('type'),
t.stringLiteral('text')
);
attributes.push(typeAttribute);
path.node._changed = true;
path.node._changeDescription = 'Added type="text" to Input component';
changes.push({
type: 'JSXElement',
location: path.node.loc,
description: 'Added type="text" to Input component'
});
}
}
// Add size props to Icon components
if (componentName && componentName.endsWith('Icon')) {
const attributes = path.node.openingElement.attributes;
const hasClassName = attributes.some(attr =>
t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: 'className' })
);
const hasSize = attributes.some(attr =>
t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: 'size' })
);
if (!hasClassName && !hasSize) {
const sizeAttribute = t.jsxAttribute(
t.jsxIdentifier('className'),
t.stringLiteral('w-4 h-4')
);
attributes.push(sizeAttribute);
path.node._changed = true;
path.node._changeDescription = 'Added size className to Icon component';
changes.push({
type: 'JSXElement',
location: path.node.loc,
description: 'Added size className to Icon component'
});
}
}
// Add aria-label to buttons
if (t.isJSXIdentifier(path.node.openingElement.name, { name: 'button' })) {
const attributes = path.node.openingElement.attributes;
const hasAriaLabel = attributes.some(attr =>
t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: 'aria-label' })
);
if (!hasAriaLabel) {
const ariaLabelAttribute = t.jsxAttribute(
t.jsxIdentifier('aria-label'),
t.stringLiteral('Button')
);
attributes.push(ariaLabelAttribute);
path.node._changed = true;
path.node._changeDescription = 'Added aria-label to button';
changes.push({
type: 'JSXElement',
location: path.node.loc,
description: 'Added aria-label to button'
});
}
}
// Add alt to images
if (t.isJSXIdentifier(path.node.openingElement.name, { name: 'img' })) {
const attributes = path.node.openingElement.attributes;
const hasAlt = attributes.some(attr =>
t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: 'alt' })
);
if (!hasAlt) {
const altAttribute = t.jsxAttribute(
t.jsxIdentifier('alt'),
t.stringLiteral('Image')
);
attributes.push(altAttribute);
path.node._changed = true;
path.node._changeDescription = 'Added alt attribute to image';
changes.push({
type: 'JSXElement',
location: path.node.loc,
description: 'Added alt attribute to image'
});
}
}
// Convert HTML buttons to Button components
if (t.isJSXIdentifier(path.node.openingElement.name, { name: 'button' })) {
// Change button to Button
path.node.openingElement.name = t.jsxIdentifier('Button');
if (path.node.closingElement) {
path.node.closingElement.name = t.jsxIdentifier('Button');
}
// Add variant prop if not present
const attributes = path.node.openingElement.attributes;
const hasVariant = attributes.some(attr =>
t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: 'variant' })
);
if (!hasVariant) {
const variantAttribute = t.jsxAttribute(
t.jsxIdentifier('variant'),
t.stringLiteral('default')
);
attributes.push(variantAttribute);
}
path.node._changed = true;
path.node._changeDescription = 'Converted HTML button to Button component';
changes.push({
type: 'JSXElement',
location: path.node.loc,
description: 'Converted HTML button to Button component'
});
}
// Convert HTML inputs to Input components
if (t.isJSXIdentifier(path.node.openingElement.name, { name: 'input' })) {
// Change input to Input
path.node.openingElement.name = t.jsxIdentifier('Input');
if (path.node.closingElement) {
path.node.closingElement.name = t.jsxIdentifier('Input');
}
path.node._changed = true;
path.node._changeDescription = 'Converted HTML input to Input component';
changes.push({
type: 'JSXElement',
location: path.node.loc,
description: 'Converted HTML input to Input component'
});
}
}
};
const result = this.transform(code, visitors, options);
return {
code: result.code,
changes: changes,
success: true
};
} catch (error) {
return {
code: code,
changes: [],
success: false,
error: error.message
};
}
}
/**
* Helper: Check if an expression is already protected by SSR guard
*/
isAlreadySSRGuarded(path) {
// Check parent nodes for typeof guards
let current = path;
let depth = 0;
while (current && depth < 5) { // Check up to 5 levels
// Check for typeof !== "undefined" pattern
if (t.isConditionalExpression(current.node) || t.isIfStatement(current.node)) {
const test = current.node.test;
// typeof window !== "undefined" pattern
if (t.isBinaryExpression(test) &&
test.operator === '!==' &&
t.isUnaryExpression(test.left) &&
test.left.operator === 'typeof') {
return true;
}
// window !== undefined pattern
if (t.isBinaryExpression(test) &&
(test.operator === '!==' || test.operator === '!==') &&
t.isIdentifier(test.left, { name: 'window' })) {
return true;
}
}
current = current.parentPath;
depth++;
}
return false;
}
/**
* Layer 5: Next.js App Router Fixes (AST-based)
* Production-ready implementation with robust hook detection, AST-based import management, and smart SSR guards
*/
transformNextJS(code, options = {}) {
const changes = [];
let needsCreateRootImport = false;
let needsHydrateRootImport = false;
let reactDOMClientImportPath = null;
let rootCounter = 0; // Counter for unique root variable names
// Track imported hooks and their local bindings (handles aliases and destructuring)
const importedHooks = new Set();
const reactDefaultImport = { name: null }; // Track default React import
try {
const visitors = {
// Consolidated Program visitor for directives and imports
Program(path) {
let hasClientHooks = false;
let hasUseClient = false;
let hasReactImport = false;
let hasReactHooks = false;
// Check for existing 'use client' directive
path.node.directives.forEach(directive => {
if (t.isDirectiveLiteral(directive.value, { value: 'use client' })) {
hasUseClient = true;
}
});
// Build map of imported hooks and track existing imports
path.node.body.forEach(node => {
if (t.isImportDeclaration(node)) {
const source = node.source.value;
// Track React imports
if (source === 'react') {
hasReactImport = true;
node.specifiers.forEach(spec => {
// Default import: import React from 'react'
if (t.isImportDefaultSpecifier(spec)) {
reactDefaultImport.name = spec.local.name;
}
// Named imports: import { useState, useEffect as useMyEffect } from 'react'
if (t.isImportSpecifier(spec)) {
const importedName = spec.imported.name;
const localName = spec.local.name;
const hookNames = ['useState', 'useEffect', 'useCallback', 'useMemo', 'useRef', 'useContext', 'useReducer', 'useLayoutEffect'];
if (hookNames.includes(importedName)) {
importedHooks.add(localName); // Track local binding (handles aliases)
}
}
});
}
// Track react-dom/client imports
if (source === 'react-dom/client') {
reactDOMClientImportPath = path.node.body.indexOf(node);
}
}
});
// Check for client-side hooks and React usage
path.traverse({
// Detect variable declarations with destructuring: const { useState: useCount } = React
VariableDeclarator(varPath) {
if (t.isObjectPattern(varPath.node.id) &&
t.isIdentifier(varPath.node.init) &&
varPath.node.init.name === reactDefaultImport.name) {
varPath.node.id.properties.forEach(prop => {
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
const hookNames = ['useState', 'useEffect', 'useCallback', 'useMemo', 'useRef', 'useContext', 'useReducer', 'useLayoutEffect'];
if (hookNames.includes(prop.key.name)) {
// Handle aliasing: { useState: useCount }
const localName = t.isIdentifier(prop.value) ? prop.value.name : prop.key.name;
importedHooks.add(localName);
}
}
});
}
},
CallExpression(callPath) {
// Direct hook calls: useState()
if (t.isIdentifier(callPath.node.callee)) {
if (importedHooks.has(callPath.node.callee.name)) {
hasClientHooks = true;
hasReactHooks = true;
}
}
// React.useState() calls
if (t.isMemberExpression(callPath.node.callee) &&
t.isIdentifier(callPath.node.callee.object) &&
callPath.node.callee.object.name === reactDefaultImport.name &&
t.isIdentifier(callPath.node.callee.property)) {
const hookNames = ['useState', 'useEffect', 'useCallback', 'useMemo', 'useRef', 'useContext', 'useReducer', 'useLayoutEffect'];
if (hookNames.includes(callPath.node.callee.property.name)) {
hasClientHooks = true;
hasReactHooks = true;
}
}
},
MemberExpression(memberPath) {
// Detect browser-only APIs that require 'use client'
if (t.isIdentifier(memberPath.node.object)) {
const browserAPIs = ['window', 'document', 'localStorage', 'sessionStorage', 'navigator'];
if (browserAPIs.includes(memberPath.node.object.name)) {
hasClientHooks = true;
}
}
}
});
// Add 'use client' if needed and not present
if (hasClientHooks && !hasUseClient) {
const useClientDirective = t.directive(t.directiveLiteral('use client'));
path.node.directives.unshift(useClientDirective);
path.node._changed = true;
path.node._changeDescription = 'Added use client directive';
changes.push({
type: 'Program',
location: path.node.loc,
description: 'Added use client directive for hooks/browser APIs'
});
}
// Add React import if needed
if (hasReactHooks && !hasReactImport) {
const reactImport = t.importDeclaration(
[t.importDefaultSpecifier(t.identifier('React'))],
t.stringLiteral('react')
);
// Insert at the beginning
path.node.body.unshift(reactImport);
path.node._changed = true;
path.node._changeDescription = 'Added React import';
changes.push({
type: 'Program',
location: path.node.loc,
description: 'Added React import for hooks'
});
}
},
// Convert ReactDOM.render to createRoot (React 19)
CallExpression(path) {
// ReactDOM.render(<App />, container) -> createRoot(container).render(<App />)
if (t.isMemberExpression(path.node.callee) &&
t.isIdentifier(path.node.callee.object, { name: 'ReactDOM' }) &&
t.isIdentifier(path.node.callee.property, { name: 'render' })) {
const [element, container] = path.node.arguments;
if (!element || !container) return;
// Generate unique root variable name to avoid redeclaration errors
const rootVarName = rootCounter === 0 ? 'root' : `root${rootCounter}`;
rootCounter++;
// Create: const root = createRoot(container);
const rootDeclaration = t.variableDeclaration('const', [
t.variableDeclarator(
t.identifier(rootVarName),
t.callExpression(t.identifier('createRoot'), [container])
)
]);
// Create: root.render(element);
const renderCall = t.expressionStatement(
t.callExpression(
t.memberExpression(t.identifier(rootVarName), t.identifier('render')),
[element]
)
);
// Replace the expression statement containing the call
const statement = path.findParent(p => p.isExpressionStatement());
if (statement) {
statement.replaceWithMultiple([rootDeclaration, renderCall]);
needsCreateRootImport = true;
changes.push({
type: 'CallExpression',
location: path.node.loc,
description: 'Converted ReactDOM.render to createRoot().render()'
});
}
}
// ReactDOM.hydrate(<App />, container) -> hydrateRoot(container, <App />)
if (t.isMemberExpression(path.node.callee) &&
t.isIdentifier(path.node.callee.object, { name: 'ReactDOM' }) &&
t.isIdentifier(path.node.callee.property, { name: 'hydrate' })) {
const [element, container] = path.node.arguments;
if (!element || !container) return;
// Create: hydrateRoot(container, element);
// Note: parameter order is swapped from hydrate!
const hydrateCall = t.callExpression(
t.identifier('hydrateRoot'),
[container, element]
);
path.replaceWith(hydrateCall);
needsHydrateRootImport = true;
changes.push({
type: 'CallExpression',
location: path.node.loc,
description: 'Converted ReactDOM.hydrate to hydrateRoot()'
});
}
// Detect ReactDOM.findDOMNode (removed in React 19)
if (t.isMemberExpression(path.node.callee) &&
t.isIdentifier(path.node.callee.object, { name: 'ReactDOM' }) &&
t.isIdentifier(path.node.callee.property, { name: 'findDOMNode' })) {
changes.push({
type: 'Warning',
location: path.node.loc,
description: 'findDOMNode() is removed in React 19 - use refs instead'
});
}
},
// Fix malformed import statements
ImportDeclaration(path) {
const specifiers = path.node.specifiers;
let hasChanges = false;
// Fix nested import statements
specifiers.forEach(specifier => {
if (t.isImportSpecifier(specifier)) {
// Check for malformed specifiers
if (specifier.imported && specifier.imported.name.includes('\n')) {
const cleanName = specifier.imported.name.replace(/\s+/g, ' ').trim();
specifier.imported.name = cleanName;
hasChanges = true;
}
}
});
if (hasChanges) {
path.node._changed = true;
path.node._changeDescription = 'Fixed malformed import statement';
changes.push({
type: 'ImportDeclaration',
location: path.node.loc,
description: 'Fixed malformed import statement'
});
}
},
// Convert metadata exports to generateMetadata
ExportNamedDeclaration(path) {
if (path.node.declaration &&
t.isVariableDeclaration(path.node.declaration)) {
const declaration = path.node.declaration;
declaration.declarations.forEach(decl => {
if (t.isIdentifier(decl.id, { name: 'metadata' })) {
// Convert to generateMetadata function
const generateMetadata = t.exportNamedDeclaration(
t.functionDeclaration(
t.identifier('generateMetadata'),
[t.identifier('{ params }')],
t.blockStatement([
t.returnStatement(
t.objectExpression([
t.objectProperty(
t.identifier('title'),
t.stringLiteral('Test Page')
)
])
)
]),
false,
true // async
)
);
path.replaceWith(generateMetadata);
path.node._changed = true;
path.node._changeDescription = 'Converted metadata to generateMetadata function';
changes.push({
type: 'ExportNamedDeclaration',
location: path.node.loc,
description: 'Converted metadata to generateMetadata function'
});
}
});
}
}
};
const result = this.transform(code, visitors, options);
// Reuse the AST from transform() to preserve all mutations
const ast = result.ast;
// Add react-dom/client imports using AST manipulation (production-ready approach)
if (needsCreateRootImport || needsHydrateRootImport) {
const importsToAdd = [];
if (needsCreateRootImport) importsToAdd.push('createRoot');
if (needsHydrateRootImport) importsToAdd.push('hydrateRoot');
let reactDOMClientImport = null;
let insertIndex = 0;
// Find existing react-dom/client import
for (let i = 0; i < ast.program.body.length; i++) {
const node = ast.program.body[i];
if (t.isImportDeclaration(node) && node.source.value === 'react-dom/client') {
reactDOMClientImport = node;
break;
}
// Track insert position (after last import)
if (t.isImportDeclaration(node)) {
insertIndex = i + 1;
}
}
if (reactDOMClientImport) {
// Add to existing import (deduplicate)
const existingImports = new Set(
reactDOMClientImport.specifiers
.filter(s => t.isImportSpecifier(s))
.map(s => s.imported.name)
);
importsToAdd.forEach(importName => {
if (!existingImports.has(importName)) {
reactDOMClientImport.specifiers.push(
t.importSpecifier(t.identifier(importName), t.identifier(importName))
);
}
});
} else {
// Create new import declaration
const newImport = t.importDeclaration(
importsToAdd.map(name => t.importSpecifier(t.identifier(name), t.identifier(name))),
t.stringLiteral('react-dom/client')
);
ast.program.body.splice(insertIndex, 0, newImport);
}
}
// Generate final code from the mutated AST
const transformedCode = this.generateCode(ast, options).code;
return {
code: transformedCode,
changes: changes,
success: true
};
} catch (error) {
return {
code: code,
changes: [],
success: false,
error: error.message
};
}
}
/**
* Layer 6: Testing Fixes (AST-based)
*/
transformTesting(code, options = {}) {
const visitors = {
// Add error boundaries
FunctionDeclaration(path) {
if (path.node.id &&
path.node.id.name.endsWith('Component') &&
!path.node.id.name.includes('ErrorBoundary')) {
// Check if this is a React component
const body = path.node.body;
if (t.isBlockStatement(body)) {
const hasReturn = body.body.some(stmt =>
t.isReturnStatement(stmt) &&
t.isJSXElement(stmt.argument)
);
if (hasReturn) {
// Wrap in error boundary
const errorBoundary = t.functionDeclaration(
t.identifier(`${path.node.id.name}ErrorBoundary`),
[t.identifier('props')],
t.blockStatement([
t.returnStatement(
t.jsxElement(
t.jsxOpeningElement(t.jsxIdentifier('ErrorBoundary'), [], false),
t.jsxClosingElement(t.jsxIdentifier('ErrorBoundary')),
[t.jsxElement(
t.jsxOpeningElement(t.jsxIdentifier(path.node.id.name), [], false),
t.jsxClosingElement(t.jsxIdentifier(path.node.id.name)),
[]
)]
)
)
])
);
path.insertAfter(errorBoundary);
path.node._changed = true;
path.node._changeDescription = 'Wrapped component in error boundary';
}
}
}
},
// Add loading states
JSXElement(path) {
const openingElement = path.node.openingElement;
if (openingElement.name.name === 'Suspense') {
const hasFallback = openingElement.attributes.some(
attr => t.isJSXAttribute(attr) &&
t.isJSXIdentifier(attr.name, { name: 'fallback' })
);
if (!hasFallback) {
const fallbackAttribute = t.jsxAttribute(
t.jsxIdentifier('fallback'),
t.jsxExpressionContainer(
t.jsxElement(
t.jsxOpeningElement(t.jsxIdentifier('div'), [], false),
t.jsxClosingElement(t.jsxIdentifier('div')),
[t.jsxText('Loading...')]
)
)
);
openingElement.attributes.push(fallbackAttribute);
openingElement._changed = true;
openingElement._changeDescription = 'Added fallback to Suspense';
}
}
}
};
return this.transform(code, visitors, options);
}
/**
* Analyze code for issues using AST
*/
analyzeCode(code, options = {}) {
const ast = this.parseCode(code, op