@@ -11,22 +11,42 @@ def generate_fusion_plan(
1111 map_path : Path ,
1212 module_name : str ,
1313) -> dict [str , Any ]:
14- """Generate a detailed module-level fusion plan from a JSON project map."""
14+ """Generate a detailed module-level fusion plan from a JSON project map.
15+
16+ Args:
17+ map_path: Path to the JSON project map generated by ``aetherfusion scan --json``.
18+ module_name: Name of the module to plan for (must exist in fusion_plan_candidates).
19+
20+ Returns:
21+ Structured fusion plan dict.
22+
23+ Raises:
24+ FileNotFoundError: If the map file does not exist.
25+ ValueError: If the module is not found in fusion_plan_candidates.
26+ json.JSONDecodeError: If the map file is not valid JSON.
27+ """
1528 if not map_path .is_file ():
1629 raise FileNotFoundError (f"JSON map not found: { map_path } " )
1730
1831 with open (map_path , "r" , encoding = "utf-8" ) as f :
1932 data = json .load (f )
2033
34+ # Find the module in fusion_plan_candidates
2135 candidates : list [dict [str , Any ]] = data .get ("fusion_plan_candidates" , [])
22- candidate = next ((c for c in candidates if c .get ("module_name" ) == module_name ), None )
36+ candidate = None
37+ for c in candidates :
38+ if c .get ("module_name" ) == module_name :
39+ candidate = c
40+ break
41+
2342 if candidate is None :
2443 available = sorted ([c .get ("module_name" , "?" ) for c in candidates ])
2544 raise ValueError (
2645 f"Module '{ module_name } ' not found in fusion_plan_candidates. "
2746 f"Available modules: { ', ' .join (available ) if available else '(none)' } "
2847 )
2948
49+ # Build the plan
3050 source_info = data .get ("projects" , {}).get ("source" , {})
3151 target_info = data .get ("projects" , {}).get ("target" , {})
3252 conflicts = data .get ("conflicts" , {})
@@ -35,8 +55,9 @@ def generate_fusion_plan(
3555 source_module_path = _pick_first (candidate .get ("source_paths" , []))
3656 target_match_path = _pick_first (candidate .get ("target_paths" , []))
3757
38- # Source-only transfer modules need a deterministic destination so patch/apply
39- # can form a complete transaction rather than emitting an empty target path.
58+ # Source-only transfer modules still need a deterministic destination.
59+ # Use <target_project>/<module_name> rather than leaving the path empty;
60+ # safe_apply will create the directory without overwriting existing files.
4061 source_only_transfer = (
4162 not target_match_path
4263 and bool (source_module_path )
@@ -83,12 +104,15 @@ def generate_fusion_plan(
83104 "do not make any network requests" ,
84105 ],
85106 "next_recommended_command" : (
86- f"python -m aetherfusion plan --map { map_path } --module { module_name } "
107+ f"python -m aetherfusion plan "
108+ f"--map { map_path } "
109+ f"--module { module_name } "
87110 ),
88111 }
89112
90113
91114def _pick_first (paths : list [str ]) -> str | None :
115+ """Return the first path from a list or None."""
92116 return paths [0 ] if paths else None
93117
94118
@@ -97,68 +121,121 @@ def _build_ordered_steps(
97121 candidate : dict [str , Any ],
98122 conflicts : dict [str , Any ],
99123) -> list [dict [str , Any ]]:
124+ """Build the ordered steps for module fusion."""
125+
100126 source_paths : list [str ] = candidate .get ("source_paths" , [])
101127 target_paths : list [str ] = candidate .get ("target_paths" , [])
102128 name_conflicts = {c .get ("relative_path" , "" ) for c in conflicts .get ("name_conflicts" , [])}
103- has_same_named = module_name in name_conflicts or bool (source_paths and target_paths )
104129
105- return [
106- {
107- "step" : 1 ,
108- "action" : "inspect_same_named_files" ,
109- "description" : f"Inspect files in module '{ module_name } ' for naming collisions between source and target projects." ,
110- "details" : {
111- "source_paths" : source_paths ,
112- "target_paths" : target_paths ,
113- "has_same_named_conflicts" : has_same_named ,
114- "recommended_tool" : "Use aetherfusion scan to identify file-level conflicts, then manually diff conflicting files." ,
115- },
116- "estimated_complexity" : "medium" if has_same_named else "low" ,
130+ steps : list [dict [str , Any ]] = []
131+
132+ # Step 1: Inspect same-named files
133+ has_same_named = module_name in name_conflicts or bool (source_paths and target_paths )
134+ steps .append ({
135+ "step" : 1 ,
136+ "action" : "inspect_same_named_files" ,
137+ "description" : (
138+ f"Inspect files in module '{ module_name } ' for naming collisions between "
139+ f"source and target projects."
140+ ),
141+ "details" : {
142+ "source_paths" : source_paths ,
143+ "target_paths" : target_paths ,
144+ "has_same_named_conflicts" : has_same_named ,
145+ "recommended_tool" : (
146+ "Use aetherfusion scan to identify specific file-level conflicts, "
147+ "then manually diff the conflicting files."
148+ ),
117149 },
118- {
119- "step" : 2 ,
120- "action" : "copy_non_conflicting_files" ,
121- "description" : f"Identify files in '{ module_name } ' that do not conflict with target files and prepare a copy plan." ,
122- "details" : {
123- "condition" : "Only files not present in target should be copied in this step." ,
124- "status" : "BLOCKED — v0.2 is read-only; actual file copy is deferred to v0.3+." ,
125- "note" : "Human review is required before copying." ,
126- },
127- "estimated_complexity" : "low" ,
150+ "estimated_complexity" : "medium" if has_same_named else "low" ,
151+ })
152+
153+ # Step 2: Copy non-conflicting files
154+ steps .append ({
155+ "step" : 2 ,
156+ "action" : "copy_non_conflicting_files" ,
157+ "description" : (
158+ f"Identify files in '{ module_name } ' that do NOT conflict with "
159+ f"target files, and prepare a copy plan."
160+ ),
161+ "details" : {
162+ "condition" : "Only files NOT present in target should be copied in this step." ,
163+ "status" : "BLOCKED — v0.2 is read-only; actual file copy is deferred to v0.3+." ,
164+ "note" : (
165+ "List candidate files here with their source paths. "
166+ "Human review required before copying."
167+ ),
128168 },
129- {
130- "step" : 3 ,
131- "action" : "review_import_dependencies" ,
132- "description" : f"Review import/require statements within '{ module_name } ' so they resolve in the target project." ,
133- "details" : {
134- "target_dependencies" : "Check whether imported modules are available in the target project." ,
135- "relative_imports" : "Verify relative paths remain valid after fusion." ,
136- "status" : "Requires manual review — automatic import rewriting is out of scope for v0.2." ,
137- },
138- "estimated_complexity" : "medium" ,
169+ "estimated_complexity" : "low" ,
170+ })
171+
172+ # Step 3: Review import dependencies
173+ steps .append ({
174+ "step" : 3 ,
175+ "action" : "review_import_dependencies" ,
176+ "description" : (
177+ f"Review all import/require statements within '{ module_name } ' files "
178+ f"to ensure they resolve correctly in the target project."
179+ ),
180+ "details" : {
181+ "target_dependencies" : (
182+ "Check if imported modules are available in the target project. "
183+ "If any are missing, add them to the target dependency list."
184+ ),
185+ "relative_imports" : (
186+ "If the module uses relative imports (e.g., '../utils'), verify "
187+ "that the relative path is still valid after fusion."
188+ ),
189+ "status" : "Requires manual review — automatic import rewriting is out of scope for v0.2." ,
139190 },
140- {
141- "step" : 4 ,
142- "action" : "check_config_requirements" ,
143- "description" : f"Check whether '{ module_name } ' requires target config changes." ,
144- "details" : {
145- "tsconfig_paths" : "Check path aliases." ,
146- "env_vars" : "Document required environment variables." ,
147- "build_config" : "Review module-specific build settings." ,
148- },
149- "estimated_complexity" : "medium" ,
191+ "estimated_complexity" : "medium" ,
192+ })
193+
194+ # Step 4: Check config requirements
195+ steps .append ({
196+ "step" : 4 ,
197+ "action" : "check_config_requirements" ,
198+ "description" : (
199+ f"Check if '{ module_name } ' requires any config changes in the target "
200+ f"project (e.g., new aliases, env vars, build config)."
201+ ),
202+ "details" : {
203+ "tsconfig_paths" : (
204+ "If the source project uses tsconfig path aliases (e.g., '@components/*'), "
205+ "ensure the target tsconfig.json includes the same aliases."
206+ ),
207+ "env_vars" : (
208+ "Check for any environment variables used by the module "
209+ "(e.g., API_URL, VITE_*) and document them for the target project."
210+ ),
211+ "build_config" : (
212+ "If using Vite/Webpack configs, check for any module-specific "
213+ "settings that need to be merged."
214+ ),
150215 },
151- {
152- "step" : 5 ,
153- "action" : "prepare_dry_run_patch" ,
154- "description" : f"Generate a dry-run preview for fusing '{ module_name } ' into the target project." ,
155- "details" : {
156- "status" : "BLOCKED — v0.2 is planning-only; patch generation is deferred to v0.3+." ,
157- "planned_actions" : "Produce create/copy/modify operations with before/after paths for review." ,
158- },
159- "estimated_complexity" : "low (planning only)" ,
216+ "estimated_complexity" : "medium" ,
217+ })
218+
219+ # Step 5: Prepare dry-run patch
220+ steps .append ({
221+ "step" : 5 ,
222+ "action" : "prepare_dry_run_patch" ,
223+ "description" : (
224+ f"Generate a dry-run preview of all changes that would be made "
225+ f"when fusing '{ module_name } ' into the target project."
226+ ),
227+ "details" : {
228+ "status" : "BLOCKED — v0.2 is planning-only; patch generation is deferred to v0.3+." ,
229+ "planned_actions" : (
230+ "This step will eventually produce a list of create/copy/modify "
231+ "operations with before/after paths, enabling a human to review "
232+ "the full impact before execution."
233+ ),
160234 },
161- ]
235+ "estimated_complexity" : "low (planning only)" ,
236+ })
237+
238+ return steps
162239
163240
164241def _build_human_decisions (
@@ -167,66 +244,85 @@ def _build_human_decisions(
167244 conflicts : dict [str , Any ],
168245 deps : dict [str , Any ],
169246) -> list [dict [str , Any ]]:
247+ """Build the list of required human decisions for this fusion plan."""
248+
170249 source_paths : list [str ] = candidate .get ("source_paths" , [])
171250 target_paths : list [str ] = candidate .get ("target_paths" , [])
172251 has_same_path = bool (source_paths and target_paths )
173252 ver_conflicts = deps .get ("version_conflicts" , [])
174253
175- return [
176- {
177- "decision_id" : "same_named_files" ,
178- "question" : f"How should same-named files in module '{ module_name } ' be handled?" ,
179- "context" : (
180- f"Source and target both contain a module named '{ module_name } '. Files with identical names must be resolved."
181- if has_same_path
182- else f"No same-named file conflicts are currently detected for '{ module_name } ', but this should be confirmed."
183- ),
184- "options" : [
185- {"value" : "overwrite" , "label" : "Overwrite" , "description" : "Replace target files with source versions." },
186- {"value" : "namespace" , "label" : "Namespace" , "description" : "Rename source files to avoid collisions." },
187- {"value" : "skip" , "label" : "Skip" , "description" : "Skip conflicting files." },
188- {"value" : "manual_merge" , "label" : "Manual Merge" , "description" : "Manually merge each conflicting file." },
189- ],
190- "is_blocking" : True ,
191- },
192- {
193- "decision_id" : "dependency_updates" ,
194- "question" : "Should dependency conflicts allow updating package.json / requirements.txt?" ,
195- "context" : (
196- f"{ len (ver_conflicts )} dependency version conflict(s) detected."
197- if ver_conflicts
198- else "No dependency version conflicts detected; confirm whether new dependencies are needed."
199- ),
200- "options" : [
201- {"value" : "allow" , "label" : "Allow Updates" , "description" : "Permit dependency file changes." },
202- {"value" : "deny" , "label" : "Deny Updates" , "description" : "Resolve conflicts manually." },
203- {"value" : "review" , "label" : "Review Each" , "description" : "Review each conflict individually." },
204- ],
205- "is_blocking" : True ,
206- },
207- {
208- "decision_id" : "route_integration" ,
209- "question" : "Should routing conflicts be resolved by integrating into target routes?" ,
210- "context" : f"If '{ module_name } ' defines routes or entry points, decide whether to merge or isolate them." ,
211- "options" : [
212- {"value" : "integrate" , "label" : "Integrate Routes" , "description" : "Merge into target routing." },
213- {"value" : "isolate" , "label" : "Isolate Routes" , "description" : "Keep routes separate." },
214- {"value" : "defer" , "label" : "Defer Decision" , "description" : "Revisit after initial fusion." },
215- ],
216- "is_blocking" : False ,
217- },
218- {
219- "decision_id" : "preserve_structure" ,
220- "question" : "Should the original source directory structure be preserved?" ,
221- "context" : (
222- f"Source: { ', ' .join (source_paths ) if source_paths else '(none)' } . "
223- f"Target: { ', ' .join (target_paths ) if target_paths else '(none)' } ."
224- ),
225- "options" : [
226- {"value" : "preserve" , "label" : "Preserve Structure" , "description" : "Keep the source layout." },
227- {"value" : "flatten" , "label" : "Flatten Structure" , "description" : "Flatten into target structure." },
228- {"value" : "adapt" , "label" : "Adapt to Target" , "description" : "Reorganize to target conventions." },
229- ],
230- "is_blocking" : False ,
231- },
232- ]
254+ decisions : list [dict [str , Any ]] = []
255+
256+ # Decision 1: How to handle same-named files
257+ decisions .append ({
258+ "decision_id" : "same_named_files" ,
259+ "question" : f"How should same-named files in module '{ module_name } ' be handled?" ,
260+ "context" : (
261+ f"Source and target both contain a module named '{ module_name } '. "
262+ f"Files with identical names exist in both projects and must be resolved."
263+ ) if has_same_path else (
264+ f"Currently no same-named file conflicts detected for '{ module_name } ', "
265+ f"but this should be confirmed before proceeding."
266+ ),
267+ "options" : [
268+ {"value" : "overwrite" , "label" : "Overwrite" , "description" : "Replace target files with source versions." },
269+ {"value" : "namespace" , "label" : "Namespace" , "description" : "Rename source files to avoid collisions (e.g., add '_fusion' suffix)." },
270+ {"value" : "skip" , "label" : "Skip" , "description" : "Skip conflicting files; only copy non-conflicting ones." },
271+ {"value" : "manual_merge" , "label" : "Manual Merge" , "description" : "Manually review and merge each conflicting file." },
272+ ],
273+ "is_blocking" : True ,
274+ })
275+
276+ # Decision 2: Dependency conflicts
277+ decisions .append ({
278+ "decision_id" : "dependency_updates" ,
279+ "question" : "Should dependency conflicts allow updating package.json / requirements.txt?" ,
280+ "context" : (
281+ f"{ len (ver_conflicts )} dependency version conflict(s) detected. "
282+ f"Resolving these may require updating the target project's dependency files."
283+ ) if ver_conflicts else (
284+ "No dependency version conflicts detected for this module. "
285+ "Confirm that no new dependencies need to be added to the target project."
286+ ),
287+ "options" : [
288+ {"value" : "allow" , "label" : "Allow Updates" , "description" : "Permit modification of package.json / requirements.txt to resolve conflicts." },
289+ {"value" : "deny" , "label" : "Deny Updates" , "description" : "Do not modify dependency files; resolve conflicts manually." },
290+ {"value" : "review" , "label" : "Review Each" , "description" : "Review each dependency conflict individually before deciding." },
291+ ],
292+ "is_blocking" : True ,
293+ })
294+
295+ # Decision 3: Route conflicts
296+ decisions .append ({
297+ "decision_id" : "route_integration" ,
298+ "question" : "Should routing conflicts be resolved by integrating into target routes?" ,
299+ "context" : (
300+ f"If module '{ module_name } ' defines its own routes or entry points, "
301+ f"decide whether to merge them into the target project's routing system "
302+ f"or keep them separate."
303+ ),
304+ "options" : [
305+ {"value" : "integrate" , "label" : "Integrate Routes" , "description" : "Merge source routes into the target project's routing system." },
306+ {"value" : "isolate" , "label" : "Isolate Routes" , "description" : "Keep source routes separate — may need a sub-path prefix." },
307+ {"value" : "defer" , "label" : "Defer Decision" , "description" : "Leave routing as-is and revisit after initial fusion." },
308+ ],
309+ "is_blocking" : False ,
310+ })
311+
312+ # Decision 4: Source directory structure preservation
313+ decisions .append ({
314+ "decision_id" : "preserve_structure" ,
315+ "question" : "Should the original directory structure of the source module be preserved?" ,
316+ "context" : (
317+ f"Module '{ module_name } ' at source path(s): { ', ' .join (source_paths ) if source_paths else '(none)' } . "
318+ f"Target path(s): { ', ' .join (target_paths ) if target_paths else '(none)' } ."
319+ ),
320+ "options" : [
321+ {"value" : "preserve" , "label" : "Preserve Structure" , "description" : "Keep the source module's original directory layout when copying to target." },
322+ {"value" : "flatten" , "label" : "Flatten Structure" , "description" : "Flatten the module files into the target's existing directory structure." },
323+ {"value" : "adapt" , "label" : "Adapt to Target" , "description" : "Reorganize to match the target project's conventions." },
324+ ],
325+ "is_blocking" : False ,
326+ })
327+
328+ return decisions
0 commit comments