morph-cli 0.1.0

AST-based codebase migration and codemod tool for JavaScript and TypeScript projects.
Documentation
use swc_ecma_ast::{AssignExpr, AssignTarget, Callee, Expr, IdentName, MemberExpr, MemberProp};
use swc_ecma_visit::{Visit, VisitWith};

use crate::core::ast::parser::ParsedModule;
use crate::core::recipe::{FileAnalysis, FileClassification};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CommonJsPattern {
    RequireCall,
    DynamicRequire,
    ModuleExports,
    ExportsMember,
    ExportsAssignment,
}

impl CommonJsPattern {
    fn label(self) -> &'static str {
        match self {
            Self::RequireCall => "require(...)",
            Self::DynamicRequire => "dynamic require",
            Self::ModuleExports => "module.exports",
            Self::ExportsMember => "exports.foo",
            Self::ExportsAssignment => "exports = ...",
        }
    }
}

#[derive(Default)]
struct CommonJsVisitor {
    patterns: Vec<CommonJsPattern>,
}

impl CommonJsVisitor {
    fn push_pattern(&mut self, pattern: CommonJsPattern) {
        if !self.patterns.contains(&pattern) {
            self.patterns.push(pattern);
        }
    }
}

impl Visit for CommonJsVisitor {
    fn visit_call_expr(&mut self, call: &swc_ecma_ast::CallExpr) {
        if let Callee::Expr(expr) = &call.callee
            && is_ident(expr, "require")
        {
            if call.args.len() == 1
                && call.args[0].spread.is_none()
                && is_static_arg(&call.args[0].expr)
            {
                self.push_pattern(CommonJsPattern::RequireCall);
            } else {
                self.push_pattern(CommonJsPattern::DynamicRequire);
            }
        }

        call.visit_children_with(self);
    }

    fn visit_assign_expr(&mut self, assign: &AssignExpr) {
        match &assign.left {
            AssignTarget::Simple(simple) => {
                if is_simple_ident(simple, "exports") {
                    self.push_pattern(CommonJsPattern::ExportsAssignment);
                }
            }
            AssignTarget::Pat(_) => {}
        }

        if is_module_exports_member(&assign.left) {
            self.push_pattern(CommonJsPattern::ModuleExports);
        }

        if is_exports_member_target(&assign.left) {
            self.push_pattern(CommonJsPattern::ExportsMember);
        }

        assign.visit_children_with(self);
    }
}

pub fn analyze_parsed_module(parsed: &ParsedModule) -> Option<FileAnalysis> {
    let mut visitor = CommonJsVisitor::default();
    parsed.module.visit_with(&mut visitor);

    if visitor.patterns.is_empty() {
        return None;
    }

    let classification = if visitor.patterns.contains(&CommonJsPattern::DynamicRequire)
        || visitor
            .patterns
            .contains(&CommonJsPattern::ExportsAssignment)
    {
        FileClassification::Risky
    } else {
        FileClassification::Safe
    };

    Some(FileAnalysis {
        path: parsed.path.clone(),
        detected_patterns: visitor
            .patterns
            .iter()
            .map(|pattern| pattern.label().to_owned())
            .collect(),
        confidence_score: confidence_score(&visitor.patterns),
        classification,
        is_transform_safe: classification == FileClassification::Safe,
        tags: Default::default(),
    })
}

fn confidence_score(patterns: &[CommonJsPattern]) -> u8 {
    let mut score = 0u8;

    for pattern in patterns {
        score = score.saturating_add(match pattern {
            CommonJsPattern::RequireCall => 25,
            CommonJsPattern::DynamicRequire => 35,
            CommonJsPattern::ModuleExports => 35,
            CommonJsPattern::ExportsMember => 30,
            CommonJsPattern::ExportsAssignment => 30,
        });
    }

    score.min(100)
}

fn is_static_arg(expr: &Expr) -> bool {
    match expr {
        Expr::Lit(swc_ecma_ast::Lit::Str(_)) => true,
        Expr::Tpl(template) => template.exprs.is_empty(),
        _ => false,
    }
}

fn is_ident(expr: &Expr, name: &str) -> bool {
    matches!(expr, Expr::Ident(ident) if ident.sym == *name)
}

fn is_simple_ident(target: &swc_ecma_ast::SimpleAssignTarget, name: &str) -> bool {
    matches!(target, swc_ecma_ast::SimpleAssignTarget::Ident(binding) if binding.id.sym == *name)
}

fn is_module_exports_member(target: &AssignTarget) -> bool {
    match target {
        AssignTarget::Simple(simple) => match simple {
            swc_ecma_ast::SimpleAssignTarget::Member(member) => {
                is_member_pair(member, "module", "exports")
            }
            _ => false,
        },
        AssignTarget::Pat(_) => false,
    }
}

fn is_exports_member_target(target: &AssignTarget) -> bool {
    match target {
        AssignTarget::Simple(simple) => match simple {
            swc_ecma_ast::SimpleAssignTarget::Member(member) => {
                is_member_pair(member, "exports", "")
            }
            _ => false,
        },
        AssignTarget::Pat(_) => false,
    }
}

fn is_member_pair(member: &MemberExpr, first: &str, second: &str) -> bool {
    match (&*member.obj, &member.prop) {
        (Expr::Ident(ident), MemberProp::Ident(IdentName { sym, .. })) if ident.sym == *first => {
            second.is_empty() || sym == second
        }
        _ => false,
    }
}

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

    fn analyze(source: &str) -> crate::core::recipe::FileAnalysis {
        let parsed = parse_source(Path::new("fixture.js"), source).expect("source should parse");
        analyze_parsed_module(&parsed).expect("analysis should detect commonjs")
    }

    #[test]
    fn detects_static_require_as_safe() {
        let analysis = analyze("const lib = require('lib');");
        assert!(
            analysis
                .detected_patterns
                .iter()
                .any(|pattern| pattern == "require(...)")
        );
        assert_eq!(analysis.classification, FileClassification::Safe);
        assert!(analysis.confidence_score > 0);
    }

    #[test]
    fn detects_dynamic_require_as_risky() {
        let analysis = analyze("const lib = require(name);");
        assert!(
            analysis
                .detected_patterns
                .iter()
                .any(|pattern| pattern == "dynamic require")
        );
        assert_eq!(analysis.classification, FileClassification::Risky);
    }

    #[test]
    fn detects_module_exports_and_exports_member() {
        let analysis = analyze("module.exports = factory(); exports.value = 1;");
        assert!(
            analysis
                .detected_patterns
                .iter()
                .any(|pattern| pattern == "module.exports")
        );
        assert!(
            analysis
                .detected_patterns
                .iter()
                .any(|pattern| pattern == "exports.foo")
        );
        assert_eq!(analysis.classification, FileClassification::Safe);
    }

    #[test]
    fn detects_exports_assignment_as_risky() {
        let analysis = analyze("exports = buildExports();");
        assert!(
            analysis
                .detected_patterns
                .iter()
                .any(|pattern| pattern == "exports = ...")
        );
        assert_eq!(analysis.classification, FileClassification::Risky);
    }

    #[test]
    fn skips_non_commonjs_files() {
        let parsed = parse_source(Path::new("fixture.js"), "export const value = 1;")
            .expect("source should parse");
        assert!(analyze_parsed_module(&parsed).is_none());
    }
}