morph-cli 0.1.0

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

use crate::core::ast::parser::ParsedModule;
use crate::core::recipe::{FileAnalysis, FileClassification};
use crate::recipes::js_to_ts::types::{JsTsPattern, TypeInferenceEngine, TypeInferenceHints};

use super::types::MigrationMode;

pub struct JsToTsDetector {
    mode: MigrationMode,
}

impl JsToTsDetector {
    pub fn new(mode: MigrationMode) -> Self {
        Self { mode }
    }

    #[allow(dead_code)]
    pub fn analyze(&self, _parsed: &ParsedModule, source: &str) -> FileAnalysis {
        let patterns = self.detect_patterns(source);
        let classification = self.classify(&patterns);
        let confidence = self.calculate_confidence(&patterns);

        FileAnalysis {
            path: _parsed.path.clone(),
            detected_patterns: patterns.iter().map(|p| p.label().to_owned()).collect(),
            confidence_score: confidence,
            classification,
            is_transform_safe: classification == FileClassification::Safe,
            tags: Default::default(),
        }
    }

    fn detect_patterns(&self, source: &str) -> Vec<JsTsPattern> {
        let mut patterns = Vec::new();

        patterns.push(JsTsPattern::PlainJavaScript);

        if source.contains("require(") {
            patterns.push(JsTsPattern::CommonJSRequire);
        }
        if source.contains("module.exports") {
            patterns.push(JsTsPattern::CommonJSExports);
        }
        if source.contains("import ") && source.contains(" from ") {
            patterns.push(JsTsPattern::EsModuleImport);
        }
        let has_jsx_pattern = (source.contains("React") || source.contains("function "))
            && (source.contains("<") && source.contains("/>") || source.contains("</"));
        if has_jsx_pattern {
            patterns.push(JsTsPattern::ReactJSX);
        }
        if source.contains("@param")
            || source.contains("@type")
            || source.contains("@returns")
            || source.contains("@typedef")
        {
            patterns.push(JsTsPattern::JSDocAnnotations);
        }
        if source.contains(": any") || source.contains("any") {
            patterns.push(JsTsPattern::ImplicitAny);
        }

        patterns
    }

    fn classify(&self, patterns: &[JsTsPattern]) -> FileClassification {
        match self.mode {
            MigrationMode::Conservative => {
                if patterns.contains(&JsTsPattern::ReactJSX)
                    || patterns.contains(&JsTsPattern::CommonJSRequire)
                {
                    FileClassification::Risky
                } else {
                    FileClassification::Safe
                }
            }
            MigrationMode::Balanced => {
                if patterns.contains(&JsTsPattern::ReactJSX)
                    && patterns.contains(&JsTsPattern::CommonJSRequire)
                {
                    FileClassification::Risky
                } else {
                    FileClassification::Safe
                }
            }
            MigrationMode::Aggressive => FileClassification::Safe,
        }
    }

    fn calculate_confidence(&self, patterns: &[JsTsPattern]) -> u8 {
        let base_score = 50u8;
        let mut score = base_score;

        for pattern in patterns {
            score = score.saturating_add(match pattern {
                JsTsPattern::PlainJavaScript => 0,
                JsTsPattern::CommonJSRequire => 15,
                JsTsPattern::CommonJSExports => 10,
                JsTsPattern::JSDocAnnotations => 10,
                JsTsPattern::ImplicitAny => 5,
                JsTsPattern::ReactJSX => 15,
                JsTsPattern::TypeAnnotatedFile => 20,
                JsTsPattern::EsModuleImport => 15,
            });
        }

        score.min(100)
    }
}

pub fn analyze_js_file(path: &Path, source: &str, mode: MigrationMode) -> Option<FileAnalysis> {
    let ext = path.extension()?.to_str()?;
    if ext != "js" && ext != "jsx" && ext != "mjs" {
        return None;
    }

    let detector = JsToTsDetector::new(mode);
    let _hints = TypeInferenceEngine::analyze_for_inference(source);

    let patterns = detector.detect_patterns(source);
    let classification = detector.classify(&patterns);
    let confidence = detector.calculate_confidence(&patterns);

    Some(FileAnalysis {
        path: path.to_path_buf(),
        detected_patterns: patterns.iter().map(|p| p.label().to_owned()).collect(),
        confidence_score: confidence,
        classification,
        is_transform_safe: classification == FileClassification::Safe,
        tags: Default::default(),
    })
}

#[allow(dead_code)]
pub fn extract_inference_hints(source: &str) -> TypeInferenceHints {
    TypeInferenceEngine::analyze_for_inference(source)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;

    fn analyze(source: &str) -> FileAnalysis {
        analyze_js_file(Path::new("fixture.js"), source, MigrationMode::Conservative)
            .expect("should detect JS patterns")
    }

    #[test]
    fn detects_plain_javascript() {
        let analysis = analyze("const x = 1;");
        assert!(
            analysis
                .detected_patterns
                .contains(&"plain JavaScript".to_string())
        );
        assert_eq!(analysis.classification, FileClassification::Safe);
    }

    #[test]
    fn detects_commonjs() {
        let analysis = analyze("const lib = require('lib'); module.exports = lib;");
        assert!(
            analysis
                .detected_patterns
                .contains(&"CommonJS require".to_string())
        );
        assert_eq!(analysis.classification, FileClassification::Risky);
    }

    #[test]
    fn detects_jsx() {
        let analysis = analyze("function App() { return <div>Hello</div>; }");
        assert!(
            analysis
                .detected_patterns
                .contains(&"React JSX".to_string())
        );
        assert_eq!(analysis.classification, FileClassification::Risky);
    }

    #[test]
    fn detects_jsdoc() {
        let analysis = analyze("/** @param {string} name */\nfunction greet(name) {}");
        assert!(
            analysis
                .detected_patterns
                .contains(&"JSDoc annotations".to_string())
        );
    }

    #[test]
    fn detects_implicit_any() {
        let analysis = analyze("const x: any = 1;");
        assert!(
            analysis
                .detected_patterns
                .contains(&"implicit any patterns".to_string())
        );
    }

    #[test]
    fn conservative_mode_considers_commonjs_risky() {
        let analysis = analyze("const lib = require('lib');");
        assert_eq!(analysis.classification, FileClassification::Risky);
    }

    #[test]
    fn balanced_mode_is_more_permissive() {
        let analysis = analyze_js_file(
            Path::new("fixture.js"),
            "const lib = require('lib');",
            MigrationMode::Balanced,
        )
        .expect("should detect");
        assert_eq!(analysis.classification, FileClassification::Safe);
    }

    #[test]
    fn aggressive_mode_is_most_permissive() {
        let analysis = analyze_js_file(
            Path::new("fixture.jsx"),
            "function App() { return <div>Hello</div>; }",
            MigrationMode::Aggressive,
        )
        .expect("should detect");
        assert_eq!(analysis.classification, FileClassification::Safe);
    }

    #[test]
    fn skips_non_js_files() {
        let result = analyze_js_file(
            Path::new("fixture.ts"),
            "const x: string = 'hi';",
            MigrationMode::Conservative,
        );
        assert!(result.is_none());
    }
}