mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-12 15:22:55 +01:00
Started tasks to migrate runtime to React 19. Added phase 3 projects
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
347
packages/noodl-editor/src/editor/src/models/migration/types.ts
Normal file
347
packages/noodl-editor/src/editor/src/models/migration/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user