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

501 lines (435 loc) 18.9 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 3: Component Fixes (AST-based) * Adds React component improvements and accessibility using proper code parsing */ const fs = require('fs').promises; const path = require('path'); const parser = require('@babel/parser'); const ASTTransformer = require('../ast-transformer'); async function isRegularFile(filePath) { try { const stat = await fs.stat(filePath); return stat.isFile(); } catch { return false; } } /** * Validate syntax of transformed code * Returns true if code is syntactically valid, false otherwise */ function validateSyntax(code) { try { parser.parse(code, { sourceType: 'module', plugins: ['typescript', 'jsx'], allowImportExportEverywhere: true, strictMode: false }); return true; } catch (error) { return false; } } function applyRegexFallbacks(input) { let code = input; const changes = []; // Ensure UI component imports for Button/Input/Card if used const needButtonImport = /<Button\b/.test(code) && !/import\s*{[^}]*Button[^}]*}\s*from\s*["']@\/components\/ui\/button["']/.test(code); const needInputImport = /<Input\b/.test(code) && !/import\s*{[^}]*Input[^}]*}\s*from\s*["']@\/components\/ui\/input["']/.test(code); const needCardImport = /<Card\b/.test(code) && !/import\s*{[^}]*Card[^}]*}\s*from\s*["']@\/components\/ui\/card["']/.test(code); const importLines = []; if (needButtonImport) importLines.push("import { Button } from \"@/components/ui/button\";"); if (needInputImport) importLines.push("import { Input } from \"@/components/ui/input\";"); if (needCardImport) importLines.push("import { Card } from \"@/components/ui/card\";"); if (importLines.length) { code = importLines.join('\n') + '\n' + code; changes.push({ description: 'Added missing UI imports', location: { line: 1 } }); } // Convert HTML button/input to components const beforeButtons = code; code = code.replace(/<button(\s+[^>]*)?>/g, (m, attrs = '') => `<Button${attrs || ''}>`); code = code.replace(/<\/button>/g, '</Button>'); if (code !== beforeButtons) { changes.push({ description: 'Converted HTML button to Button component', location: {} }); if (!/import\s*{[^}]*Button/.test(beforeButtons)) { if (!/import\s*{[^}]*Button[^}]*}\s*from\s*["']@\/components\/ui\/button["']/.test(code)) { code = `import { Button } from "@/components/ui/button";\n` + code; } } } const beforeInputs = code; code = code.replace(/<input(\s+[^>]*)?\/>/g, (m, attrs = '') => `<Input${attrs || ''} />`); if (code !== beforeInputs) { changes.push({ description: 'Converted HTML input to Input component', location: {} }); if (!/import\s*{[^}]*Input/.test(beforeInputs)) { if (!/import\s*{[^}]*Input[^}]*}\s*from\s*["']@\/components\/ui\/input["']/.test(code)) { code = `import { Input } from "@/components/ui/input";\n` + code; } } } // Add default variant to Button without variant const btnVariantRegex = /<Button(?![^>]*\bvariant=)([^>]*)>/g; code = code.replace(btnVariantRegex, (m, attrs) => { changes.push({ description: 'Added default Button variant', location: {} }); return `<Button variant="default"${attrs}>`; }); // Add default type to Input without type const inputTypeRegex = /<Input(?![^>]*\btype=)([^>]*)\/>/g; code = code.replace(inputTypeRegex, (m, attrs) => { changes.push({ description: 'Added default Input type', location: {} }); return `<Input type="text"${attrs} />`; }); // Add aria-label to <button> turned into <Button> or existing Button without aria-label const ariaBtnRegex = /<Button(?![^>]*\baria-label=)([^>]*)>([^<]*)<\/Button>/g; code = code.replace(ariaBtnRegex, (m, attrs, inner) => { const label = (inner || 'Button').toString().trim() || 'Button'; changes.push({ description: 'Added aria-label to Button', location: {} }); return `<Button aria-label="${label}"${attrs}>${inner}</Button>`; }); // Add alt text to img without alt const imgAltRegex = /<img(?![^>]*\balt=)([^>]*)>/g; code = code.replace(imgAltRegex, (m, attrs) => { changes.push({ description: 'Added alt attribute to img', location: {} }); return `<img alt="Image"${attrs}>`; }); // Tokenize parameters using stack-based parsing const tokenizeParams = (str) => { const tokens = []; let current = ''; let depth = 0; let hasComplex = false; for (const char of str) { if (char === '(' || char === '{' || char === '[') { depth++; if (char === '{' || char === '[') hasComplex = true; current += char; } else if (char === ')' || char === '}' || char === ']') { depth--; current += char; } else if (char === ',' && depth === 0) { tokens.push(current.trim()); current = ''; } else { if (char === '=' || char === ':') hasComplex = true; current += char; } } if (current.trim()) tokens.push(current.trim()); // Extract second identifier if it exists (handle defaults like `idx = 0`) let secondIdentifier = null; if (tokens.length > 1) { const secondToken = tokens[1]; // Check if simple identifier OR identifier with default value const identMatch = secondToken.match(/^([a-zA-Z_$][\w$]*)(?:\s*=|$)/); if (identMatch) { secondIdentifier = identMatch[1]; } } return { tokens, hasComplex, secondIdentifier }; }; // Classify params and generate key expression const classifyMapParams = (params) => { const trimmed = params.trim(); const hadParens = trimmed.startsWith('(') && trimmed.endsWith(')'); const inner = hadParens ? trimmed.slice(1, -1).trim() : trimmed; const { tokens, hasComplex, secondIdentifier } = tokenizeParams(inner); const firstToken = tokens[0]?.trim() || ''; const isSimpleIdentifier = /^[a-zA-Z_$][\w$]*$/.test(firstToken); const isDestructured = firstToken.startsWith('{') || firstToken.startsWith('['); // Regex fallback rule: synthesize index for ALL callbacks without a second param let keyExpr, needsIndex; if (secondIdentifier) { // Has valid second parameter, reuse it keyExpr = secondIdentifier; needsIndex = false; } else { // No second param: synthesize index for determinism keyExpr = 'index'; needsIndex = true; } return { keyExpr, needsIndex, originalParams: trimmed, hadParens }; }; // Insert index parameter properly const insertIndexParam = (params, hadParens) => { const trimmed = params.trim(); if (hadParens) { const inner = trimmed.slice(1, -1).trim(); // Handle empty params: () => becomes (index) => if (!inner) { return '(index)'; } // Insert before closing paren: (item) => becomes (item, index) => return trimmed.slice(0, -1) + ', index)'; } else { // Wrap and add index: item => becomes (item, index) => return `(${trimmed}, index)`; } }; // Add key prop to map items missing keys - PAIRED TAGS (e.g., <Tag>...</Tag>) // IMPROVED REGEX: Properly captures ALL parameter patterns including default params // Uses balanced parentheses matching to handle complex cases code = code.replace( /\{\s*([a-zA-Z_$][\w$]*)\.map\((\((?:[^()]|\([^()]*\))*\)|[^(),]+)\s*=>\s*<([A-Z][\w]*)\b([^>]*)>\s*([^<]*)\s*<\/\3>\s*\)\s*\}/g, (m, arr, params, tag, attrs, inner) => { if (/\bkey=/.test(m)) return m; try { const { keyExpr, needsIndex, originalParams, hadParens } = classifyMapParams(params); const newParams = needsIndex ? insertIndexParam(originalParams, hadParens) : originalParams; changes.push({ description: 'Added key prop in map()', location: {} }); return `{ ${arr}.map(${newParams} => <${tag} key={${keyExpr}}${attrs}>${inner}</${tag}>) }`; } catch (error) { // If classification fails, return original to avoid corruption return m; } } ); // Add key prop to map items missing keys - SELF-CLOSING TAGS (e.g., <Tag />) // Handles patterns like: items.map(item => <TodoItem {...item} />) code = code.replace( /\{\s*([a-zA-Z_$][\w$]*)\.map\((\((?:[^()]|\([^()]*\))*\)|[^(),]+)\s*=>\s*<([A-Z][\w]*)\b([^>]*)\/>\s*\)\s*\}/g, (m, arr, params, tag, attrs) => { if (/\bkey=/.test(m)) return m; try { const { keyExpr, needsIndex, originalParams, hadParens } = classifyMapParams(params); const newParams = needsIndex ? insertIndexParam(originalParams, hadParens) : originalParams; changes.push({ description: 'Added key prop in map() (self-closing)', location: {} }); return `{ ${arr}.map(${newParams} => <${tag} key={${keyExpr}}${attrs} />) }`; } catch (error) { // If classification fails, return original to avoid corruption return m; } } ); // Normalize newlines code = code.replace(/\r\n/g, '\n'); return { code, changes }; } /** * React 19: Component transformation helpers */ function convertForwardRefToDirectRef(code) { let transformedCode = code; // Match complete forwardRef patterns including the closing wrapper // Pattern 1: forwardRef with TS generics - full component const tsFullPattern = /const\s+(\w+)\s*=\s*forwardRef<[^>]+>\s*\(\s*\(\s*(\w+)\s*,\s*(\w+)\s*\)\s*=>\s*\{([\s\S]*?)\}\s*\)\s*;?/g; transformedCode = transformedCode.replace(tsFullPattern, (match, name, propsParam, refParam, body) => { return `const ${name} = ({ ${refParam}, ...${propsParam} }: any) => {${body}};`; }); // Pattern 2: forwardRef standard - full component with block body const stdFullPattern = /const\s+(\w+)\s*=\s*forwardRef\s*\(\s*\(\s*(\w+)\s*,\s*(\w+)\s*\)\s*=>\s*\{([\s\S]*?)\}\s*\)\s*;?/g; transformedCode = transformedCode.replace(stdFullPattern, (match, name, propsParam, refParam, body) => { return `const ${name} = ({ ${refParam}, ...${propsParam} }) => {${body}};`; }); // Pattern 3: forwardRef with single expression (arrow without braces) const arrowSinglePattern = /const\s+(\w+)\s*=\s*forwardRef\s*\(\s*\(\s*(\w+)\s*,\s*(\w+)\s*\)\s*=>\s*\(([\s\S]*?)\)\s*\)\s*;?/g; transformedCode = transformedCode.replace(arrowSinglePattern, (match, name, propsParam, refParam, expr) => { return `const ${name} = ({ ${refParam}, ...${propsParam} }) => (${expr});`; }); // Import cleanup: remove forwardRef from react imports if not used anymore if (!transformedCode.includes('forwardRef(')) { transformedCode = transformedCode.replace(/import\s*{\s*([^}]*)\s*}\s*from\s*['"]react['"];?/g, (match, imports) => { const list = imports.split(',').map(s => s.trim()).filter(s => s && !/^forwardRef\b/.test(s)); return list.length > 0 ? `import { ${list.join(', ')} } from 'react';` : ''; }); transformedCode = transformedCode.replace(/import\s+React\s*,\s*{\s*([^}]*)\s*}\s*from\s*['"]react['"];?/g, (match, imports) => { const list = imports.split(',').map(s => s.trim()).filter(s => s && !/^forwardRef\b/.test(s)); return list.length > 0 ? `import React, { ${list.join(', ')} } from 'react';` : `import React from 'react';`; }); } return transformedCode; } function convertStringRefsToCallbacks(code) { let transformedCode = code; const stringRefPattern = /ref=(["'])([\w$]+)\1/g; transformedCode = transformedCode.replace(stringRefPattern, (match, quote, refName) => { if (code.includes('class ') || code.includes('this.')) { return `ref={el => this.${refName} = el}`; } return `ref={${refName}Ref}`; }); return transformedCode; } function detectPropTypesUsage(code) { const warnings = []; const propTypesPattern = /(\w+)\.propTypes\s*=\s*\{/g; let m; while ((m = propTypesPattern.exec(code)) !== null) { warnings.push({ type: 'react19-propTypes', message: `PropTypes usage detected on ${m[1]} (React 19: consider TypeScript types instead)`, suggestion: 'Migrate PropTypes to TypeScript types or interfaces for function components.' }); } return warnings; } function applyReact19ComponentFixes(code, options = {}) { const { verbose = false } = options; let transformedCode = code; const fixes = []; const warnings = []; // forwardRef if (transformedCode.includes('forwardRef')) { const before = transformedCode; transformedCode = convertForwardRefToDirectRef(transformedCode); if (transformedCode !== before) { fixes.push({ type: 'react19-forwardRef', description: 'Converted forwardRef to direct ref prop' }); if (verbose) process.stdout.write('[INFO] Converted forwardRef to direct ref prop for React 19 compatibility\n'); } } // string refs if (transformedCode.includes('ref="') || transformedCode.includes("ref='")) { const before = transformedCode; transformedCode = convertStringRefsToCallbacks(transformedCode); if (transformedCode !== before) { fixes.push({ type: 'react19-stringRefs', description: 'Converted string refs to callback/useRef pattern' }); if (verbose) process.stdout.write('[INFO] Converted string refs to callback refs for React 19 compatibility\n'); } } // PropTypes warnings const ptWarnings = detectPropTypesUsage(transformedCode); warnings.push(...ptWarnings); if (verbose && ptWarnings.length > 0) { ptWarnings.forEach(w => { process.stdout.write(`[WARNING] ${w.message}\n`); process.stdout.write(`[SUGGESTION] ${w.suggestion}\n`); }); } return { code: transformedCode, fixes, warnings }; } async function transform(code, options = {}) { const { dryRun = false, verbose = false, filePath = process.cwd() } = options; const results = []; let changeCount = 0; let updatedCode = code; const states = [code]; // Track state changes const changes = []; try { // Handle empty input if (!code.trim()) { return { success: false, code, originalCode: code, changeCount: 0, error: 'Empty input file', states: [code], changes }; } // Create backup if not in dry-run mode and is a file const existsAsFile = await isRegularFile(filePath); const backupPath = `${filePath}.backup-${Date.now()}`; if (existsAsFile && !dryRun) { await fs.copyFile(filePath, backupPath); results.push({ type: 'backup', file: filePath, success: true, backupPath }); if (verbose) process.stdout.write(`Created backup at ${path.basename(backupPath)}\n`); } const react19Only = options && options.react19Only === true; if (!react19Only) { // AST-FIRST STRATEGY: Only use regex fallback when AST fails let astSucceeded = false; let astCode = updatedCode; let astChanges = []; try { const transformer = new ASTTransformer(); const transformResult = transformer.transformComponents(updatedCode, { filename: filePath }); if (transformResult && transformResult.success) { astCode = transformResult.code; astChanges = transformResult.changes || []; astSucceeded = astChanges.length > 0; } } catch (error) { // AST failed, will use regex fallback if (verbose) process.stdout.write(`[INFO] AST transformation failed, using regex fallback\n`); } // SMART FALLBACK: Only apply regex if AST didn't make changes if (astSucceeded) { // AST succeeded - use AST result EXCLUSIVELY (no regex) updatedCode = astCode; astChanges.forEach(c => changes.push(c)); if (verbose) process.stdout.write(`[INFO] Using AST transformation (regex skipped)\n`); } else { // AST failed or made no changes - try regex fallback with STRICT validation if (verbose) process.stdout.write(`[INFO] AST made no changes, attempting regex fallback\n`); const beforeRegex = updatedCode; const fallback = applyRegexFallbacks(updatedCode); // VALIDATE: Check if regex made changes and if output is syntactically valid const regexMadeChanges = fallback.code !== beforeRegex; const regexOutputValid = validateSyntax(fallback.code); if (regexMadeChanges && regexOutputValid) { // Regex produced valid changes - accept them updatedCode = fallback.code; fallback.changes.forEach(c => changes.push(c)); if (verbose) process.stdout.write(`[INFO] Regex fallback succeeded with ${fallback.changes.length} changes\n`); } else if (regexMadeChanges && !regexOutputValid) { // Regex produced INVALID code - REJECT and revert if (verbose) process.stdout.write(`[ERROR] Regex fallback produced invalid syntax - REJECTING changes\n`); updatedCode = beforeRegex; } else { // Regex made no changes - keep original if (verbose) process.stdout.write(`[INFO] Regex fallback made no changes\n`); updatedCode = beforeRegex; } } } // Apply React 19 component fixes (forwardRef, string refs, PropTypes warnings) const react19 = applyReact19ComponentFixes(updatedCode, { verbose }); updatedCode = react19.code; react19.fixes.forEach(f => changes.push(f)); react19.warnings.forEach(w => changes.push(w)); changeCount = changes.length; if (updatedCode !== code) states.push(updatedCode); if (dryRun) { if (verbose && changeCount > 0) { process.stdout.write(`[SUCCESS] Layer 3 identified ${changeCount} component fixes (dry-run)\n`); } return { success: true, code: updatedCode, // For L3 tests, dry-run still returns transformed code originalCode: code, changeCount, results, states: [code], changes }; } // Write file if not in dry-run mode 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(`[SUCCESS] Layer 3 applied ${changeCount} component fixes to ${path.basename(filePath)}\n`); } return { success: true, code: updatedCode, originalCode: code, changeCount, results, states, changes }; } catch (error) { if (verbose) process.stderr.write(`[ERROR] Layer 3 failed: ${error.message}\n`); return { success: false, code, originalCode: code, changeCount: 0, error: error.message, states: [code], changes }; } } module.exports = { transform };