Started tasks to migrate runtime to React 19. Added phase 3 projects

This commit is contained in:
Richard Osborne
2025-12-13 22:37:44 +01:00
parent 8dd4f395c0
commit 1477a29ff7
55 changed files with 49205 additions and 281 deletions

View File

@@ -0,0 +1,587 @@
/**
* MigrationSession
*
* State machine for managing the React 19 migration process.
* Handles step transitions, progress tracking, and session persistence.
*
* @module noodl-editor/models/migration
* @since 1.2.0
*/
import { EventDispatcher } from '../../../../shared/utils/EventDispatcher';
import { detectRuntimeVersion, scanProjectForMigration } from './ProjectScanner';
import {
MigrationSession as MigrationSessionState,
MigrationStep,
MigrationScan,
MigrationProgress,
MigrationResult,
MigrationLogEntry,
AIConfig,
AIBudget,
AIPreferences,
RuntimeVersionInfo
} from './types';
// =============================================================================
// Constants
// =============================================================================
/**
* Default AI budget configuration
*/
const DEFAULT_AI_BUDGET: AIBudget = {
maxPerSession: 5.0, // $5 max per migration session
spent: 0,
pauseIncrement: 1.0, // Pause and confirm every $1
showEstimates: true
};
/**
* Default AI preferences
*/
const DEFAULT_AI_PREFERENCES: AIPreferences = {
preferFunctional: true,
preserveComments: true,
verboseOutput: false
};
// =============================================================================
// MigrationSessionManager
// =============================================================================
/**
* Manages the migration session state machine.
* Extends EventDispatcher for reactive updates to UI.
*/
export class MigrationSessionManager extends EventDispatcher {
private session: MigrationSessionState | null = null;
/**
* Creates a new migration session for a project
*/
async createSession(
sourcePath: string,
projectName: string
): Promise<MigrationSessionState> {
// Generate unique session ID
const sessionId = `migration-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Detect runtime version
const versionInfo = await detectRuntimeVersion(sourcePath);
// Only allow migration of React 17 projects
if (versionInfo.version !== 'react17' && versionInfo.version !== 'unknown') {
throw new Error(
`Project is already using ${versionInfo.version}. Migration not needed.`
);
}
// Create session
this.session = {
id: sessionId,
step: 'confirm',
source: {
path: sourcePath,
name: projectName,
runtimeVersion: 'react17'
},
target: {
path: '', // Will be set when user confirms
copied: false
}
};
this.notifyListeners('sessionCreated', { session: this.session });
return this.session;
}
/**
* Gets the current session
*/
getSession(): MigrationSessionState | null {
return this.session;
}
/**
* Gets the current step
*/
getCurrentStep(): MigrationStep | null {
return this.session?.step ?? null;
}
/**
* Validates if a step transition is allowed
*/
private canTransitionTo(from: MigrationStep, to: MigrationStep): boolean {
const allowedTransitions: Record<MigrationStep, MigrationStep[]> = {
confirm: ['scanning'],
scanning: ['report', 'failed'],
report: ['configureAi', 'migrating'], // Can skip AI config if no AI needed
configureAi: ['migrating'],
migrating: ['complete', 'failed'],
complete: [], // Terminal state
failed: ['confirm'] // Can retry from beginning
};
return allowedTransitions[from]?.includes(to) ?? false;
}
/**
* Transitions to a new step
*/
async transitionTo(step: MigrationStep): Promise<void> {
if (!this.session) {
throw new Error('No active migration session');
}
const currentStep = this.session.step;
if (!this.canTransitionTo(currentStep, step)) {
throw new Error(
`Invalid transition from "${currentStep}" to "${step}"`
);
}
const previousStep = this.session.step;
this.session.step = step;
this.notifyListeners('stepChanged', {
session: this.session,
previousStep,
newStep: step
});
}
/**
* Sets the target path for the migrated project copy
*/
setTargetPath(targetPath: string): void {
if (!this.session) {
throw new Error('No active migration session');
}
this.session.target.path = targetPath;
this.notifyListeners('targetPathSet', {
session: this.session,
targetPath
});
}
/**
* Starts scanning the project for migration needs
*/
async startScanning(): Promise<MigrationScan> {
if (!this.session) {
throw new Error('No active migration session');
}
await this.transitionTo('scanning');
try {
const scan = await scanProjectForMigration(
this.session.source.path,
(progress, currentItem, stats) => {
this.notifyListeners('scanProgress', {
session: this.session,
progress,
currentItem,
stats
});
}
);
this.session.scan = scan;
await this.transitionTo('report');
this.notifyListeners('scanComplete', {
session: this.session,
scan
});
return scan;
} catch (error) {
await this.transitionTo('failed');
throw error;
}
}
/**
* Configures AI settings for the migration
*/
configureAI(config: Partial<AIConfig>): void {
if (!this.session) {
throw new Error('No active migration session');
}
this.session.ai = {
enabled: config.enabled ?? false,
apiKey: config.apiKey,
budget: config.budget ?? DEFAULT_AI_BUDGET,
preferences: config.preferences ?? DEFAULT_AI_PREFERENCES
};
this.notifyListeners('aiConfigured', {
session: this.session,
ai: this.session.ai
});
}
/**
* Starts the migration process
*/
async startMigration(): Promise<MigrationResult> {
if (!this.session) {
throw new Error('No active migration session');
}
if (!this.session.scan) {
throw new Error('Project must be scanned before migration');
}
await this.transitionTo('migrating');
const startTime = Date.now();
// Initialize progress
this.session.progress = {
phase: 'copying',
current: 0,
total: this.getTotalMigrationSteps(),
log: []
};
try {
// Phase 1: Copy project
await this.executeCopyPhase();
// Phase 2: Automatic migrations
await this.executeAutomaticPhase();
// Phase 3: AI-assisted migrations (if enabled)
if (this.session.ai?.enabled) {
await this.executeAIAssistedPhase();
}
// Phase 4: Finalize
await this.executeFinalizePhase();
// Calculate result
const result: MigrationResult = {
success: true,
migrated: this.getSuccessfulMigrationCount(),
needsReview: this.getNeedsReviewCount(),
failed: this.getFailedCount(),
totalCost: this.session.ai?.budget.spent ?? 0,
duration: Date.now() - startTime
};
this.session.result = result;
await this.transitionTo('complete');
this.notifyListeners('migrationComplete', {
session: this.session,
result
});
return result;
} catch (error) {
const result: MigrationResult = {
success: false,
migrated: this.getSuccessfulMigrationCount(),
needsReview: this.getNeedsReviewCount(),
failed: this.getFailedCount() + 1,
totalCost: this.session.ai?.budget.spent ?? 0,
duration: Date.now() - startTime
};
this.session.result = result;
await this.transitionTo('failed');
this.notifyListeners('migrationFailed', {
session: this.session,
error,
result
});
throw error;
}
}
/**
* Adds a log entry to the migration progress
*/
addLogEntry(entry: Omit<MigrationLogEntry, 'timestamp'>): void {
if (!this.session?.progress) return;
const logEntry: MigrationLogEntry = {
...entry,
timestamp: new Date().toISOString()
};
this.session.progress.log.push(logEntry);
this.notifyListeners('logEntry', {
session: this.session,
entry: logEntry
});
}
/**
* Updates migration progress
*/
updateProgress(updates: Partial<MigrationProgress>): void {
if (!this.session?.progress) return;
Object.assign(this.session.progress, updates);
this.notifyListeners('progressUpdated', {
session: this.session,
progress: this.session.progress
});
}
/**
* Cancels the current migration session
*/
cancelSession(): void {
if (!this.session) return;
const session = this.session;
this.session = null;
this.notifyListeners('sessionCancelled', { session });
}
/**
* Resets a failed session to retry
*/
async resetForRetry(): Promise<void> {
if (!this.session) {
throw new Error('No active migration session');
}
if (this.session.step !== 'failed') {
throw new Error('Can only reset failed sessions');
}
await this.transitionTo('confirm');
// Clear progress and result
this.session.progress = undefined;
this.session.result = undefined;
this.session.target.copied = false;
this.notifyListeners('sessionReset', { session: this.session });
}
// ===========================================================================
// Private Migration Phase Methods
// ===========================================================================
private getTotalMigrationSteps(): number {
if (!this.session?.scan) return 0;
const { categories } = this.session.scan;
return (
1 + // Copy phase
categories.automatic.length +
categories.simpleFixes.length +
categories.needsReview.length +
1 // Finalize phase
);
}
private getSuccessfulMigrationCount(): number {
// Count from log entries
return (
this.session?.progress?.log.filter((l) => l.level === 'success').length ?? 0
);
}
private getNeedsReviewCount(): number {
return this.session?.scan?.categories.needsReview.length ?? 0;
}
private getFailedCount(): number {
return (
this.session?.progress?.log.filter((l) => l.level === 'error').length ?? 0
);
}
private async executeCopyPhase(): Promise<void> {
this.updateProgress({ phase: 'copying', current: 0 });
this.addLogEntry({
level: 'info',
message: 'Creating project copy...'
});
// TODO: Implement actual file copying using filesystem
// For now, this is a placeholder
await this.simulateDelay(500);
if (this.session) {
this.session.target.copied = true;
}
this.addLogEntry({
level: 'success',
message: 'Project copied successfully'
});
this.updateProgress({ current: 1 });
}
private async executeAutomaticPhase(): Promise<void> {
if (!this.session?.scan) return;
this.updateProgress({ phase: 'automatic' });
this.addLogEntry({
level: 'info',
message: 'Applying automatic migrations...'
});
const { automatic, simpleFixes } = this.session.scan.categories;
const allAutomatic = [...automatic, ...simpleFixes];
for (let i = 0; i < allAutomatic.length; i++) {
const component = allAutomatic[i];
this.updateProgress({
current: 1 + i,
currentComponent: component.name
});
// TODO: Implement actual automatic fixes
await this.simulateDelay(100);
this.addLogEntry({
level: 'success',
component: component.name,
message: `Migrated automatically`
});
}
}
private async executeAIAssistedPhase(): Promise<void> {
if (!this.session?.scan || !this.session.ai?.enabled) return;
this.updateProgress({ phase: 'ai-assisted' });
this.addLogEntry({
level: 'info',
message: 'Starting AI-assisted migration...'
});
const { needsReview } = this.session.scan.categories;
for (let i = 0; i < needsReview.length; i++) {
const component = needsReview[i];
this.updateProgress({
currentComponent: component.name
});
// TODO: Implement actual AI migration using Claude API
await this.simulateDelay(200);
this.addLogEntry({
level: 'warning',
component: component.name,
message: 'AI migration not yet implemented - marked for manual review'
});
}
}
private async executeFinalizePhase(): Promise<void> {
this.updateProgress({ phase: 'finalizing' });
this.addLogEntry({
level: 'info',
message: 'Finalizing migration...'
});
// TODO: Update project.json with migration metadata
await this.simulateDelay(200);
this.addLogEntry({
level: 'success',
message: 'Migration finalized'
});
this.updateProgress({
current: this.getTotalMigrationSteps()
});
}
private simulateDelay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
// =============================================================================
// Singleton Export
// =============================================================================
/**
* Global migration session manager instance
*/
export const migrationSessionManager = new MigrationSessionManager();
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Checks if a project needs migration
*/
export async function checkProjectNeedsMigration(
projectPath: string
): Promise<{
needsMigration: boolean;
versionInfo: RuntimeVersionInfo;
}> {
const versionInfo = await detectRuntimeVersion(projectPath);
return {
needsMigration: versionInfo.version === 'react17' || versionInfo.version === 'unknown',
versionInfo
};
}
/**
* Gets a human-readable step label
*/
export function getStepLabel(step: MigrationStep): string {
const labels: Record<MigrationStep, string> = {
confirm: 'Confirm Migration',
scanning: 'Scanning Project',
report: 'Migration Report',
configureAi: 'Configure AI Assistance',
migrating: 'Migrating',
complete: 'Migration Complete',
failed: 'Migration Failed'
};
return labels[step];
}
/**
* Gets the step number for progress display (1-indexed)
*/
export function getStepNumber(step: MigrationStep): number {
const order: MigrationStep[] = [
'confirm',
'scanning',
'report',
'configureAi',
'migrating',
'complete'
];
const index = order.indexOf(step);
return index >= 0 ? index + 1 : 0;
}
/**
* Gets total number of steps for progress display
*/
export function getTotalSteps(includeAi: boolean): number {
return includeAi ? 6 : 5;
}

View File

@@ -0,0 +1,619 @@
/**
* ProjectScanner
*
* Handles detection of project runtime versions and scanning for legacy React patterns
* that need migration. Uses a 5-tier detection system with confidence levels.
*
* @module noodl-editor/models/migration
* @since 1.2.0
*/
import { filesystem } from '@noodl/platform';
import {
RuntimeVersionInfo,
RuntimeVersion,
LegacyPatternScan,
LegacyPattern,
MigrationScan,
ComponentMigrationInfo,
MigrationIssue
} from './types';
// =============================================================================
// Constants
// =============================================================================
/**
* OpenNoodl version number that introduced React 19
* Projects created with this version or later use React 19
*/
const REACT19_MIN_VERSION = '1.2.0';
/**
* Date when OpenNoodl fork was created
* Projects before this date are assumed to be legacy React 17
*/
const OPENNOODL_FORK_DATE = new Date('2024-01-01');
/**
* Patterns to detect legacy React code that needs migration
*/
const LEGACY_PATTERNS: LegacyPattern[] = [
{
regex: /componentWillMount\s*\(/,
name: 'componentWillMount',
type: 'componentWillMount',
description: 'componentWillMount lifecycle method (removed in React 19)',
autoFixable: false
},
{
regex: /componentWillReceiveProps\s*\(/,
name: 'componentWillReceiveProps',
type: 'componentWillReceiveProps',
description: 'componentWillReceiveProps lifecycle method (removed in React 19)',
autoFixable: false
},
{
regex: /componentWillUpdate\s*\(/,
name: 'componentWillUpdate',
type: 'componentWillUpdate',
description: 'componentWillUpdate lifecycle method (removed in React 19)',
autoFixable: false
},
{
regex: /UNSAFE_componentWillMount/,
name: 'UNSAFE_componentWillMount',
type: 'unsafeLifecycle',
description: 'UNSAFE_componentWillMount lifecycle method (removed in React 19)',
autoFixable: false
},
{
regex: /UNSAFE_componentWillReceiveProps/,
name: 'UNSAFE_componentWillReceiveProps',
type: 'unsafeLifecycle',
description: 'UNSAFE_componentWillReceiveProps lifecycle method (removed in React 19)',
autoFixable: false
},
{
regex: /UNSAFE_componentWillUpdate/,
name: 'UNSAFE_componentWillUpdate',
type: 'unsafeLifecycle',
description: 'UNSAFE_componentWillUpdate lifecycle method (removed in React 19)',
autoFixable: false
},
{
regex: /ref\s*=\s*["'][^"']+["']/,
name: 'String ref',
type: 'stringRef',
description: 'String refs are removed in React 19, use createRef() or useRef()',
autoFixable: true
},
{
regex: /contextTypes\s*=/,
name: 'Legacy contextTypes',
type: 'legacyContext',
description: 'Legacy contextTypes API is removed in React 19',
autoFixable: false
},
{
regex: /childContextTypes\s*=/,
name: 'Legacy childContextTypes',
type: 'legacyContext',
description: 'Legacy childContextTypes API is removed in React 19',
autoFixable: false
},
{
regex: /getChildContext\s*\(/,
name: 'getChildContext',
type: 'legacyContext',
description: 'getChildContext method is removed in React 19',
autoFixable: false
},
{
regex: /React\.createFactory/,
name: 'createFactory',
type: 'createFactory',
description: 'React.createFactory is removed in React 19',
autoFixable: true
},
{
regex: /ReactDOM\.findDOMNode/,
name: 'findDOMNode',
type: 'findDOMNode',
description: 'ReactDOM.findDOMNode is removed in React 19',
autoFixable: false
},
{
regex: /ReactDOM\.render\s*\(/,
name: 'ReactDOM.render',
type: 'reactDomRender',
description: 'ReactDOM.render is removed in React 19, use createRoot',
autoFixable: true
}
];
// =============================================================================
// Project JSON Types
// =============================================================================
interface ProjectJson {
name?: string;
version?: string;
editorVersion?: string;
runtimeVersion?: RuntimeVersion;
migratedFrom?: {
version: 'react17';
date: string;
originalPath: string;
aiAssisted: boolean;
};
createdAt?: string;
components?: Array<{
id: string;
name: string;
graph?: unknown;
}>;
metadata?: Record<string, unknown>;
settings?: Record<string, unknown>;
}
// =============================================================================
// Version Detection
// =============================================================================
/**
* Compares two semantic version strings
* @returns -1 if a < b, 0 if a == b, 1 if a > b
*/
function compareVersions(a: string, b: string): number {
const partsA = a.split('.').map(Number);
const partsB = b.split('.').map(Number);
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
const numA = partsA[i] || 0;
const numB = partsB[i] || 0;
if (numA < numB) return -1;
if (numA > numB) return 1;
}
return 0;
}
/**
* Reads the project.json file from a project directory
*/
async function readProjectJson(projectPath: string): Promise<ProjectJson | null> {
try {
const projectJsonPath = `${projectPath}/project.json`;
const content = await filesystem.readJson(projectJsonPath);
return content as ProjectJson;
} catch (error) {
console.warn(`Could not read project.json from ${projectPath}:`, error);
return null;
}
}
/**
* Gets the creation date of a project from filesystem metadata
* Note: The IFileSystem interface doesn't expose birthtime, so this returns null
* and relies on other detection methods. Could be enhanced in platform-electron.
*/
async function getProjectCreationDate(_projectPath: string): Promise<Date | null> {
// IFileSystem doesn't have stat or birthtime access
// This would need platform-specific implementation
return null;
}
/**
* Detects the runtime version of a project using a 5-tier detection system.
*
* Detection order:
* 1. Explicit runtimeVersion field in project.json (highest confidence)
* 2. migratedFrom metadata (indicates already migrated)
* 3. Editor version number comparison
* 4. Legacy code pattern scanning
* 5. Project creation date heuristic (lowest confidence)
*
* @param projectPath - Path to the project directory
* @returns Runtime version info with confidence level
*/
export async function detectRuntimeVersion(projectPath: string): Promise<RuntimeVersionInfo> {
const indicators: string[] = [];
// Read project.json
const projectJson = await readProjectJson(projectPath);
if (!projectJson) {
return {
version: 'unknown',
confidence: 'low',
indicators: ['Could not read project.json']
};
}
// ==========================================================================
// Check 1: Explicit runtimeVersion field (most reliable)
// ==========================================================================
if (projectJson.runtimeVersion) {
return {
version: projectJson.runtimeVersion,
confidence: 'high',
indicators: ['Explicit runtimeVersion field in project.json']
};
}
// ==========================================================================
// Check 2: Look for migratedFrom field (indicates already migrated)
// ==========================================================================
if (projectJson.migratedFrom) {
return {
version: 'react19',
confidence: 'high',
indicators: ['Project has migratedFrom metadata - already migrated']
};
}
// ==========================================================================
// Check 3: Check editor version number
// OpenNoodl 1.2+ = React 19, earlier = React 17
// ==========================================================================
const editorVersion = projectJson.editorVersion || projectJson.version;
if (editorVersion && typeof editorVersion === 'string') {
// Clean up version string (remove 'v' prefix if present)
const cleanVersion = editorVersion.replace(/^v/, '');
// Check if it's a valid semver-like string
if (/^\d+\.\d+/.test(cleanVersion)) {
const comparison = compareVersions(cleanVersion, REACT19_MIN_VERSION);
if (comparison >= 0) {
indicators.push(`Editor version ${editorVersion} >= ${REACT19_MIN_VERSION}`);
return {
version: 'react19',
confidence: 'high',
indicators
};
} else {
indicators.push(`Editor version ${editorVersion} < ${REACT19_MIN_VERSION}`);
return {
version: 'react17',
confidence: 'high',
indicators
};
}
}
}
// ==========================================================================
// Check 4: Heuristic - scan for React 17 specific patterns in custom code
// ==========================================================================
const legacyPatterns = await scanForLegacyPatterns(projectPath);
if (legacyPatterns.found) {
indicators.push(`Found legacy React patterns: ${legacyPatterns.patterns.join(', ')}`);
return {
version: 'react17',
confidence: 'medium',
indicators
};
}
// ==========================================================================
// Check 5: Project creation date heuristic
// Projects created before OpenNoodl fork are assumed React 17
// ==========================================================================
const createdAt = projectJson.createdAt
? new Date(projectJson.createdAt)
: await getProjectCreationDate(projectPath);
if (createdAt && createdAt < OPENNOODL_FORK_DATE) {
indicators.push(`Project created ${createdAt.toISOString()} (before OpenNoodl fork)`);
return {
version: 'react17',
confidence: 'medium',
indicators
};
}
// ==========================================================================
// Default: Unknown - could be either version
// ==========================================================================
return {
version: 'unknown',
confidence: 'low',
indicators: ['No version indicators found - manual verification recommended']
};
}
// =============================================================================
// Legacy Pattern Scanning
// =============================================================================
/**
* Scans a project directory for legacy React patterns in JavaScript files.
* Looks for componentWillMount, string refs, legacy context, etc.
*
* @param projectPath - Path to the project directory
* @returns Object containing found patterns and file locations
*/
export async function scanForLegacyPatterns(projectPath: string): Promise<LegacyPatternScan> {
const result: LegacyPatternScan = {
found: false,
patterns: [],
files: []
};
try {
// List all files in the project directory
const allFiles = await listFilesRecursively(projectPath);
// Filter to JS/JSX/TS/TSX files, excluding node_modules
const jsFiles = allFiles.filter((file) => {
const isJsFile = /\.(js|jsx|ts|tsx)$/.test(file);
const isNotNodeModules = !file.includes('node_modules');
return isJsFile && isNotNodeModules;
});
// Scan each file for legacy patterns
for (const file of jsFiles) {
try {
const content = await filesystem.readFile(file);
const lines = content.split('\n');
for (const pattern of LEGACY_PATTERNS) {
lines.forEach((line, index) => {
if (pattern.regex.test(line)) {
result.found = true;
if (!result.patterns.includes(pattern.name)) {
result.patterns.push(pattern.name);
}
result.files.push({
path: file,
line: index + 1,
pattern: pattern.name,
content: line.trim()
});
}
});
}
} catch (readError) {
// Skip files we can't read
console.warn(`Could not read file ${file}:`, readError);
}
}
} catch (error) {
console.error('Error scanning for legacy patterns:', error);
}
return result;
}
/**
* Recursively lists all files in a directory
*/
async function listFilesRecursively(dirPath: string): Promise<string[]> {
const files: string[] = [];
try {
const entries = await filesystem.listDirectory(dirPath);
for (const entry of entries) {
if (entry.isDirectory) {
// Skip node_modules and hidden directories
if (entry.name.startsWith('.') || entry.name === 'node_modules') {
continue;
}
const subFiles = await listFilesRecursively(entry.fullPath);
files.push(...subFiles);
} else {
files.push(entry.fullPath);
}
}
} catch (error) {
console.warn(`Could not list directory ${dirPath}:`, error);
}
return files;
}
// =============================================================================
// Full Project Scan
// =============================================================================
/**
* Counter for generating unique issue IDs
*/
let issueIdCounter = 0;
/**
* Generates a unique issue ID
*/
function generateIssueId(): string {
return `issue-${Date.now()}-${++issueIdCounter}`;
}
/**
* Performs a full migration scan of a project.
* Analyzes all components and JS files for migration needs.
*
* @param projectPath - Path to the project directory
* @param onProgress - Optional callback for progress updates
* @returns Full migration scan results
*/
export async function scanProjectForMigration(
projectPath: string,
onProgress?: (progress: number, currentItem: string, stats: { components: number; nodes: number; jsFiles: number }) => void
): Promise<MigrationScan> {
const projectJson = await readProjectJson(projectPath);
const stats = {
components: 0,
nodes: 0,
jsFiles: 0
};
const categories: MigrationScan['categories'] = {
automatic: [],
simpleFixes: [],
needsReview: []
};
// Count components from project.json
if (projectJson?.components) {
stats.components = projectJson.components.length;
// Count total nodes across all components
projectJson.components.forEach((component) => {
if (component.graph && typeof component.graph === 'object') {
const graph = component.graph as { roots?: Array<{ children?: unknown[] }> };
if (graph.roots) {
stats.nodes += countNodesInRoots(graph.roots);
}
}
});
}
// Scan JavaScript files for issues
const allFiles = await listFilesRecursively(projectPath);
const jsFiles = allFiles.filter(
(file) => /\.(js|jsx|ts|tsx)$/.test(file) && !file.includes('node_modules')
);
stats.jsFiles = jsFiles.length;
// Group issues by file/component
const fileIssues: Map<string, MigrationIssue[]> = new Map();
for (let i = 0; i < jsFiles.length; i++) {
const file = jsFiles[i];
const relativePath = file.replace(projectPath, '').replace(/^\//, '');
onProgress?.((i / jsFiles.length) * 100, relativePath, stats);
try {
const content = await filesystem.readFile(file);
const lines = content.split('\n');
const issues: MigrationIssue[] = [];
for (const pattern of LEGACY_PATTERNS) {
lines.forEach((line, lineIndex) => {
if (pattern.regex.test(line)) {
issues.push({
id: generateIssueId(),
type: pattern.type,
description: pattern.description,
location: {
file: relativePath,
line: lineIndex + 1
},
autoFixable: pattern.autoFixable,
fix: pattern.autoFixable
? { type: 'automatic', description: `Auto-fix ${pattern.name}` }
: { type: 'ai-required', description: `AI assistance needed for ${pattern.name}` }
});
}
});
}
if (issues.length > 0) {
fileIssues.set(relativePath, issues);
}
} catch {
// Skip files we can't read
}
}
// Categorize files by issue severity
for (const [filePath, issues] of fileIssues.entries()) {
const hasAutoFixableOnly = issues.every((issue) => issue.autoFixable);
const estimatedCost = estimateAICost(issues.length);
const componentInfo: ComponentMigrationInfo = {
id: filePath.replace(/[^a-zA-Z0-9]/g, '-'),
name: filePath.split('/').pop() || filePath,
path: filePath,
issues,
estimatedCost: hasAutoFixableOnly ? 0 : estimatedCost
};
if (hasAutoFixableOnly) {
categories.simpleFixes.push(componentInfo);
} else {
categories.needsReview.push(componentInfo);
}
}
// All components without issues are automatic
if (projectJson?.components) {
const filesWithIssues = new Set(fileIssues.keys());
projectJson.components.forEach((component) => {
// Check if this component has any JS with issues
// For now, assume all components without explicit issues are automatic
const componentPath = component.name.replace(/\//g, '-');
if (!filesWithIssues.has(componentPath)) {
categories.automatic.push({
id: component.id,
name: component.name,
path: component.name,
issues: [],
estimatedCost: 0
});
}
});
}
return {
completedAt: new Date().toISOString(),
totalComponents: stats.components,
totalNodes: stats.nodes,
customJsFiles: stats.jsFiles,
categories
};
}
/**
* Counts nodes in a graph roots array
*/
function countNodesInRoots(roots: Array<{ children?: unknown[] }>): number {
let count = 0;
function countRecursive(nodes: unknown[]): void {
for (const node of nodes) {
count++;
if (node && typeof node === 'object' && 'children' in node) {
const children = (node as { children?: unknown[] }).children;
if (Array.isArray(children)) {
countRecursive(children);
}
}
}
}
countRecursive(roots);
return count;
}
/**
* Estimates AI cost for migrating issues
* Based on ~$0.01 per simple issue, ~$0.05 per complex issue
*/
function estimateAICost(issueCount: number): number {
// Rough estimate: $0.03 per issue on average
return issueCount * 0.03;
}
// =============================================================================
// Exports
// =============================================================================
export {
LEGACY_PATTERNS,
REACT19_MIN_VERSION,
OPENNOODL_FORK_DATE,
readProjectJson,
compareVersions
};
export type { ProjectJson };

View File

@@ -0,0 +1,35 @@
/**
* Migration Module
*
* Provides tools for migrating legacy Noodl projects (React 17)
* to the new OpenNoodl runtime (React 19).
*
* @module noodl-editor/models/migration
* @since 1.2.0
*/
// Types
export * from './types';
// Project Scanner
export {
detectRuntimeVersion,
scanForLegacyPatterns,
scanProjectForMigration,
LEGACY_PATTERNS,
REACT19_MIN_VERSION,
OPENNOODL_FORK_DATE,
readProjectJson,
compareVersions
} from './ProjectScanner';
export type { ProjectJson } from './ProjectScanner';
// Migration Session Manager
export {
MigrationSessionManager,
migrationSessionManager,
checkProjectNeedsMigration,
getStepLabel,
getStepNumber,
getTotalSteps
} from './MigrationSession';

View File

@@ -0,0 +1,347 @@
/**
* Migration System Types
*
* Type definitions for the React 19 migration system that allows users
* to upgrade legacy Noodl projects (React 17) to the new OpenNoodl runtime (React 19).
*
* @module noodl-editor/models/migration
* @since 1.2.0
*/
// =============================================================================
// Runtime Version Types
// =============================================================================
export type RuntimeVersion = 'react17' | 'react19' | 'unknown';
export type ConfidenceLevel = 'high' | 'medium' | 'low';
/**
* Result of detecting the runtime version of a project
*/
export interface RuntimeVersionInfo {
version: RuntimeVersion;
confidence: ConfidenceLevel;
indicators: string[];
}
// =============================================================================
// Migration Issue Types
// =============================================================================
export type MigrationIssueType =
| 'componentWillMount'
| 'componentWillReceiveProps'
| 'componentWillUpdate'
| 'unsafeLifecycle'
| 'stringRef'
| 'legacyContext'
| 'createFactory'
| 'findDOMNode'
| 'reactDomRender'
| 'other';
/**
* A specific migration issue found in a component
*/
export interface MigrationIssue {
id: string;
type: MigrationIssueType;
description: string;
location: {
file: string;
line: number;
column?: number;
};
autoFixable: boolean;
fix?: {
type: 'automatic' | 'ai-required';
description: string;
};
}
/**
* Information about a component that needs migration
*/
export interface ComponentMigrationInfo {
id: string;
name: string;
path: string;
issues: MigrationIssue[];
estimatedCost?: number;
}
// =============================================================================
// Migration Session Types
// =============================================================================
export type MigrationStep =
| 'confirm'
| 'scanning'
| 'report'
| 'configureAi'
| 'migrating'
| 'complete'
| 'failed';
export type MigrationPhase = 'copying' | 'automatic' | 'ai-assisted' | 'finalizing';
/**
* Results of scanning a project for migration needs
*/
export interface MigrationScan {
completedAt: string;
totalComponents: number;
totalNodes: number;
customJsFiles: number;
categories: {
/** Components that migrate automatically (no code changes) */
automatic: ComponentMigrationInfo[];
/** Components with simple, auto-fixable issues */
simpleFixes: ComponentMigrationInfo[];
/** Components that need manual review or AI assistance */
needsReview: ComponentMigrationInfo[];
};
}
/**
* A single entry in the migration log
*/
export interface MigrationLogEntry {
timestamp: string;
level: 'info' | 'success' | 'warning' | 'error';
component?: string;
message: string;
details?: string;
cost?: number;
}
/**
* Progress information during migration
*/
export interface MigrationProgress {
phase: MigrationPhase;
current: number;
total: number;
currentComponent?: string;
log: MigrationLogEntry[];
}
/**
* Final result of a migration
*/
export interface MigrationResult {
success: boolean;
migrated: number;
needsReview: number;
failed: number;
totalCost: number;
duration: number;
}
/**
* Complete migration session state
*/
export interface MigrationSession {
id: string;
step: MigrationStep;
/** Source project (React 17) */
source: {
path: string;
name: string;
runtimeVersion: 'react17';
};
/** Target (copy) project */
target: {
path: string;
copied: boolean;
};
/** Scan results */
scan?: MigrationScan;
/** AI configuration */
ai?: AIConfig;
/** Migration progress */
progress?: MigrationProgress;
/** Final result */
result?: MigrationResult;
}
// =============================================================================
// AI Migration Types
// =============================================================================
/**
* Budget configuration for AI-assisted migration
*/
export interface AIBudget {
/** Maximum spend per migration session in dollars */
maxPerSession: number;
/** Amount spent so far */
spent: number;
/** Pause and ask after each increment */
pauseIncrement: number;
/** Whether to show cost estimates */
showEstimates: boolean;
}
/**
* User preferences for AI migration
*/
export interface AIPreferences {
/** Prefer converting to functional components with hooks */
preferFunctional: boolean;
/** Keep existing code comments */
preserveComments: boolean;
/** Add explanatory comments to changes */
verboseOutput: boolean;
}
/**
* Complete AI configuration
*/
export interface AIConfig {
enabled: boolean;
/** API key - only stored in memory during session */
apiKey?: string;
budget: AIBudget;
preferences: AIPreferences;
}
/**
* Response from Claude when migrating a component
*/
export interface AIMigrationResponse {
success: boolean;
code: string | null;
changes: string[];
warnings: string[];
confidence: number;
reason?: string;
suggestion?: string;
tokensUsed: {
input: number;
output: number;
};
cost: number;
}
/**
* Request for user decision when AI migration fails
*/
export interface AIDecisionRequest {
componentId: string;
componentName: string;
attempts: number;
attemptHistory: Array<{
code: string | null;
error: string;
cost: number;
}>;
costSpent: number;
retryCost: number;
}
/**
* User's decision on how to proceed with a failed AI migration
*/
export interface AIDecision {
componentId: string;
action: 'retry' | 'skip' | 'manual' | 'getHelp';
}
// =============================================================================
// Project Manifest Extensions
// =============================================================================
/**
* Status of a component after migration
*/
export type ComponentMigrationStatus = 'auto' | 'ai-migrated' | 'needs-review' | 'manually-fixed';
/**
* Migration note for a component stored in project.json
*/
export interface ComponentMigrationNote {
status: ComponentMigrationStatus;
issues?: string[];
aiSuggestion?: string;
dismissedAt?: string;
}
/**
* Information about the original project before migration
*/
export interface MigratedFromInfo {
version: 'react17';
date: string;
originalPath: string;
aiAssisted: boolean;
}
/**
* Extensions to the project.json manifest for migration tracking
*/
export interface ProjectMigrationMetadata {
/** Current runtime version */
runtimeVersion?: RuntimeVersion;
/** Information about the source project if this was migrated */
migratedFrom?: MigratedFromInfo;
/** Migration notes per component */
migrationNotes?: Record<string, ComponentMigrationNote>;
}
// =============================================================================
// Legacy Pattern Definitions
// =============================================================================
/**
* Pattern definition for detecting legacy React code
*/
export interface LegacyPattern {
regex: RegExp;
name: string;
type: MigrationIssueType;
description: string;
autoFixable: boolean;
}
/**
* Result of scanning for legacy patterns in a project
*/
export interface LegacyPatternScan {
found: boolean;
patterns: string[];
files: Array<{
path: string;
line: number;
pattern: string;
content?: string;
}>;
}
// =============================================================================
// Event Types
// =============================================================================
export type MigrationEventType =
| 'scan-started'
| 'scan-progress'
| 'scan-complete'
| 'migration-started'
| 'migration-progress'
| 'migration-complete'
| 'migration-failed'
| 'ai-decision-required'
| 'budget-pause-required';
export interface MigrationEvent {
type: MigrationEventType;
sessionId: string;
data?: unknown;
}