morph_cli/recipes/js_to_ts/
mod.rs1mod detect;
2mod inference;
3mod transform;
4mod types;
5
6use std::path::Path;
7
8use anyhow::Result;
9use indicatif::ProgressBar;
10
11use crate::core::cache::{CachedDetectionOutcome, load_detection, record_miss, save_detection};
12use crate::core::recipe::{
13 DetectionFailure, DetectionReport, FileClassification, Recipe, RecipeMetadata,
14 TransformOptions, TransformReport,
15};
16use crate::recipes::js_to_ts::transform::{JsToTsTransform, rename_file};
17use crate::recipes::js_to_ts::types::MigrationMode;
18
19const METADATA: RecipeMetadata = RecipeMetadata {
20 name: "js-to-ts",
21 description: "Migrate JavaScript files to TypeScript with extension renaming and type inference scaffolding.",
22 supported_extensions: &["js", "jsx", "mjs", "ts", "tsx", "mts"],
23 required_recipes: &[],
24 incompatible_recipes: &[],
25 should_run_before: &["react-class-to-hooks"],
26 should_run_after: &["commonjs-to-esm"],
27 maturity: crate::core::recipe::RecipeMaturity::Stable,
28 category: crate::core::recipe::RecipeCategory::Modernization,
29 tags: &["risky", "typescript", "js-to-ts", "type-safety", "modernize"],
30};
31
32pub struct JsToTsRecipe {
33 mode: MigrationMode,
34 rename_extensions: bool,
35 infer_types: bool,
36}
37
38impl JsToTsRecipe {
39 pub fn new(mode: MigrationMode, rename_extensions: bool, infer_types: bool) -> Self {
40 Self {
41 mode,
42 rename_extensions,
43 infer_types,
44 }
45 }
46
47 pub fn conservative() -> Self {
48 Self::new(MigrationMode::Conservative, true, false)
49 }
50}
51
52impl Recipe for JsToTsRecipe {
53 fn metadata(&self) -> &'static RecipeMetadata {
54 &METADATA
55 }
56
57 fn detect(&self, root: &Path, progress: &ProgressBar) -> Result<DetectionReport> {
58 let mut report = DetectionReport::default();
59
60 for entry in walkdir::WalkDir::new(root)
61 .into_iter()
62 .filter_entry(|e| {
63 let name = e.file_name().to_string_lossy();
64 name != "node_modules" && name != ".git" && name != "target" && name != "dist" && name != "build"
65 })
66 .filter_map(|entry| entry.ok())
67 {
68 let path = entry.path();
69 progress.set_message(format!("Scanning {}", path.display()));
70
71 if !entry.file_type().is_file() {
72 continue;
73 }
74
75 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
76 if !METADATA.supported_extensions.contains(&ext) {
77 continue;
78 }
79
80 report.total_files += 1;
81
82 if let Some(outcome) = load_detection(METADATA.name, path) {
83 apply_cached_outcome(&mut report, outcome);
84 continue;
85 }
86 record_miss();
87
88 match std::fs::read_to_string(path) {
89 Ok(source) => {
90 report.parseable_files += 1;
91
92 if let Some(analysis) = detect::analyze_js_file(path, &source, self.mode) {
93 let _ = save_detection(
94 METADATA.name,
95 path,
96 &CachedDetectionOutcome::Analysis(analysis.clone()),
97 );
98 report.analyses.push(analysis);
99 } else {
100 let skipped = path.to_path_buf();
101 let _ = save_detection(
102 METADATA.name,
103 path,
104 &CachedDetectionOutcome::Skipped(skipped.clone()),
105 );
106 report.skipped_files.push(skipped);
107 }
108 }
109 Err(error) => {
110 let failure = DetectionFailure {
111 path: path.to_path_buf(),
112 error: format!("failed to read file: {}", error),
113 };
114 let _ = save_detection(
115 METADATA.name,
116 path,
117 &CachedDetectionOutcome::Failure(failure.clone()),
118 );
119 report.failed_files.push(failure);
120 }
121 }
122 }
123
124 Ok(report)
125 }
126
127 fn transform(
128 &self,
129 report: &DetectionReport,
130 options: TransformOptions,
131 ) -> Result<TransformReport> {
132 let mut transform_report = TransformReport::default();
133 let transformer = JsToTsTransform::new(self.mode, self.rename_extensions, self.infer_types);
134
135 for analysis in &report.analyses {
136 if !transformer.should_transform(analysis) {
137 transform_report
138 .skipped_files
139 .push(crate::core::recipe::SkippedTransform {
140 path: analysis.path.clone(),
141 reason: "File not eligible for transformation".to_string(),
142 });
143 continue;
144 }
145
146 if analysis.classification == FileClassification::Risky
147 && options.mode == crate::core::recipe::TransformMode::DryRun
148 {
149 transform_report.unsupported_patterns.push(
150 crate::core::recipe::UnsupportedPatternReport {
151 path: analysis.path.clone(),
152 patterns: analysis.detected_patterns.clone(),
153 },
154 );
155 continue;
156 }
157
158 match transformer.transform_file(analysis) {
159 Ok(result) => {
160 let mut should_write = result.has_changes() && options.mode == crate::core::recipe::TransformMode::Write;
161
162 if should_write && options.review {
163 let original_content = std::fs::read_to_string(&result.original_path).unwrap_or_default();
164 let renderer = crate::core::diff::renderer::DiffRenderer::new(
165 crate::core::diff::preview::PreviewConfig {
166 max_lines: 100,
167 show_line_numbers: true,
168 summary_only: false,
169 verbose: false,
170 },
171 );
172 println!("\nRename {} to {}", result.original_path.display(), result.target_path.display());
173 match crate::core::diff::preview::prompt_review(&result.original_path, &original_content, &original_content, &renderer) {
174 Ok(crate::core::diff::preview::ReviewAction::Apply) => should_write = true,
175 Ok(crate::core::diff::preview::ReviewAction::Skip) => {
176 transform_report.skipped_files.push(crate::core::recipe::SkippedTransform {
177 path: analysis.path.clone(),
178 reason: "Skipped by user".to_string(),
179 });
180 continue;
181 }
182 Ok(crate::core::diff::preview::ReviewAction::Abort) => {
183 anyhow::bail!("Migration aborted by user");
184 }
185 Err(e) => anyhow::bail!("Review error: {}", e),
186 }
187 }
188
189 if should_write
190 && rename_file(&result.original_path, &result.target_path).is_err()
191 {
192 let err = std::io::Error::other("rename failed");
193 eprintln!(
194 "Warning: Failed to rename {}: {}",
195 result.original_path.display(),
196 err
197 );
198 transform_report.unsupported_patterns.push(
199 crate::core::recipe::UnsupportedPatternReport {
200 path: analysis.path.clone(),
201 patterns: vec![err.to_string()],
202 },
203 );
204 continue;
205 }
206
207 if result.has_changes() {
208 transform_report.changed_files.push(result.target_path);
209 }
210
211 for warning in &result.warnings {
212 eprintln!("Warning for {}: {}", analysis.path.display(), warning);
213 }
214 }
215 Err(e) => {
216 transform_report.unsupported_patterns.push(
217 crate::core::recipe::UnsupportedPatternReport {
218 path: analysis.path.clone(),
219 patterns: vec![e.to_string()],
220 },
221 );
222 }
223 }
224 }
225
226 Ok(transform_report)
227 }
228}
229
230fn apply_cached_outcome(report: &mut DetectionReport, outcome: CachedDetectionOutcome) {
231 match outcome {
232 CachedDetectionOutcome::Analysis(analysis) => {
233 report.parseable_files += 1;
234 report.analyses.push(analysis);
235 }
236 CachedDetectionOutcome::Skipped(path) => {
237 report.parseable_files += 1;
238 report.skipped_files.push(path);
239 }
240 CachedDetectionOutcome::Failure(failure) => report.failed_files.push(failure),
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn test_metadata() {
250 let recipe = JsToTsRecipe::conservative();
251 let metadata = recipe.metadata();
252 assert_eq!(metadata.name, "js-to-ts");
253 assert!(!metadata.supported_extensions.is_empty());
254 }
255
256 #[test]
257 fn test_conservative_mode() {
258 let recipe = JsToTsRecipe::conservative();
259 assert!(matches!(recipe.mode, MigrationMode::Conservative));
260 }
261}