@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
258 lines (216 loc) • 8.97 kB
JavaScript
/**
* NeuroLint - Licensed under Apache License 2.0
* Copyright (c) 2025 NeuroLint
* http://www.apache.org/licenses/LICENSE-2.0
*/
/**
* React 19.2 Feature Detector
* Detects opportunities to use React 19.2 features:
* - View Transitions
* - useEffectEvent
* - Activity component
*/
const fs = require('fs').promises;
const path = require('path');
class React192FeatureDetector {
constructor(options = {}) {
this.verbose = options.verbose || false;
this.projectPath = options.projectPath || process.cwd();
}
log(message, level = 'info') {
if (this.verbose) {
const prefix = level === 'error' ? '[ERROR]' : level === 'success' ? '[SUCCESS]' : '[INFO]';
console.log(`${prefix} ${message}`);
}
}
/**
* Main detection entry point
*/
async detect() {
this.log('Scanning for React 19.2 feature opportunities...', 'info');
try {
const opportunities = {
viewTransitions: await this.detectViewTransitionOpportunities(),
useEffectEvent: await this.detectUseEffectEventOpportunities(),
activity: await this.detectActivityOpportunities(),
total: 0
};
opportunities.total = opportunities.viewTransitions.length +
opportunities.useEffectEvent.length +
opportunities.activity.length;
this.printReport(opportunities);
return opportunities;
} catch (error) {
this.log(`Detection failed: ${error.message}`, 'error');
throw error;
}
}
/**
* Detect View Transition opportunities
* Look for manual animation code that could use View Transitions API
*/
async detectViewTransitionOpportunities() {
const opportunities = [];
const files = await this.findSourceFiles();
for (const filePath of files) {
try {
const content = await fs.readFile(filePath, 'utf8');
// Detect manual animation patterns
const hasManualAnimations = content.match(/(useSpring|useTransition|animate|transition|framer-motion|react-spring)/i);
const hasNavigationAnimation = content.match(/(useRouter|useNavigate|Link.*animate)/i);
if (hasManualAnimations || hasNavigationAnimation) {
opportunities.push({
file: filePath,
type: 'view-transition',
description: 'Manual animation detected - consider using React 19.2 View Transitions',
example: `import { useTransition } from 'react';\n\nfunction Page() {\n const [isPending, startTransition] = useTransition();\n \n const navigate = () => {\n startTransition(() => {\n // Navigation with View Transition\n router.push('/next-page');\n });\n };\n}`
});
}
} catch {
// Skip file
}
}
return opportunities;
}
/**
* Detect useEffectEvent opportunities
* Look for non-reactive logic in useEffect
*/
async detectUseEffectEventOpportunities() {
const opportunities = [];
const files = await this.findSourceFiles();
for (const filePath of files) {
try {
const content = await fs.readFile(filePath, 'utf8');
// Detect useEffect with callbacks
const useEffectPattern = /useEffect\s*\(\s*\(\s*\)\s*=>\s*{([^}]+)}/g;
let match;
while ((match = useEffectPattern.exec(content)) !== null) {
const effectBody = match[1];
// Check if effect calls functions/callbacks
const hasCallback = effectBody.match(/(onClick|onSubmit|handleClick|handle\w+)\(/);
const hasDependencies = content.includes('// eslint-disable-next-line react-hooks/exhaustive-deps');
if (hasCallback || hasDependencies) {
opportunities.push({
file: filePath,
type: 'use-effect-event',
description: 'useEffect with callbacks detected - consider using useEffectEvent to extract non-reactive logic',
example: `import { useEffectEvent } from 'react';\n\nfunction Component({ onClick }) {\n const onClickEvent = useEffectEvent(onClick);\n \n useEffect(() => {\n // No need to include onClick in dependencies\n onClickEvent();\n }, []); // No exhaustive-deps warning\n}`
});
break; // One per file is enough
}
}
} catch {
// Skip file
}
}
return opportunities;
}
/**
* Detect Activity component opportunities
* Look for background rendering patterns
*/
async detectActivityOpportunities() {
const opportunities = [];
const files = await this.findSourceFiles();
for (const filePath of files) {
try {
const content = await fs.readFile(filePath, 'utf8');
// Detect display:none patterns
const hasHiddenComponent = content.match(/display:\s*['"]none['"]/);
const hasConditionalRender = content.match(/{(\w+)\s*\?\s*<\w+/g);
const hasHiddenAttr = content.match(/hidden={true}/);
if (hasHiddenComponent || (hasConditionalRender && content.includes('hidden'))) {
opportunities.push({
file: filePath,
type: 'activity',
description: 'Hidden component detected - consider using React 19.2 Activity component to maintain state',
example: `import { Activity } from 'react';\n\nfunction Component({ isVisible }) {\n return (\n <Activity mode={isVisible ? 'visible' : 'hidden'}>\n {/* Component maintains state even when hidden */}\n <ExpensiveComponent />\n </Activity>\n );\n}`
});
}
} catch {
// Skip file
}
}
return opportunities;
}
/**
* Print detection report
*/
printReport(opportunities) {
console.log('\n' + '='.repeat(60));
console.log('React 19.2 Feature Opportunities');
console.log('='.repeat(60));
console.log(`\nTotal Opportunities: ${opportunities.total}`);
if (opportunities.viewTransitions.length > 0) {
console.log(`\n[View Transitions] (${opportunities.viewTransitions.length} opportunities)`);
opportunities.viewTransitions.slice(0, 3).forEach(opp => {
console.log(` - ${path.basename(opp.file)}: ${opp.description}`);
});
if (opportunities.viewTransitions.length > 3) {
console.log(` ... and ${opportunities.viewTransitions.length - 3} more`);
}
console.log(`\n Example:`);
console.log(` ${opportunities.viewTransitions[0].example.split('\n').join('\n ')}`);
}
if (opportunities.useEffectEvent.length > 0) {
console.log(`\n[useEffectEvent] (${opportunities.useEffectEvent.length} opportunities)`);
opportunities.useEffectEvent.slice(0, 3).forEach(opp => {
console.log(` - ${path.basename(opp.file)}: ${opp.description}`);
});
if (opportunities.useEffectEvent.length > 3) {
console.log(` ... and ${opportunities.useEffectEvent.length - 3} more`);
}
console.log(`\n Example:`);
console.log(` ${opportunities.useEffectEvent[0].example.split('\n').join('\n ')}`);
}
if (opportunities.activity.length > 0) {
console.log(`\n[Activity Component] (${opportunities.activity.length} opportunities)`);
opportunities.activity.slice(0, 3).forEach(opp => {
console.log(` - ${path.basename(opp.file)}: ${opp.description}`);
});
if (opportunities.activity.length > 3) {
console.log(` ... and ${opportunities.activity.length - 3} more`);
}
console.log(`\n Example:`);
console.log(` ${opportunities.activity[0].example.split('\n').join('\n ')}`);
}
if (opportunities.total === 0) {
console.log('\nNo React 19.2 feature opportunities detected.');
console.log('Your codebase may already be using modern patterns, or these features may not be applicable.');
}
console.log('\n' + '='.repeat(60) + '\n');
}
/**
* Helper: Find all source files
*/
async findSourceFiles() {
const files = [];
const extensions = ['.ts', '.tsx', '.js', '.jsx'];
const ignoreDirs = ['node_modules', '.next', 'dist', 'build', '.git'];
const scan = async (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 {
// Skip inaccessible directories
}
};
await scan(this.projectPath);
return files;
}
}
module.exports = React192FeatureDetector;