Skip to content

Commit b1b23b0

Browse files
committed
refactor: preserve planner behavior around deterministic targets
1 parent 7405bda commit b1b23b0

1 file changed

Lines changed: 212 additions & 116 deletions

File tree

aetherfusion/planner/fusion_planner.py

Lines changed: 212 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -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

91114
def _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

164241
def _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

Comments
 (0)