robopilot 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. robopilot/__init__.py +0 -0
  2. robopilot/apply/__init__.py +5 -0
  3. robopilot/apply/apply_plan.py +218 -0
  4. robopilot/apply_plan/__init__.py +13 -0
  5. robopilot/apply_plan/plan.py +216 -0
  6. robopilot/apply_preview/__init__.py +5 -0
  7. robopilot/apply_preview/preview.py +156 -0
  8. robopilot/debugger/__init__.py +6 -0
  9. robopilot/debugger/log_analyzer.py +198 -0
  10. robopilot/deps/__init__.py +5 -0
  11. robopilot/deps/analyzer.py +440 -0
  12. robopilot/detector/__init__.py +5 -0
  13. robopilot/detector/project_detector.py +349 -0
  14. robopilot/diff/__init__.py +5 -0
  15. robopilot/diff/spec_diff.py +189 -0
  16. robopilot/generator/__init__.py +2 -0
  17. robopilot/generator/project_generator.py +106 -0
  18. robopilot/generator/project_spec.py +78 -0
  19. robopilot/generator/task_classifier.py +55 -0
  20. robopilot/generator/template_registry.py +177 -0
  21. robopilot/generator/templates.py +541 -0
  22. robopilot/graph/__init__.py +6 -0
  23. robopilot/graph/mermaid_generator.py +34 -0
  24. robopilot/history/__init__.py +15 -0
  25. robopilot/history/journal.py +226 -0
  26. robopilot/inspector/__init__.py +6 -0
  27. robopilot/inspector/project_inspector.py +287 -0
  28. robopilot/main.py +1294 -0
  29. robopilot/migration/__init__.py +31 -0
  30. robopilot/migration/plan_diff.py +177 -0
  31. robopilot/migration/plan_validator.py +142 -0
  32. robopilot/migration/preview.py +267 -0
  33. robopilot/migration/ros1_to_ros2.py +579 -0
  34. robopilot/planner/__init__.py +26 -0
  35. robopilot/planner/base.py +30 -0
  36. robopilot/planner/llm_planner.py +127 -0
  37. robopilot/planner/openai_client.py +67 -0
  38. robopilot/planner/prompts.py +59 -0
  39. robopilot/planner/provider_config.py +43 -0
  40. robopilot/planner/rule_based_planner.py +40 -0
  41. robopilot/refiner/__init__.py +6 -0
  42. robopilot/refiner/llm_refiner.py +126 -0
  43. robopilot/refiner/spec_refiner.py +172 -0
  44. robopilot/repair/__init__.py +9 -0
  45. robopilot/repair/repair_suggester.py +269 -0
  46. robopilot/report/__init__.py +5 -0
  47. robopilot/report/project_report.py +92 -0
  48. robopilot/rollback/__init__.py +5 -0
  49. robopilot/rollback/rollback.py +155 -0
  50. robopilot/ros1/__init__.py +5 -0
  51. robopilot/ros1/inspector.py +403 -0
  52. robopilot/spec/__init__.py +13 -0
  53. robopilot/spec/io.py +178 -0
  54. robopilot/spec/validator.py +66 -0
  55. robopilot/utils/__init__.py +2 -0
  56. robopilot/utils/file_ops.py +30 -0
  57. robopilot-1.1.0.dist-info/METADATA +207 -0
  58. robopilot-1.1.0.dist-info/RECORD +62 -0
  59. robopilot-1.1.0.dist-info/WHEEL +5 -0
  60. robopilot-1.1.0.dist-info/entry_points.txt +2 -0
  61. robopilot-1.1.0.dist-info/licenses/LICENSE +21 -0
  62. robopilot-1.1.0.dist-info/top_level.txt +1 -0
robopilot/__init__.py ADDED
File without changes
@@ -0,0 +1,5 @@
1
+ """Safe apply execution helpers."""
2
+
3
+ from robopilot.apply.apply_plan import ApplySummary, apply_from_plan
4
+
5
+ __all__ = ["ApplySummary", "apply_from_plan"]
@@ -0,0 +1,218 @@
1
+ """Safely apply a previously exported apply plan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ from robopilot.apply_plan.plan import (
11
+ apply_plan_from_preview,
12
+ load_apply_plan,
13
+ validate_apply_plan,
14
+ )
15
+ from robopilot.apply_preview.preview import preview_apply
16
+ from robopilot.generator.project_generator import render_project_files
17
+ from robopilot.history.journal import record_history_entry
18
+ from robopilot.spec.io import load_spec
19
+ from robopilot.spec.validator import validate_spec
20
+
21
+
22
+ SAFETY_NOTE = (
23
+ "RoboPilot apply validates the plan, re-runs apply-preview, refuses stale "
24
+ "plans and conflicts, and only writes files listed in files_to_create or "
25
+ "files_to_update when --confirm is provided."
26
+ )
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ApplySummary:
31
+ """Summary of a dry-run or confirmed apply operation."""
32
+
33
+ plan_path: str
34
+ project_path: str
35
+ dry_run: bool
36
+ files_created: tuple[str, ...]
37
+ files_updated: tuple[str, ...]
38
+ files_kept: tuple[str, ...]
39
+ backups_created: tuple[str, ...]
40
+ skipped_files: tuple[str, ...]
41
+ conflicts: tuple[str, ...]
42
+ safety_note: str
43
+
44
+ def to_dict(self) -> dict[str, object]:
45
+ """Return a stable JSON-serializable representation."""
46
+ return {
47
+ "plan_path": self.plan_path,
48
+ "project_path": self.project_path,
49
+ "dry_run": self.dry_run,
50
+ "files_created": list(self.files_created),
51
+ "files_updated": list(self.files_updated),
52
+ "files_kept": list(self.files_kept),
53
+ "backups_created": list(self.backups_created),
54
+ "skipped_files": list(self.skipped_files),
55
+ "conflicts": list(self.conflicts),
56
+ "safety_note": self.safety_note,
57
+ }
58
+
59
+
60
+ def apply_from_plan(plan_path: Path, *, confirm: bool = False) -> ApplySummary:
61
+ """Dry-run or apply a saved plan after conservative safety checks."""
62
+ plan = load_apply_plan(plan_path)
63
+ validation = validate_apply_plan(plan)
64
+ if not validation.is_valid:
65
+ raise ValueError("Invalid apply plan: " + "; ".join(validation.errors))
66
+
67
+ spec_path = Path(_string_field(plan, "spec_path"))
68
+ project_path = Path(_string_field(plan, "project_path"))
69
+ spec = load_spec(spec_path)
70
+ spec_validation = validate_spec(spec)
71
+ if not spec_validation.is_valid:
72
+ raise ValueError("Invalid ProjectSpec: " + "; ".join(spec_validation.errors))
73
+
74
+ fresh_preview = preview_apply(spec_path, project_path)
75
+ fresh_plan = apply_plan_from_preview(fresh_preview)
76
+ _ensure_plan_is_fresh(plan, fresh_plan)
77
+
78
+ conflicts = tuple(_list_field(plan, "conflicts"))
79
+ if confirm and conflicts:
80
+ raise ValueError("Refusing to apply because conflicts are present.")
81
+
82
+ files_to_create = tuple(_validate_relative_paths(_list_field(plan, "files_to_create")))
83
+ files_to_update = tuple(_validate_relative_paths(_list_field(plan, "files_to_update")))
84
+ files_to_keep = tuple(_validate_relative_paths(_list_field(plan, "files_to_keep")))
85
+ expected_files = {
86
+ path.as_posix(): content for path, content in render_project_files(spec).items()
87
+ }
88
+
89
+ _ensure_planned_files_are_expected(files_to_create + files_to_update, expected_files)
90
+
91
+ if not confirm:
92
+ return ApplySummary(
93
+ plan_path=str(plan_path),
94
+ project_path=str(project_path),
95
+ dry_run=True,
96
+ files_created=(),
97
+ files_updated=(),
98
+ files_kept=files_to_keep,
99
+ backups_created=(),
100
+ skipped_files=tuple(sorted(files_to_create + files_to_update + files_to_keep + conflicts)),
101
+ conflicts=conflicts,
102
+ safety_note="Dry run only. No files were modified. " + SAFETY_NOTE,
103
+ )
104
+
105
+ created: list[str] = []
106
+ updated: list[str] = []
107
+ backups: list[str] = []
108
+ backup_root = project_path / ".robopilot_backups" / _timestamp()
109
+
110
+ for relative_path in files_to_create:
111
+ target = project_path / relative_path
112
+ if target.exists():
113
+ raise ValueError(f"Refusing to create existing file: {relative_path}")
114
+ _write_expected_file(target, expected_files[relative_path])
115
+ created.append(relative_path)
116
+
117
+ for relative_path in files_to_update:
118
+ target = project_path / relative_path
119
+ if not target.is_file():
120
+ raise ValueError(f"Refusing to update missing file: {relative_path}")
121
+ backup_path = backup_root / relative_path
122
+ backup_path.parent.mkdir(parents=True, exist_ok=True)
123
+ shutil.copy2(target, backup_path)
124
+ backups.append(backup_path.relative_to(project_path).as_posix())
125
+ _write_expected_file(target, expected_files[relative_path])
126
+ updated.append(relative_path)
127
+
128
+ summary = ApplySummary(
129
+ plan_path=str(plan_path),
130
+ project_path=str(project_path),
131
+ dry_run=False,
132
+ files_created=tuple(sorted(created)),
133
+ files_updated=tuple(sorted(updated)),
134
+ files_kept=files_to_keep,
135
+ backups_created=tuple(sorted(backups)),
136
+ skipped_files=tuple(sorted(files_to_keep + conflicts)),
137
+ conflicts=conflicts,
138
+ safety_note=SAFETY_NOTE,
139
+ )
140
+ record_history_entry(
141
+ project_path=project_path,
142
+ operation="apply",
143
+ plan_path=str(plan_path),
144
+ dry_run=False,
145
+ success=True,
146
+ files_created=summary.files_created,
147
+ files_updated=summary.files_updated,
148
+ files_kept=summary.files_kept,
149
+ conflicts=summary.conflicts,
150
+ skipped_files=summary.skipped_files,
151
+ summary=(
152
+ f"Applied plan with {len(summary.files_created)} files created "
153
+ f"and {len(summary.files_updated)} files updated."
154
+ ),
155
+ )
156
+ return summary
157
+
158
+
159
+ def _ensure_plan_is_fresh(
160
+ saved_plan: dict[str, object],
161
+ fresh_plan: dict[str, object],
162
+ ) -> None:
163
+ compared_fields = (
164
+ "spec_path",
165
+ "project_path",
166
+ "package_name",
167
+ "selected_template",
168
+ "files_to_create",
169
+ "files_to_update",
170
+ "files_to_keep",
171
+ "conflicts",
172
+ "missing_project",
173
+ )
174
+ for field in compared_fields:
175
+ if saved_plan.get(field) != fresh_plan.get(field):
176
+ raise ValueError(f"Refusing to apply stale plan: {field} changed.")
177
+
178
+
179
+ def _ensure_planned_files_are_expected(
180
+ relative_paths: tuple[str, ...],
181
+ expected_files: dict[str, str],
182
+ ) -> None:
183
+ for relative_path in relative_paths:
184
+ if relative_path not in expected_files:
185
+ raise ValueError(f"Refusing to modify unexpected file: {relative_path}")
186
+
187
+
188
+ def _write_expected_file(path: Path, content: str) -> None:
189
+ path.parent.mkdir(parents=True, exist_ok=True)
190
+ path.write_text(content, encoding="utf-8")
191
+
192
+
193
+ def _string_field(plan: dict[str, object], field: str) -> str:
194
+ value = plan.get(field)
195
+ if not isinstance(value, str):
196
+ raise ValueError(f"{field} must be a string.")
197
+ return value
198
+
199
+
200
+ def _list_field(plan: dict[str, object], field: str) -> tuple[str, ...]:
201
+ value = plan.get(field)
202
+ if not isinstance(value, list):
203
+ raise ValueError(f"{field} must be a list.")
204
+ return tuple(str(item) for item in value)
205
+
206
+
207
+ def _validate_relative_paths(paths: tuple[str, ...]) -> tuple[str, ...]:
208
+ validated: list[str] = []
209
+ for raw_path in paths:
210
+ path = Path(raw_path)
211
+ if path.is_absolute() or ".." in path.parts:
212
+ raise ValueError(f"Refusing unsafe relative path: {raw_path}")
213
+ validated.append(path.as_posix())
214
+ return tuple(validated)
215
+
216
+
217
+ def _timestamp() -> str:
218
+ return datetime.now().strftime("%Y%m%d_%H%M%S")
@@ -0,0 +1,13 @@
1
+ """Read-only apply plan export helpers."""
2
+
3
+ from robopilot.apply_plan.plan import (
4
+ ApplyPlanValidationResult,
5
+ export_apply_plan,
6
+ validate_apply_plan_file,
7
+ )
8
+
9
+ __all__ = [
10
+ "ApplyPlanValidationResult",
11
+ "export_apply_plan",
12
+ "validate_apply_plan_file",
13
+ ]
@@ -0,0 +1,216 @@
1
+ """Export and validate read-only apply plans."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from robopilot.apply_preview.preview import ApplyPreviewResult, preview_apply
11
+
12
+
13
+ GENERATED_BY = "RoboPilot ApplyPlan"
14
+ SUPPORTED_FORMATS = {"yaml", "json"}
15
+ REQUIRED_FIELDS = (
16
+ "generated_by",
17
+ "spec_path",
18
+ "project_path",
19
+ "package_name",
20
+ "selected_template",
21
+ "files_to_create",
22
+ "files_to_update",
23
+ "files_to_keep",
24
+ "conflicts",
25
+ "missing_project",
26
+ "safety_note",
27
+ "suggested_next_steps",
28
+ )
29
+ LIST_FIELDS = {
30
+ "files_to_create",
31
+ "files_to_update",
32
+ "files_to_keep",
33
+ "conflicts",
34
+ "suggested_next_steps",
35
+ }
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ApplyPlanValidationResult:
40
+ """Validation result for a serialized apply plan."""
41
+
42
+ is_valid: bool
43
+ errors: tuple[str, ...]
44
+
45
+
46
+ def export_apply_plan(
47
+ *,
48
+ spec_path: Path,
49
+ project_path: Path,
50
+ output_path: Path,
51
+ output_format: str = "yaml",
52
+ ) -> dict[str, object]:
53
+ """Export a deterministic apply plan without modifying the project."""
54
+ normalized_format = output_format.strip().lower()
55
+ if normalized_format not in SUPPORTED_FORMATS:
56
+ raise ValueError("Unsupported apply plan format. Use 'yaml' or 'json'.")
57
+
58
+ preview = preview_apply(spec_path, project_path)
59
+ plan = apply_plan_from_preview(preview)
60
+ output_path.parent.mkdir(parents=True, exist_ok=True)
61
+ if normalized_format == "json":
62
+ output_path.write_text(json.dumps(plan, indent=2) + "\n", encoding="utf-8")
63
+ else:
64
+ output_path.write_text(apply_plan_to_yaml(plan), encoding="utf-8")
65
+ return plan
66
+
67
+
68
+ def apply_plan_from_preview(preview: ApplyPreviewResult) -> dict[str, object]:
69
+ """Convert an apply-preview result into a stable apply plan mapping."""
70
+ data = preview.to_dict()
71
+ return {
72
+ "generated_by": GENERATED_BY,
73
+ "spec_path": data["spec_path"],
74
+ "project_path": data["project_path"],
75
+ "package_name": data["package_name"],
76
+ "selected_template": data["selected_template"],
77
+ "files_to_create": data["files_to_create"],
78
+ "files_to_update": data["files_to_update"],
79
+ "files_to_keep": data["files_to_keep"],
80
+ "conflicts": data["conflicts"],
81
+ "missing_project": data["missing_project"],
82
+ "safety_note": data["safety_note"],
83
+ "suggested_next_steps": data["suggested_next_steps"],
84
+ }
85
+
86
+
87
+ def apply_plan_to_yaml(plan: dict[str, object]) -> str:
88
+ """Serialize an apply plan using RoboPilot's small YAML-like subset."""
89
+ lines: list[str] = []
90
+ for field in REQUIRED_FIELDS:
91
+ value = plan.get(field)
92
+ if field in LIST_FIELDS:
93
+ lines.append(f"{field}:")
94
+ for item in _list_value(value):
95
+ lines.append(f' - "{_quote(str(item))}"')
96
+ continue
97
+ if isinstance(value, bool):
98
+ rendered = "true" if value else "false"
99
+ else:
100
+ rendered = f'"{_quote(str(value or ""))}"'
101
+ lines.append(f"{field}: {rendered}")
102
+ return "\n".join(lines) + "\n"
103
+
104
+
105
+ def validate_apply_plan_file(plan_path: Path) -> ApplyPlanValidationResult:
106
+ """Validate a serialized apply plan without executing it."""
107
+ try:
108
+ plan = load_apply_plan(plan_path)
109
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
110
+ return ApplyPlanValidationResult(False, (str(exc),))
111
+ return validate_apply_plan(plan)
112
+
113
+
114
+ def validate_apply_plan(plan: dict[str, object]) -> ApplyPlanValidationResult:
115
+ """Validate required apply plan fields and basic types."""
116
+ errors: list[str] = []
117
+ for field in REQUIRED_FIELDS:
118
+ if field not in plan:
119
+ errors.append(f"{field} is required.")
120
+
121
+ for field in LIST_FIELDS:
122
+ if field in plan and not isinstance(plan[field], list):
123
+ errors.append(f"{field} must be a list.")
124
+
125
+ if "missing_project" in plan and not isinstance(plan["missing_project"], bool):
126
+ errors.append("missing_project must be a boolean.")
127
+
128
+ for field in (
129
+ "generated_by",
130
+ "spec_path",
131
+ "project_path",
132
+ "package_name",
133
+ "selected_template",
134
+ "safety_note",
135
+ ):
136
+ if field in plan and not isinstance(plan[field], str):
137
+ errors.append(f"{field} must be a string.")
138
+
139
+ return ApplyPlanValidationResult(is_valid=not errors, errors=tuple(errors))
140
+
141
+
142
+ def load_apply_plan(plan_path: Path) -> dict[str, object]:
143
+ """Load a JSON or RoboPilot YAML-like apply plan."""
144
+ content = plan_path.read_text(encoding="utf-8")
145
+ if plan_path.suffix.lower() == ".json" or content.lstrip().startswith("{"):
146
+ data = json.loads(content)
147
+ if not isinstance(data, dict):
148
+ raise ValueError("Apply plan JSON must be an object.")
149
+ return data
150
+ return apply_plan_from_yaml(content)
151
+
152
+
153
+ def apply_plan_from_yaml(content: str) -> dict[str, object]:
154
+ """Parse RoboPilot's small apply-plan YAML-like subset."""
155
+ data: dict[str, object] = {}
156
+ current_list: str | None = None
157
+ for raw_line in content.splitlines():
158
+ if not raw_line.strip() or raw_line.lstrip().startswith("#"):
159
+ continue
160
+ if not raw_line.startswith(" "):
161
+ key, value = _split_key_value(raw_line)
162
+ if value == "" and key in LIST_FIELDS:
163
+ data[key] = []
164
+ current_list = key
165
+ continue
166
+ current_list = None
167
+ if key == "missing_project":
168
+ data[key] = _parse_bool(value)
169
+ else:
170
+ data[key] = _unquote(value)
171
+ continue
172
+ if current_list is None:
173
+ raise ValueError(f"Unexpected indented line outside a list: {raw_line}")
174
+ stripped = raw_line.strip()
175
+ if not stripped.startswith("- "):
176
+ raise ValueError(f"Expected list item: {raw_line}")
177
+ value = _unquote(stripped[2:].strip())
178
+ current_value = data[current_list]
179
+ if not isinstance(current_value, list):
180
+ raise ValueError(f"Expected list field: {current_list}")
181
+ current_value.append(value)
182
+ return data
183
+
184
+
185
+ def _split_key_value(line: str) -> tuple[str, str]:
186
+ if ":" not in line:
187
+ raise ValueError(f"Expected key/value line: {line}")
188
+ key, value = line.split(":", 1)
189
+ return key.strip(), value.strip()
190
+
191
+
192
+ def _parse_bool(value: str) -> bool:
193
+ normalized = _unquote(value).strip().lower()
194
+ if normalized == "true":
195
+ return True
196
+ if normalized == "false":
197
+ return False
198
+ raise ValueError(f"Expected boolean value, got: {value}")
199
+
200
+
201
+ def _quote(value: str) -> str:
202
+ return value.replace("\\", "\\\\").replace('"', '\\"')
203
+
204
+
205
+ def _unquote(value: str) -> str:
206
+ if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
207
+ value = value[1:-1]
208
+ return value.replace('\\"', '"').replace("\\\\", "\\")
209
+
210
+
211
+ def _list_value(value: object) -> list[Any]:
212
+ if isinstance(value, list):
213
+ return value
214
+ if isinstance(value, tuple):
215
+ return list(value)
216
+ return []
@@ -0,0 +1,5 @@
1
+ """Read-only apply preview helpers."""
2
+
3
+ from robopilot.apply_preview.preview import ApplyPreviewResult, preview_apply
4
+
5
+ __all__ = ["ApplyPreviewResult", "preview_apply"]
@@ -0,0 +1,156 @@
1
+ """Read-only preview of applying a ProjectSpec to a project directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from robopilot.generator.project_generator import render_project_files
9
+ from robopilot.spec.io import load_spec
10
+ from robopilot.spec.validator import validate_spec
11
+
12
+
13
+ SAFETY_NOTE = (
14
+ "This is a read-only apply preview. RoboPilot did not modify project files, "
15
+ "execute ROS2, run launch files, run colcon, or execute generated Python code."
16
+ )
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ApplyPreviewResult:
21
+ """Structured apply preview result."""
22
+
23
+ spec_path: str
24
+ project_path: str
25
+ package_name: str
26
+ selected_template: str
27
+ files_to_create: tuple[str, ...]
28
+ files_to_update: tuple[str, ...]
29
+ files_to_keep: tuple[str, ...]
30
+ conflicts: tuple[str, ...]
31
+ missing_project: bool
32
+ safety_note: str
33
+ suggested_next_steps: tuple[str, ...]
34
+
35
+ def to_dict(self) -> dict[str, object]:
36
+ """Return a stable JSON-serializable representation."""
37
+ return {
38
+ "spec_path": self.spec_path,
39
+ "project_path": self.project_path,
40
+ "package_name": self.package_name,
41
+ "selected_template": self.selected_template,
42
+ "files_to_create": list(self.files_to_create),
43
+ "files_to_update": list(self.files_to_update),
44
+ "files_to_keep": list(self.files_to_keep),
45
+ "conflicts": list(self.conflicts),
46
+ "missing_project": self.missing_project,
47
+ "safety_note": self.safety_note,
48
+ "suggested_next_steps": list(self.suggested_next_steps),
49
+ }
50
+
51
+
52
+ def preview_apply(spec_path: Path, project_path: Path) -> ApplyPreviewResult:
53
+ """Preview what applying a ProjectSpec would change without writing files."""
54
+ spec = load_spec(spec_path)
55
+ validation = validate_spec(spec)
56
+ if not validation.is_valid:
57
+ raise ValueError("Invalid ProjectSpec: " + "; ".join(validation.errors))
58
+
59
+ expected_files = render_project_files(spec)
60
+ missing_project = not project_path.exists()
61
+ files_to_create: list[str] = []
62
+ files_to_update: list[str] = []
63
+ files_to_keep: list[str] = []
64
+
65
+ for relative_path, expected_content in expected_files.items():
66
+ normalized = _normalize_path(relative_path)
67
+ target = project_path / relative_path
68
+ if missing_project or not target.exists():
69
+ files_to_create.append(normalized)
70
+ continue
71
+ if not target.is_file():
72
+ files_to_update.append(normalized)
73
+ continue
74
+ try:
75
+ existing_content = target.read_text(encoding="utf-8")
76
+ except UnicodeDecodeError:
77
+ files_to_update.append(normalized)
78
+ continue
79
+ if existing_content == expected_content:
80
+ files_to_keep.append(normalized)
81
+ else:
82
+ files_to_update.append(normalized)
83
+
84
+ conflicts = _detect_conflicts(project_path, set(expected_files))
85
+ return ApplyPreviewResult(
86
+ spec_path=str(spec_path),
87
+ project_path=str(project_path),
88
+ package_name=spec.package_name,
89
+ selected_template=spec.selected_template,
90
+ files_to_create=tuple(sorted(files_to_create)),
91
+ files_to_update=tuple(sorted(files_to_update)),
92
+ files_to_keep=tuple(sorted(files_to_keep)),
93
+ conflicts=conflicts,
94
+ missing_project=missing_project,
95
+ safety_note=SAFETY_NOTE,
96
+ suggested_next_steps=_suggest_next_steps(
97
+ missing_project=missing_project,
98
+ files_to_create=files_to_create,
99
+ files_to_update=files_to_update,
100
+ conflicts=conflicts,
101
+ spec_path=spec_path,
102
+ project_path=project_path,
103
+ ),
104
+ )
105
+
106
+
107
+ def _detect_conflicts(project_path: Path, expected_paths: set[Path]) -> tuple[str, ...]:
108
+ if not project_path.exists() or not project_path.is_dir():
109
+ return ()
110
+
111
+ conflicts: list[str] = []
112
+ for path in project_path.rglob("*"):
113
+ if not path.is_file():
114
+ continue
115
+ relative_path = path.relative_to(project_path)
116
+ if _is_ignored_artifact(relative_path):
117
+ continue
118
+ if relative_path not in expected_paths:
119
+ conflicts.append(_normalize_path(relative_path))
120
+ return tuple(sorted(conflicts))
121
+
122
+
123
+ def _suggest_next_steps(
124
+ *,
125
+ missing_project: bool,
126
+ files_to_create: list[str],
127
+ files_to_update: list[str],
128
+ conflicts: tuple[str, ...],
129
+ spec_path: Path,
130
+ project_path: Path,
131
+ ) -> tuple[str, ...]:
132
+ steps: list[str] = []
133
+ if missing_project:
134
+ steps.append("Create the project with robopilot generate --spec before applying future changes.")
135
+ if files_to_create or files_to_update:
136
+ steps.append(f"Review this preview before generating from {spec_path}.")
137
+ if conflicts:
138
+ steps.append("Review conflicts manually; RoboPilot will not overwrite unexpected files blindly.")
139
+ if not steps:
140
+ steps.append("No file changes are needed for the expected generated files.")
141
+ steps.append(f"Run robopilot inspect {project_path} for a static project health check.")
142
+ return tuple(steps)
143
+
144
+
145
+ def _is_ignored_artifact(relative_path: Path) -> bool:
146
+ parts = set(relative_path.parts)
147
+ return (
148
+ "__pycache__" in parts
149
+ or ".robopilot_backups" in parts
150
+ or ".robopilot_history" in parts
151
+ or relative_path.suffix in {".pyc", ".pyo"}
152
+ )
153
+
154
+
155
+ def _normalize_path(path: Path) -> str:
156
+ return path.as_posix()
@@ -0,0 +1,6 @@
1
+ """Offline robotics error log analysis helpers."""
2
+
3
+ from robopilot.debugger.log_analyzer import LogAnalysis, analyze_log
4
+
5
+ __all__ = ["LogAnalysis", "analyze_log"]
6
+