morph-cli 0.1.0

AST-based codebase migration and codemod tool for JavaScript and TypeScript projects.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use anyhow::Result;
use swc_ecma_ast::*;
use swc_ecma_visit::{Visit, VisitWith};
use crate::core::ast::parser::parse_file;
use crate::core::ast::semantic::SemanticModel;
use crate::utils::terminal;
use colored::Colorize;

static VERIFY_CACHE_HITS: AtomicUsize = AtomicUsize::new(0);
static VERIFY_CACHE_MISSES: AtomicUsize = AtomicUsize::new(0);

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct VerifyIssue {
    pub severity: IssueSeverity,
    pub title: String,
    pub description: String,
    pub confidence_reason: String,
    pub hint: String,
    pub follow_up: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum IssueSeverity {
    Error,
    Warning,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CachedVerifyEntry {
    pub size: u64,
    pub modified_secs: u64,
    pub errors: Vec<String>,
    pub warnings: Vec<String>,
    pub issues: Vec<VerifyIssue>,
}

pub struct VerifySummary {
    pub files_scanned: usize,
    pub total_errors: usize,
    pub total_warnings: usize,
    pub file_reports: Vec<FileVerifyReport>,
}

fn load_verify_cache(path: &Path) -> Option<(Vec<String>, Vec<String>, Vec<VerifyIssue>)> {
    let sanitize = |val: &str| {
        val.chars()
            .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' })
            .collect::<String>()
    };
    let cache_dir = Path::new(".morph-cli/cache/__verify__");
    let cache_path = cache_dir.join(format!("{}.json", sanitize(&path.to_string_lossy())));
    if !cache_path.exists() {
        return None;
    }

    let content = std::fs::read_to_string(&cache_path).ok()?;
    let cached = serde_json::from_str::<CachedVerifyEntry>(&content).ok()?;

    if let Ok(meta) = std::fs::metadata(path) {
        let modified_secs = meta
            .modified()
            .ok()
            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
            .map(|d| d.as_secs())
            .unwrap_or_default();

        if meta.len() == cached.size && modified_secs == cached.modified_secs {
            VERIFY_CACHE_HITS.fetch_add(1, Ordering::Relaxed);
            return Some((cached.errors, cached.warnings, cached.issues));
        }
    }

    VERIFY_CACHE_MISSES.fetch_add(1, Ordering::Relaxed);
    None
}

fn save_verify_cache(
    path: &Path,
    errors: Vec<String>,
    warnings: Vec<String>,
    issues: Vec<VerifyIssue>,
) -> Option<()> {
    let sanitize = |val: &str| {
        val.chars()
            .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' })
            .collect::<String>()
    };
    let cache_dir = Path::new(".morph-cli/cache/__verify__");
    let _ = std::fs::create_dir_all(cache_dir);
    let cache_path = cache_dir.join(format!("{}.json", sanitize(&path.to_string_lossy())));

    let meta = std::fs::metadata(path).ok()?;
    let modified_secs = meta
        .modified()
        .ok()
        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
        .map(|d| d.as_secs())
        .unwrap_or_default();

    let entry = CachedVerifyEntry {
        size: meta.len(),
        modified_secs,
        errors,
        warnings,
        issues,
    };

    let content = serde_json::to_string_pretty(&entry).ok()?;
    let _ = std::fs::write(cache_path, content);
    Some(())
}

pub fn execute(path: &Path) -> Result<()> {
    let spinner = terminal::spinner("Verifying project integrity...");
    let summary = run_verification(path)?;
    spinner.finish_and_clear();

    println!();
    println!("{}", terminal::label("Verification Report"));
    println!("{}", "─".repeat(60));
    println!("  Files Scanned: {}", summary.files_scanned);
    println!(
        "  Errors:        {}",
        if summary.total_errors > 0 {
            summary.total_errors.to_string().red().bold().to_string()
        } else {
            "0".green().to_string()
        }
    );
    println!(
        "  Warnings:      {}",
        if summary.total_warnings > 0 {
            summary.total_warnings.to_string().yellow().bold().to_string()
        } else {
            "0".green().to_string()
        }
    );
    println!("{}", "─".repeat(60));

    for report in &summary.file_reports {
        if report.issues.is_empty() {
            continue;
        }
        
        println!();
        println!("  File: {}", report.path.display().to_string().cyan().bold());
        
        for issue in &report.issues {
            let prefix = match issue.severity {
                IssueSeverity::Error => "[ERROR]".red().bold(),
                IssueSeverity::Warning => "[WARN]".yellow().bold(),
            };
            
            println!("    {} {} - {}", prefix, issue.title.bold(), issue.description);
            println!("      ├─ {} {}", "Confidence:".dimmed(), issue.confidence_reason);
            println!("      ├─ {} {}", "Hint:".dimmed(), issue.hint);
            println!("      └─ {} {}", "Follow-Up:".dimmed(), issue.follow_up);
            println!();
        }
    }

    let reused = VERIFY_CACHE_HITS.load(Ordering::Relaxed);
    let skipped_reparses = reused;
    let time_saved_ms = reused * 25;

    println!("{}", "─".repeat(60));
    println!("{}", terminal::label("Performance & Caching Summary"));
    println!("  Cached Files Reused: {}", reused);
    println!("  Skipped Reparses:    {}", skipped_reparses);
    println!("  Est Time Savings:    {}ms", time_saved_ms);
    println!("{}", "─".repeat(60));

    if summary.total_errors > 0 {
        anyhow::bail!("Verification failed with {} errors", summary.total_errors);
    } else {
        println!("{} Verification passed!", terminal::success_prefix());
    }

    Ok(())
}

pub fn run_verification(path: &Path) -> Result<VerifySummary> {
    let mut files_scanned = 0;
    let mut total_errors = 0;
    let mut total_warnings = 0;
    let mut file_reports = Vec::new();

    for entry in walkdir::WalkDir::new(path)
        .into_iter()
        .filter_entry(|e| {
            let name = e.file_name().to_string_lossy();
            name != "node_modules" && name != ".git" && name != "target" && name != "dist" && name != "build"
        })
        .filter_map(|e| e.ok())
        .filter(|e| e.file_type().is_file())
    {
        let p = entry.path();
        let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
        if !["js", "ts", "jsx", "tsx", "mjs", "cjs"].contains(&ext) {
            continue;
        }

        files_scanned += 1;
        let mut report = FileVerifyReport::new(p.to_path_buf());
        
        if let Some((errors, warnings, issues)) = load_verify_cache(p) {
            report.errors = errors;
            report.warnings = warnings;
            report.issues = issues;
        } else {
            let source = std::fs::read_to_string(p).unwrap_or_default();
            let has_todo = source.contains("TODO:") || source.contains("TODO") || source.contains("manual migration");

            match parse_file(p) {
                Ok(parsed) => {
                    let semantic = SemanticModel::new(&parsed.module);
                    
                    let mut verifier = MigrationVerifier::new();
                    verifier.visit_module(&parsed.module);
                    
                    // Duplicate imports check
                    let mut seen_imports = std::collections::HashSet::new();
                    
                    // Check collisions
                    for collision in &semantic.collisions {
                        let issue = VerifyIssue {
                            severity: IssueSeverity::Warning,
                            title: "Naming Collision".to_string(),
                            description: format!("Found multiple declarations for symbol: {}", collision),
                            confidence_reason: "Collisions lead to scope shadow bugs and unexpected variable resolution.".to_string(),
                            hint: "Check if the variable is defined twice or has overlapping scopes.".to_string(),
                            follow_up: "Rename one of the variables to ensure symbol uniqueness.".to_string(),
                        };
                        report.warnings.push(format!("{}: {}", issue.title, issue.description));
                        report.issues.push(issue);
                    }

                    // Scan body for imports and exports
                    for item in &parsed.module.body {
                        match item {
                            ModuleItem::ModuleDecl(ModuleDecl::Import(import)) => {
                                let src = import.src.value.to_string();
                                if !seen_imports.insert(src.clone()) {
                                    let issue = VerifyIssue {
                                        severity: IssueSeverity::Warning,
                                        title: "Duplicate Import Source".to_string(),
                                        description: format!("The module '{}' was imported multiple times.", src),
                                        confidence_reason: "Duplicate imports cause redundant dependency graph resolution.".to_string(),
                                        hint: "Look for multiple import statements from the same source.".to_string(),
                                        follow_up: "Merge the imported bindings into a single static import statement.".to_string(),
                                    };
                                    report.warnings.push(format!("{}: {}", issue.title, issue.description));
                                    report.issues.push(issue);
                                }

                                if src.starts_with('.') {
                                    if !resolve_local_import(p, &src) {
                                        let issue = VerifyIssue {
                                            severity: IssueSeverity::Error,
                                            title: "Unresolved Local Import".to_string(),
                                            description: format!("Local import '{}' could not be resolved to a file on disk.", src),
                                            confidence_reason: "Unresolved paths break compilation and runtime bundling.".to_string(),
                                            hint: "Verify that the relative path matches the file location and extension.".to_string(),
                                            follow_up: "Locate the correct path and rename or adjust the import target.".to_string(),
                                        };
                                        report.errors.push(format!("{}: {}", issue.title, issue.description));
                                        report.issues.push(issue);
                                    }
                                }
                            }
                            ModuleItem::ModuleDecl(ModuleDecl::ExportNamed(export)) => {
                                if let Some(src) = &export.src {
                                    let src_val = src.value.to_string();
                                    if src_val.starts_with('.') && !resolve_local_import(p, &src_val) {
                                        let issue = VerifyIssue {
                                            severity: IssueSeverity::Error,
                                            title: "Unresolved Local Re-export".to_string(),
                                            description: format!("Local re-export '{}' could not be resolved to a file on disk.", src_val),
                                            confidence_reason: "Unresolved export references prevent dependent files from importing bindings.".to_string(),
                                            hint: "Verify that the re-exported path exists and is spelt correctly.".to_string(),
                                            follow_up: "Update the re-export target to point to a valid file.".to_string(),
                                        };
                                        report.errors.push(format!("{}: {}", issue.title, issue.description));
                                        report.issues.push(issue);
                                    }
                                }
                            }
                            _ => {}
                        }
                    }
                    
                    // Mixed ESM and CommonJS check
                    if (verifier.has_esm_import || verifier.has_esm_export) && (verifier.has_cjs_require || verifier.has_cjs_export) {
                        let issue = VerifyIssue {
                            severity: IssueSeverity::Warning,
                            title: "Partially Migrated Paradigm".to_string(),
                            description: "Mixed ESM and CommonJS patterns detected in the same file.".to_string(),
                            confidence_reason: "Mixed syntax increases compiler ambiguity, often causing bundling failures.".to_string(),
                            hint: "Scan the file for residual require() or module.exports statements.".to_string(),
                            follow_up: "Refactor all remaining CommonJS elements to standard ESM imports/exports.".to_string(),
                        };
                        report.warnings.push(format!("{}: {}", issue.title, issue.description));
                        report.issues.push(issue);
                    }
                    
                    // Mixed Express and Fastify check
                    if verifier.has_express && verifier.has_fastify {
                        let issue = VerifyIssue {
                            severity: IssueSeverity::Warning,
                            title: "Mixed Routing Frameworks".to_string(),
                            description: "Found references to both Express and Fastify in the same file.".to_string(),
                            confidence_reason: "Mixing paradigms in a single file makes the migration state inconsistent.".to_string(),
                            hint: "Inspect references to express or fastify in this file.".to_string(),
                            follow_up: "Complete the migration of Express routes to Fastify and remove Express references.".to_string(),
                        };
                        report.warnings.push(format!("{}: {}", issue.title, issue.description));
                        report.issues.push(issue);
                    }
                    
                    // Unsafe req/res mutation check
                    if verifier.has_unsafe_mutation {
                        let issue = VerifyIssue {
                            severity: IssueSeverity::Warning,
                            title: "Unsafe Request/Response Mutation".to_string(),
                            description: "Found direct assignments to properties on req or res objects.".to_string(),
                            confidence_reason: "Modifying req/res directly breaks Fastify's schema optimization and is discouraged.".to_string(),
                            hint: "Look for assignments like req.property = value or res.property = value.".to_string(),
                            follow_up: "Refactor mutations to use Fastify request decorators or custom plugins.".to_string(),
                        };
                        report.warnings.push(format!("{}: {}", issue.title, issue.description));
                        report.issues.push(issue);
                    }
                }
                Err(e) => {
                    let issue = VerifyIssue {
                        severity: IssueSeverity::Error,
                        title: "Syntax Error".to_string(),
                        description: format!("The parser failed to compile the file: {}", e),
                        confidence_reason: "Code with syntax errors cannot be scanned, resolved, or run.".to_string(),
                        hint: "Check syntax or missing brackets/parentheses around the error location.".to_string(),
                        follow_up: "Fix the javascript syntax error before executing further transforms.".to_string(),
                    };
                    report.errors.push(format!("{}: {}", issue.title, issue.description));
                    report.issues.push(issue);
                }
            }
            
            // Skipped Transform check (TODO comments)
            if has_todo {
                let issue = VerifyIssue {
                    severity: IssueSeverity::Warning,
                    title: "Skipped Transform / Manual Action Required".to_string(),
                    description: "File contains TODO or manual migration markers indicating incomplete steps.".to_string(),
                    confidence_reason: "Automated recipes skip highly complex patterns to avoid regression, leaving placeholders.".to_string(),
                    hint: "Look for // TODO or 'manual migration' comments in this file.".to_string(),
                    follow_up: "Follow the comment instructions to implement the skipped block manually.".to_string(),
                };
                report.warnings.push(format!("{}: {}", issue.title, issue.description));
                report.issues.push(issue);
            }

            let _ = save_verify_cache(p, report.errors.clone(), report.warnings.clone(), report.issues.clone());
        }

        if !report.is_empty() {
            total_errors += report.errors.len();
            total_warnings += report.warnings.len();
            file_reports.push(report);
        }
    }

    Ok(VerifySummary {
        files_scanned,
        total_errors,
        total_warnings,
        file_reports,
    })
}

pub struct FileVerifyReport {
    pub path: PathBuf,
    pub errors: Vec<String>,
    pub warnings: Vec<String>,
    pub issues: Vec<VerifyIssue>,
}

impl FileVerifyReport {
    pub fn new(path: PathBuf) -> Self {
        Self {
            path,
            errors: Vec::new(),
            warnings: Vec::new(),
            issues: Vec::new(),
        }
    }
    pub fn is_empty(&self) -> bool {
        self.errors.is_empty() && self.warnings.is_empty()
    }
}

fn resolve_local_import(current_file: &Path, import_src: &str) -> bool {
    crate::utils::path::resolve_relative_import(current_file, import_src).is_some()
}

struct MigrationVerifier {
    pub has_esm_import: bool,
    pub has_cjs_require: bool,
    pub has_esm_export: bool,
    pub has_cjs_export: bool,
    pub has_express: bool,
    pub has_fastify: bool,
    pub has_unsafe_mutation: bool,
}

impl MigrationVerifier {
    fn new() -> Self {
        Self {
            has_esm_import: false,
            has_cjs_require: false,
            has_esm_export: false,
            has_cjs_export: false,
            has_express: false,
            has_fastify: false,
            has_unsafe_mutation: false,
        }
    }
}

impl Visit for MigrationVerifier {
    fn visit_call_expr(&mut self, call: &CallExpr) {
        if let Callee::Expr(expr) = &call.callee {
            if let Expr::Ident(i) = expr.as_ref() {
                if i.sym.as_ref() == "require" {
                    self.has_cjs_require = true;
                }
            }
        }
        call.visit_children_with(self);
    }

    fn visit_member_expr(&mut self, expr: &MemberExpr) {
        if let Expr::Ident(i) = expr.obj.as_ref() {
            let name = i.sym.as_ref();
            if name == "module" {
                if let MemberProp::Ident(p) = &expr.prop {
                    if p.sym.as_ref() == "exports" {
                        self.has_cjs_export = true;
                    }
                }
            } else if name == "exports" {
                self.has_cjs_export = true;
            } else if name == "express" {
                self.has_express = true;
            } else if name == "fastify" {
                self.has_fastify = true;
            }
        }
        expr.visit_children_with(self);
    }

    fn visit_import_decl(&mut self, import: &ImportDecl) {
        self.has_esm_import = true;
        let src = import.src.value.to_string();
        if src.contains("express") {
            self.has_express = true;
        } else if src.contains("fastify") {
            self.has_fastify = true;
        }
        import.visit_children_with(self);
    }

    fn visit_export_decl(&mut self, export: &ExportDecl) {
        self.has_esm_export = true;
        export.visit_children_with(self);
    }

    fn visit_assign_expr(&mut self, expr: &AssignExpr) {
        if let AssignTarget::Simple(simple) = &expr.left {
            if let SimpleAssignTarget::Member(member) = simple {
                if let Expr::Ident(i) = member.obj.as_ref() {
                    let name = i.sym.as_ref();
                    if name == "req" || name == "res" {
                        if let MemberProp::Ident(p) = &member.prop {
                            let prop = p.sym.as_ref();
                            if !["session", "user", "body", "query", "params", "headers"].contains(&prop) {
                                self.has_unsafe_mutation = true;
                            }
                        }
                    }
                }
            }
        }
        expr.visit_children_with(self);
    }
}