morph-cli 0.1.0

AST-based codebase migration and codemod tool for JavaScript and TypeScript projects.
Documentation
use std::collections::HashSet;

use swc_ecma_ast::{
    AssignExpr, AssignTarget, CallExpr, Callee, Expr, ExprStmt, IfStmt, ModuleItem, Stmt, TryStmt,
};
use swc_ecma_visit::{Visit, VisitWith};

use crate::core::ast::parser::ParsedModule;

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[allow(dead_code)]
pub enum SafetyLevel {
    Safe = 0,
    MostlySafe = 1,
    Risky = 2,
    Unsafe = 3,
}

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[allow(dead_code)]
pub enum RiskReason {
    DynamicRequire(String),
    ConditionalImport(String),
    CircularDependency,
    MutationHeavyExports,
    MixedModuleSystems,
    TopLevelSideEffect(String),
    ReassignedExports,
    NestedRequire(String),
    ReassignedImport(String),
    TryCatchRequire,
    HoistedFunctionRequire,
}

#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SafetyReport {
    pub level: SafetyLevel,
    pub reasons: Vec<RiskReason>,
    pub warnings: Vec<String>,
    pub can_transform: bool,
}

pub(crate) struct SafetyAnalyzer {
    #[allow(dead_code)]
    visited_requires: HashSet<String>,
    #[allow(dead_code)]
    exported_names: HashSet<String>,
    reassigned_exports: bool,
    has_conditional_require: bool,
    has_top_level_side_effects: bool,
    has_dynamic_require: bool,
    has_mixed_imports: bool,
    require_depth: u32,
    conditional_depth: u32,
}

impl SafetyAnalyzer {
    #[allow(dead_code)]
    pub fn analyze(parsed: &ParsedModule) -> SafetyReport {
        let mut analyzer = SafetyAnalyzer {
            visited_requires: HashSet::new(),
            exported_names: HashSet::new(),
            reassigned_exports: false,
            has_conditional_require: false,
            has_top_level_side_effects: false,
            has_dynamic_require: false,
            has_mixed_imports: false,
            require_depth: 0,
            conditional_depth: 0,
        };

        analyzer.visit_module(&parsed.module);

        let mut reasons = Vec::new();
        let mut warnings = Vec::new();

        if analyzer.has_dynamic_require {
            reasons.push(RiskReason::DynamicRequire(
                "variable or expression".to_string(),
            ));
        }

        if analyzer.has_conditional_require {
            reasons.push(RiskReason::ConditionalImport(
                "if/unary expression".to_string(),
            ));
        }

        if analyzer.reassigned_exports {
            reasons.push(RiskReason::ReassignedExports);
        }

        if analyzer.has_mixed_imports {
            reasons.push(RiskReason::MixedModuleSystems);
        }

        if analyzer.has_top_level_side_effects {
            warnings.push("top-level side effects detected".to_string());
        }

        let level = calculate_safety_level(&reasons);
        let can_transform = level <= SafetyLevel::MostlySafe;

        SafetyReport {
            level,
            reasons,
            warnings,
            can_transform,
        }
    }
}

impl Visit for SafetyAnalyzer {
    fn visit_module(&mut self, module: &swc_ecma_ast::Module) {
        for item in &module.body {
            if let ModuleItem::Stmt(Stmt::Expr(ExprStmt { expr, .. })) = item
                && !is_import_export_expr(expr)
            {
                self.has_top_level_side_effects = true;
                break;
            }
        }

        module.visit_children_with(self);
    }

    fn visit_module_decl(&mut self, decl: &swc_ecma_ast::ModuleDecl) {
        self.has_mixed_imports = true;
        decl.visit_children_with(self);
    }

    fn visit_call_expr(&mut self, call: &CallExpr) {
        if let Callee::Expr(expr) = &call.callee
            && is_require_ident(expr)
        {
            let arg_expr = call.args.first().map(|a| &*a.expr);

            if let Some(Expr::Ident(ident)) = arg_expr {
                self.visited_requires.insert(ident.sym.to_string());
            }

            if self.conditional_depth > 0 {
                self.has_conditional_require = true;
            }

            if self.require_depth > 0 {
                let reason = format!("depth {}", self.require_depth);
                self.visited_requires.insert(reason);
            }

            if !is_static_require_arg(arg_expr) {
                self.has_dynamic_require = true;
            }
        }

        if let Callee::Expr(expr) = &call.callee
            && is_import_ident(expr)
        {
            self.has_mixed_imports = true;
        }

        call.visit_children_with(self);
    }

    fn visit_assign_expr(&mut self, assign: &AssignExpr) {
        if let AssignTarget::Simple(simple) = &assign.left
            && let swc_ecma_ast::SimpleAssignTarget::Ident(ident) = simple
            && ident.id.sym == "exports"
        {
            self.reassigned_exports = true;
        }

        assign.visit_children_with(self);
    }

    fn visit_if_stmt(&mut self, stmt: &IfStmt) {
        if contains_require_call(&stmt.test) {
            self.has_conditional_require = true;
        }

        self.conditional_depth += 1;
        stmt.visit_children_with(self);
        self.conditional_depth -= 1;
    }

    fn visit_try_stmt(&mut self, stmt: &TryStmt) {
        self.conditional_depth += 1;
        stmt.visit_children_with(self);
        self.conditional_depth -= 1;
    }
}

#[allow(dead_code)]
fn calculate_safety_level(reasons: &[RiskReason]) -> SafetyLevel {
    let mut score = 0i32;

    for reason in reasons {
        score += match reason {
            RiskReason::DynamicRequire(_) => 3,
            RiskReason::ConditionalImport(_) => 4,
            RiskReason::CircularDependency => 4,
            RiskReason::MutationHeavyExports => 2,
            RiskReason::MixedModuleSystems => 2,
            RiskReason::TopLevelSideEffect(_) => 1,
            RiskReason::ReassignedExports => 5,
            RiskReason::NestedRequire(_) => 2,
            RiskReason::ReassignedImport(_) => 4,
            RiskReason::TryCatchRequire => 2,
            RiskReason::HoistedFunctionRequire => 1,
        };
    }

    match score {
        0 => SafetyLevel::Safe,
        1..=3 => SafetyLevel::MostlySafe,
        4..=6 => SafetyLevel::Risky,
        _ => SafetyLevel::Unsafe,
    }
}

#[allow(dead_code)]
fn is_require_ident(expr: &Expr) -> bool {
    matches!(expr, Expr::Ident(ident) if ident.sym == "require")
}

#[allow(dead_code)]
fn is_import_ident(expr: &Expr) -> bool {
    matches!(expr, Expr::Ident(ident) if ident.sym == "import")
}

#[allow(dead_code)]
fn is_static_require_arg(arg: Option<&Expr>) -> bool {
    match arg {
        Some(Expr::Lit(swc_ecma_ast::Lit::Str(_))) => true,
        Some(Expr::Tpl(tpl)) => tpl.exprs.is_empty(),
        _ => false,
    }
}

#[allow(dead_code)]
fn is_import_export_expr(expr: &Expr) -> bool {
    matches!(expr, Expr::Assign(_))
}

#[allow(dead_code)]
fn contains_require_call(expr: &Expr) -> bool {
    match expr {
        Expr::Call(call) => {
            if let Callee::Expr(callee) = &call.callee {
                is_require_ident(callee)
            } else {
                false
            }
        }
        Expr::Unary(unary) => contains_require_call(&unary.arg),
        _ => false,
    }
}

impl SafetyReport {
    #[allow(dead_code)]
    pub fn summary(&self) -> String {
        let level_str = match self.level {
            SafetyLevel::Safe => "safe",
            SafetyLevel::MostlySafe => "mostly safe",
            SafetyLevel::Risky => "risky",
            SafetyLevel::Unsafe => "unsafe",
        };

        if self.reasons.is_empty() {
            format!("Safety: {} - no risks detected", level_str)
        } else {
            let reason_count = self.reasons.len();
            format!("Safety: {} - {} risk(s) detected", level_str, reason_count)
        }
    }

    #[allow(dead_code)]
    pub fn should_transform(&self, allow_risky: bool, strict: bool) -> bool {
        if strict {
            self.level == SafetyLevel::Safe
        } else if allow_risky {
            self.level <= SafetyLevel::Risky
        } else {
            self.level <= SafetyLevel::MostlySafe
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::ast::parser::parse_source;
    use std::path::Path;

    fn analyze(source: &str) -> SafetyReport {
        let parsed = parse_source(Path::new("fixture.js"), source).expect("source should parse");
        SafetyAnalyzer::analyze(&parsed)
    }

    #[test]
    fn test_safe_require() {
        let report = analyze("const lib = require('lib'); module.exports = lib;");
        assert_eq!(report.level, SafetyLevel::Safe);
        assert!(report.can_transform);
        assert!(report.reasons.is_empty());
    }

    #[test]
    fn test_dynamic_require() {
        let report = analyze("const lib = require(variable); module.exports = lib;");
        assert!(report.level >= SafetyLevel::Safe);
    }

    #[test]
    fn test_conditional_require() {
        let report = analyze("if (condition) { require('a'); }");
        assert!(report.level >= SafetyLevel::Risky);
    }

    #[test]
    fn test_exports_reassignment() {
        let report = analyze("exports = factory();");
        assert!(report.level >= SafetyLevel::Risky);
        assert!(
            report
                .reasons
                .iter()
                .any(|r| matches!(r, RiskReason::ReassignedExports))
        );
    }

    #[test]
    fn test_mixed_imports() {
        let report = analyze("const x = require('bar'); const lib = require('lib');");
        assert!(report.can_transform);
    }

    #[test]
    fn test_side_effects() {
        let report = analyze("console.log('hello'); const lib = require('lib');");
        assert!(!report.warnings.is_empty());
        assert!(report.warnings.iter().any(|w| w.contains("side effect")));
    }

    #[test]
    fn test_should_transform_strict() {
        let safe_report = analyze("const lib = require('lib'); module.exports = lib;");
        let risky_report = analyze("const lib = require(variable);");

        assert!(safe_report.should_transform(false, true));
        assert!(!risky_report.should_transform(false, true));
    }

    #[test]
    fn test_should_transform_allow_risky() {
        let safe_report = analyze("const lib = require('lib'); module.exports = lib;");
        let risky_report = analyze("const lib = require(variable);");

        assert!(safe_report.should_transform(true, false));
        assert!(risky_report.should_transform(true, false));
    }

    #[test]
    fn test_multiple_risks() {
        let report = analyze("const lib = require(condition ? 'a' : variable); exports = lib;");
        assert!(report.level >= SafetyLevel::Risky);
    }

    #[test]
    fn test_nested_require() {
        let report = analyze(
            "function getLib() { return require('lib'); } module.exports = { get: getLib };",
        );
        assert!(report.level <= SafetyLevel::Risky);
    }

    #[test]
    fn test_try_catch_require() {
        let report =
            analyze("try { const lib = require('lib'); } catch(e) { } module.exports = lib;");
        assert!(report.can_transform || !report.reasons.is_empty());
    }

    #[test]
    fn test_export_member() {
        let report = analyze("exports.foo = require('lib');");
        assert!(report.can_transform);
    }

    #[test]
    fn test_module_exports_object() {
        let report = analyze("module.exports = { foo: require('foo'), bar: require('bar') };");
        assert!(report.can_transform);
    }
}