morph-cli 0.1.0

AST-based codebase migration and codemod tool for JavaScript and TypeScript projects.
Documentation
use std::path::{Path, PathBuf};

use anyhow::Result;
use indicatif::ProgressBar;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RecipeMaturity {
    Experimental,
    Beta,
    #[default]
    Stable,
}

impl std::fmt::Display for RecipeMaturity {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Experimental => write!(f, "experimental"),
            Self::Beta => write!(f, "beta"),
            Self::Stable => write!(f, "stable"),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RecipeCategory {
    Migration,
    Cleanup,
    Modernization,
    Analysis,
    Experimental,
}

impl std::fmt::Display for RecipeCategory {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Migration => write!(f, "migration"),
            Self::Cleanup => write!(f, "cleanup"),
            Self::Modernization => write!(f, "modernization"),
            Self::Analysis => write!(f, "analysis"),
            Self::Experimental => write!(f, "experimental"),
        }
    }
}

impl std::str::FromStr for RecipeCategory {
    type Err = anyhow::Error;

    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
        match s.to_lowercase().as_str() {
            "migration" => Ok(Self::Migration),
            "cleanup" => Ok(Self::Cleanup),
            "modernization" => Ok(Self::Modernization),
            "analysis" => Ok(Self::Analysis),
            "experimental" => Ok(Self::Experimental),
            _ => Err(anyhow::anyhow!("Invalid category: {}", s)),
        }
    }
}

#[derive(Debug, Clone)]
pub struct RecipeMetadata {
    pub name: &'static str,
    pub description: &'static str,
    pub supported_extensions: &'static [&'static str],
    pub required_recipes: &'static [&'static str],
    pub incompatible_recipes: &'static [&'static str],
    /// Recipes that this recipe should run before (soft hint, not enforced as a hard dependency).
    pub should_run_before: &'static [&'static str],
    /// Recipes that this recipe should run after (soft hint, not enforced as a hard dependency).
    pub should_run_after: &'static [&'static str],
    pub maturity: RecipeMaturity,
    pub category: RecipeCategory,
    pub tags: &'static [&'static str],
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetectionFailure {
    pub path: PathBuf,
    pub error: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FileClassification {
    Safe,
    Risky,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileAnalysis {
    pub path: PathBuf,
    pub detected_patterns: Vec<String>,
    pub confidence_score: u8,
    pub classification: FileClassification,
    pub is_transform_safe: bool,
    #[serde(default)]
    pub tags: Vec<String>,
}

pub fn compute_file_tags(
    path: &Path,
    content: Option<&str>,
    detected_patterns: &[String],
    is_risky: bool,
    is_ignored: bool,
) -> Vec<String> {
    let mut tags = std::collections::BTreeSet::new();

    if is_ignored {
        tags.insert("ignored".to_string());
        return tags.into_iter().collect();
    }

    let path_str = path.to_string_lossy().to_lowercase();
    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();

    // typescript
    if ext == "ts" || ext == "tsx" {
        tags.insert("typescript".to_string());
    }

    // generated
    if path_str.contains(".min.") 
        || path_str.contains(".bundle.") 
        || path_str.contains("/dist/") 
        || path_str.contains("/build/")
        || path_str.contains("/node_modules/") 
        || path_str.contains("package-lock.json")
        || path_str.contains("yarn.lock")
    {
        tags.insert("generated".to_string());
    }

    // risky
    if is_risky 
        || path_str.contains("risky") 
        || detected_patterns.iter().any(|p| p.to_lowercase().contains("dynamic require") || p.to_lowercase().contains("eval"))
    {
        tags.insert("risky".to_string());
    }

    // react
    let mut has_react = ext == "jsx" || ext == "tsx";
    if !has_react {
        if let Some(c) = content {
            has_react = c.contains("React") || c.contains("react") || c.contains("JSX") || c.contains("jsx") || c.contains("useState") || c.contains("Component");
        }
    }
    if has_react || detected_patterns.iter().any(|p| {
        let lp = p.to_lowercase();
        lp.contains("react") || lp.contains("jsx") || lp.contains("hooks")
    }) {
        tags.insert("react".to_string());
    }

    // commonjs
    let mut has_cjs = ext == "cjs";
    if !has_cjs {
        if let Some(c) = content {
            has_cjs = c.contains("require(") || c.contains("module.exports") || c.contains("exports.");
        }
    }
    if has_cjs || detected_patterns.iter().any(|p| {
        let lp = p.to_lowercase();
        lp.contains("require") || lp.contains("exports")
    }) {
        tags.insert("commonjs".to_string());
    }

    // esm
    let mut has_esm = ext == "mjs";
    if !has_esm {
        if let Some(c) = content {
            has_esm = (c.contains("import ") && c.contains(" from ")) || c.contains("export ") || c.contains("export default");
        }
    }
    if has_esm || detected_patterns.iter().any(|p| {
        let lp = p.to_lowercase();
        lp.contains("import") || lp.contains("export")
    }) {
        tags.insert("esm".to_string());
    }

    tags.into_iter().collect()
}

pub fn compute_tags_for_file(
    path: &Path,
    content_opt: Option<&str>,
    patterns: &[String],
    is_risky: bool,
    is_ignored: bool,
) -> Vec<String> {
    if is_ignored {
        return vec!["ignored".to_string()];
    }
    let content = content_opt.map(|s| s.to_string()).unwrap_or_else(|| {
        std::fs::read_to_string(path).unwrap_or_default()
    });
    compute_file_tags(path, Some(&content), patterns, is_risky, is_ignored)
}

#[derive(Debug, Clone, Default)]
pub struct DetectionReport {
    pub analyses: Vec<FileAnalysis>,
    pub skipped_files: Vec<PathBuf>,
    pub total_files: usize,
    pub parseable_files: usize,
    pub failed_files: Vec<DetectionFailure>,
}

impl DetectionReport {
    pub fn safe_transforms(&self) -> usize {
        self.analyses
            .iter()
            .filter(|analysis| analysis.classification == FileClassification::Safe)
            .count()
    }

    pub fn risky_transforms(&self) -> usize {
        self.analyses
            .iter()
            .filter(|analysis| analysis.classification == FileClassification::Risky)
            .count()
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransformMode {
    DryRun,
    Write,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransformConfidence {
    Safe,
    Moderate,
    Risky,
}

impl std::fmt::Display for TransformConfidence {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Safe => write!(f, "safe"),
            Self::Moderate => write!(f, "moderate"),
            Self::Risky => write!(f, "risky"),
        }
    }
}

#[derive(Debug, Clone, Copy)]
pub struct TransformOptions {
    pub mode: TransformMode,
    pub review: bool,
    pub autofix: bool,
    pub format: bool,
    pub prettier: bool,
    pub no_format: bool,
}

#[derive(Debug, Clone)]
pub struct SkippedTransform {
    pub path: PathBuf,
    #[allow(dead_code)]
    pub reason: String,
}

#[derive(Debug, Clone)]
pub struct UnsupportedPatternReport {
    pub path: PathBuf,
    pub patterns: Vec<String>,
}

#[derive(Debug, Clone, Default)]
pub struct TransformReport {
    pub changed_files: Vec<PathBuf>,
    pub skipped_files: Vec<SkippedTransform>,
    pub unsupported_patterns: Vec<UnsupportedPatternReport>,
    pub file_confidences: std::collections::HashMap<PathBuf, TransformConfidence>,
}

impl TransformReport {
    pub fn changed_file_count(&self) -> usize {
        self.changed_files.len()
    }

    pub fn populate_confidences(&mut self, detection_report: &DetectionReport) {
        for path in &self.changed_files {
            let confidence = self.calculate_confidence(path, detection_report);
            self.file_confidences.insert(path.clone(), confidence);
        }
    }

    pub fn calculate_confidence(
        &self,
        path: &Path,
        detection_report: &DetectionReport,
    ) -> TransformConfidence {
        let mut score = 100;

        // Penalty for unsupported patterns in this file
        if self.unsupported_patterns.iter().any(|p| &p.path == path) {
            score -= 40;
        }

        // Penalty based on detection confidence
        if let Some(analysis) = detection_report.analyses.iter().find(|a| &a.path == path) {
            if analysis.confidence_score < 50 {
                score -= 30;
            } else if analysis.confidence_score < 80 {
                score -= 10;
            }

            if analysis.classification == FileClassification::Risky {
                score -= 20;
            }
        }

        // Penalty for parse failures in detection
        if detection_report.failed_files.iter().any(|f| &f.path == path) {
            score -= 50;
        }

        if score >= 80 {
            TransformConfidence::Safe
        } else if score >= 40 {
            TransformConfidence::Moderate
        } else {
            TransformConfidence::Risky
        }
    }
}

pub trait Recipe: Send + Sync {
    fn metadata(&self) -> &'static RecipeMetadata;
    fn detect(&self, root: &Path, progress: &ProgressBar) -> Result<DetectionReport>;
    fn transform(
        &self,
        report: &DetectionReport,
        options: TransformOptions,
    ) -> Result<TransformReport>;
}