mcp-souschef 2.8.0__py3-none-any.whl → 3.2.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 (36) hide show
  1. {mcp_souschef-2.8.0.dist-info → mcp_souschef-3.2.0.dist-info}/METADATA +159 -384
  2. mcp_souschef-3.2.0.dist-info/RECORD +47 -0
  3. {mcp_souschef-2.8.0.dist-info → mcp_souschef-3.2.0.dist-info}/WHEEL +1 -1
  4. souschef/__init__.py +31 -7
  5. souschef/assessment.py +1451 -105
  6. souschef/ci/common.py +126 -0
  7. souschef/ci/github_actions.py +3 -92
  8. souschef/ci/gitlab_ci.py +2 -52
  9. souschef/ci/jenkins_pipeline.py +2 -59
  10. souschef/cli.py +149 -16
  11. souschef/converters/playbook.py +378 -138
  12. souschef/converters/resource.py +12 -11
  13. souschef/converters/template.py +177 -0
  14. souschef/core/__init__.py +6 -1
  15. souschef/core/metrics.py +313 -0
  16. souschef/core/path_utils.py +233 -19
  17. souschef/core/validation.py +53 -0
  18. souschef/deployment.py +71 -12
  19. souschef/generators/__init__.py +13 -0
  20. souschef/generators/repo.py +695 -0
  21. souschef/parsers/attributes.py +1 -1
  22. souschef/parsers/habitat.py +1 -1
  23. souschef/parsers/inspec.py +25 -2
  24. souschef/parsers/metadata.py +5 -3
  25. souschef/parsers/recipe.py +1 -1
  26. souschef/parsers/resource.py +1 -1
  27. souschef/parsers/template.py +1 -1
  28. souschef/server.py +1039 -121
  29. souschef/ui/app.py +486 -374
  30. souschef/ui/pages/ai_settings.py +74 -8
  31. souschef/ui/pages/cookbook_analysis.py +3216 -373
  32. souschef/ui/pages/validation_reports.py +274 -0
  33. mcp_souschef-2.8.0.dist-info/RECORD +0 -42
  34. souschef/converters/cookbook_specific.py.backup +0 -109
  35. {mcp_souschef-2.8.0.dist-info → mcp_souschef-3.2.0.dist-info}/entry_points.txt +0 -0
  36. {mcp_souschef-2.8.0.dist-info → mcp_souschef-3.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -30,20 +30,14 @@ def _parse_properties(properties_str: str) -> dict[str, Any]:
30
30
  if not properties_str:
31
31
  return {}
32
32
  try:
33
- # Try ast.literal_eval first for safety
33
+ # Use ast.literal_eval for safe parsing of Python literals
34
34
  result = ast.literal_eval(properties_str)
35
35
  if isinstance(result, dict):
36
36
  return result
37
37
  return {}
38
38
  except (ValueError, SyntaxError):
39
- # Fallback to eval if needed, but this is less safe
40
- try:
41
- result = eval(properties_str) # noqa: S307
42
- if isinstance(result, dict):
43
- return result
44
- return {}
45
- except Exception:
46
- return {}
39
+ # If parsing fails, return empty dict rather than using unsafe eval
40
+ return {}
47
41
 
48
42
 
49
43
  def _normalize_template_value(value: Any) -> Any:
@@ -245,13 +239,17 @@ def _get_include_recipe_params(
245
239
  Build parameters for include_recipe resources.
246
240
 
247
241
  Uses cookbook-specific configurations when available.
242
+ Falls back to include_role for unknown cookbooks.
248
243
  """
249
244
  cookbook_config = get_cookbook_package_config(resource_name)
250
245
  if cookbook_config:
251
246
  # Return a copy to prevent callers from mutating the shared mapping.
252
247
  return dict(cookbook_config["params"])
253
- # Default behavior for recipes without a specific mapping.
254
- return {"name": resource_name, "state": "present"}
248
+
249
+ # For unknown cookbooks, use include_role with the cookbook name
250
+ # Extract cookbook name from "cookbook::recipe" format
251
+ cookbook_name = resource_name.split("::")[0]
252
+ return {"name": cookbook_name}
255
253
 
256
254
 
257
255
  def _get_default_params(resource_name: str, action: str) -> dict[str, Any]:
@@ -303,6 +301,9 @@ def _convert_chef_resource_to_ansible(
303
301
  cookbook_config = get_cookbook_package_config(resource_name)
304
302
  if cookbook_config:
305
303
  ansible_module = cookbook_config["module"]
304
+ else:
305
+ # For include_recipe without specific mapping, use include_role
306
+ ansible_module = "ansible.builtin.include_role"
306
307
 
307
308
  # Handle unknown resource types
308
309
  if ansible_module is None:
@@ -0,0 +1,177 @@
1
+ """Chef ERB template to Jinja2 converter."""
2
+
3
+ from pathlib import Path
4
+
5
+ from souschef.parsers.template import (
6
+ _convert_erb_to_jinja2,
7
+ _extract_template_variables,
8
+ )
9
+
10
+
11
+ def convert_template_file(erb_path: str) -> dict:
12
+ """
13
+ Convert an ERB template file to Jinja2 format.
14
+
15
+ Args:
16
+ erb_path: Path to the ERB template file.
17
+
18
+ Returns:
19
+ Dictionary containing:
20
+ - success: bool, whether conversion succeeded
21
+ - original_file: str, path to original ERB file
22
+ - jinja2_file: str, suggested path for .j2 file
23
+ - jinja2_content: str, converted Jinja2 template content
24
+ - variables: list, variables found in template
25
+ - error: str (optional), error message if conversion failed
26
+
27
+ """
28
+ try:
29
+ file_path = Path(erb_path).resolve()
30
+
31
+ if not file_path.exists():
32
+ return {
33
+ "success": False,
34
+ "error": f"File not found: {erb_path}",
35
+ "original_file": erb_path,
36
+ }
37
+
38
+ if not file_path.is_file():
39
+ return {
40
+ "success": False,
41
+ "error": f"Path is not a file: {erb_path}",
42
+ "original_file": erb_path,
43
+ }
44
+
45
+ # Read ERB template
46
+ try:
47
+ content = file_path.read_text(encoding="utf-8")
48
+ except UnicodeDecodeError:
49
+ return {
50
+ "success": False,
51
+ "error": f"Unable to decode {erb_path} as UTF-8 text",
52
+ "original_file": str(file_path),
53
+ }
54
+
55
+ # Extract variables
56
+ variables = _extract_template_variables(content)
57
+
58
+ # Convert ERB to Jinja2
59
+ jinja2_content = _convert_erb_to_jinja2(content)
60
+
61
+ # Determine output file name
62
+ jinja2_file = str(file_path).replace(".erb", ".j2")
63
+
64
+ return {
65
+ "success": True,
66
+ "original_file": str(file_path),
67
+ "jinja2_file": jinja2_file,
68
+ "jinja2_content": jinja2_content,
69
+ "variables": sorted(variables),
70
+ }
71
+
72
+ except Exception as e:
73
+ return {
74
+ "success": False,
75
+ "error": f"Conversion failed: {e}",
76
+ "original_file": erb_path,
77
+ }
78
+
79
+
80
+ def convert_cookbook_templates(cookbook_path: str) -> dict:
81
+ """
82
+ Convert all ERB templates in a cookbook to Jinja2.
83
+
84
+ Args:
85
+ cookbook_path: Path to the cookbook directory.
86
+
87
+ Returns:
88
+ Dictionary containing:
89
+ - success: bool, whether all conversions succeeded
90
+ - templates_converted: int, number of templates successfully converted
91
+ - templates_failed: int, number of templates that failed conversion
92
+ - results: list of dict, individual template conversion results
93
+ - error: str (optional), error message if cookbook not found
94
+
95
+ """
96
+ try:
97
+ cookbook_dir = Path(cookbook_path).resolve()
98
+
99
+ if not cookbook_dir.exists():
100
+ return {
101
+ "success": False,
102
+ "error": f"Cookbook directory not found: {cookbook_path}",
103
+ "templates_converted": 0,
104
+ "templates_failed": 0,
105
+ "results": [],
106
+ }
107
+
108
+ # Find all .erb files in the cookbook
109
+ erb_files = list(cookbook_dir.glob("**/*.erb"))
110
+
111
+ if not erb_files:
112
+ return {
113
+ "success": True,
114
+ "templates_converted": 0,
115
+ "templates_failed": 0,
116
+ "results": [],
117
+ "message": "No ERB templates found in cookbook",
118
+ }
119
+
120
+ results = []
121
+ templates_converted = 0
122
+ templates_failed = 0
123
+
124
+ for erb_file in erb_files:
125
+ result = convert_template_file(str(erb_file))
126
+ results.append(result)
127
+
128
+ if result["success"]:
129
+ templates_converted += 1
130
+ else:
131
+ templates_failed += 1
132
+
133
+ return {
134
+ "success": templates_failed == 0,
135
+ "templates_converted": templates_converted,
136
+ "templates_failed": templates_failed,
137
+ "results": results,
138
+ }
139
+
140
+ except Exception as e:
141
+ return {
142
+ "success": False,
143
+ "error": f"Failed to convert cookbook templates: {e}",
144
+ "templates_converted": 0,
145
+ "templates_failed": 0,
146
+ "results": [],
147
+ }
148
+
149
+
150
+ def convert_template_with_ai(erb_path: str, ai_service=None) -> dict:
151
+ """
152
+ Convert an ERB template to Jinja2 using AI assistance for complex conversions.
153
+
154
+ This function first attempts rule-based conversion, then optionally uses AI
155
+ for validation or complex Ruby logic that can't be automatically converted.
156
+
157
+ Args:
158
+ erb_path: Path to the ERB template file.
159
+ ai_service: Optional AI service instance for complex conversions.
160
+
161
+ Returns:
162
+ Dictionary with conversion results (same format as convert_template_file).
163
+
164
+ """
165
+ # Start with rule-based conversion
166
+ result = convert_template_file(erb_path)
167
+
168
+ # Add conversion method metadata
169
+ result["conversion_method"] = "rule-based"
170
+
171
+ # Future enhancement: Use AI service to validate/improve complex conversions
172
+ if ai_service is not None:
173
+ # AI validation/improvement logic deferred to future enhancement
174
+ # when AI integration becomes more critical to the template conversion process
175
+ pass
176
+
177
+ return result
souschef/core/__init__.py CHANGED
@@ -50,7 +50,11 @@ from souschef.core.errors import (
50
50
  validate_directory_exists,
51
51
  validate_file_exists,
52
52
  )
53
- from souschef.core.path_utils import _normalize_path, _safe_join
53
+ from souschef.core.path_utils import (
54
+ _ensure_within_base_path,
55
+ _normalize_path,
56
+ _safe_join,
57
+ )
54
58
  from souschef.core.ruby_utils import _normalize_ruby_value
55
59
  from souschef.core.validation import (
56
60
  ValidationCategory,
@@ -63,6 +67,7 @@ __all__ = [
63
67
  "_normalize_path",
64
68
  "_normalize_ruby_value",
65
69
  "_safe_join",
70
+ "_ensure_within_base_path",
66
71
  "ValidationCategory",
67
72
  "ValidationEngine",
68
73
  "ValidationLevel",
@@ -0,0 +1,313 @@
1
+ """Centralized metrics and effort calculation module for consistent time estimations."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+ from typing import NamedTuple
6
+
7
+ __all__ = [
8
+ "ComplexityLevel",
9
+ "EffortMetrics",
10
+ "convert_days_to_hours",
11
+ "convert_days_to_weeks",
12
+ "convert_hours_to_days",
13
+ "estimate_effort_for_complexity",
14
+ "get_team_recommendation",
15
+ "get_timeline_weeks",
16
+ ]
17
+
18
+
19
+ class ComplexityLevel(str, Enum):
20
+ """Standard complexity levels used across all components."""
21
+
22
+ LOW = "low"
23
+ MEDIUM = "medium"
24
+ HIGH = "high"
25
+
26
+
27
+ @dataclass
28
+ class EffortMetrics:
29
+ """
30
+ Centralized container for all effort estimates.
31
+
32
+ Provides consistent representations across different formats:
33
+ - Base unit: person-days (with decimal precision)
34
+ - Derived: hours, weeks with consistent conversion factors
35
+ - Ranges: For display purposes, converting days to week ranges
36
+
37
+ Ensures all components (migration planning, dependency mapping,
38
+ validation reports) use the same underlying numbers.
39
+ """
40
+
41
+ estimated_days: float
42
+ """Base unit: person-days (e.g., 2.5, 5.0, 10.0)"""
43
+
44
+ @property
45
+ def estimated_hours(self) -> float:
46
+ """Convert days to hours using standard 8-hour workday."""
47
+ return self.estimated_days * 8
48
+
49
+ @property
50
+ def estimated_weeks_low(self) -> int:
51
+ """Conservative estimate: assumes optimal parallelization."""
52
+ return max(1, int(self.estimated_days / 7))
53
+
54
+ @property
55
+ def estimated_weeks_high(self) -> int:
56
+ """Realistic estimate: assumes sequential/limited parallelization."""
57
+ return max(1, int(self.estimated_days / 3.5))
58
+
59
+ @property
60
+ def estimated_weeks_range(self) -> str:
61
+ """Human-readable week range (e.g., '2-4 weeks')."""
62
+ low = self.estimated_weeks_low
63
+ high = self.estimated_weeks_high
64
+ if low == high:
65
+ return f"{low} week{'s' if low != 1 else ''}"
66
+ return f"{low}-{high} weeks"
67
+
68
+ @property
69
+ def estimated_days_formatted(self) -> str:
70
+ """Formatted days with appropriate precision."""
71
+ if self.estimated_days == int(self.estimated_days):
72
+ return f"{int(self.estimated_days)} days"
73
+ return f"{self.estimated_days:.1f} days"
74
+
75
+ def __str__(self) -> str:
76
+ """Return a string representation of effort metrics."""
77
+ return f"{self.estimated_days_formatted} ({self.estimated_weeks_range})"
78
+
79
+
80
+ class TeamRecommendation(NamedTuple):
81
+ """Team composition and timeline recommendation."""
82
+
83
+ team_size: str
84
+ """e.g., '1 developer + 1 reviewer'"""
85
+
86
+ timeline_weeks: int
87
+ """Recommended timeline in weeks"""
88
+
89
+ description: str
90
+ """Human-readable description of the recommendation"""
91
+
92
+
93
+ # Conversion constants - Single source of truth
94
+ HOURS_PER_WORKDAY = 8
95
+ DAYS_PER_WEEK = 7
96
+
97
+ # Complexity thresholds for automatic categorization
98
+ COMPLEXITY_THRESHOLD_LOW = 30
99
+ COMPLEXITY_THRESHOLD_HIGH = 70
100
+
101
+ # Effort multiplier per resource (base 1 resource = baseline effort)
102
+ EFFORT_MULTIPLIER_PER_RESOURCE = 0.125 # 0.125 days = 1 hour per resource
103
+
104
+
105
+ def convert_days_to_hours(days: float) -> float:
106
+ """Convert person-days to hours using standard 8-hour workday."""
107
+ return days * HOURS_PER_WORKDAY
108
+
109
+
110
+ def convert_hours_to_days(hours: float) -> float:
111
+ """Convert hours to person-days using standard 8-hour workday."""
112
+ return hours / HOURS_PER_WORKDAY
113
+
114
+
115
+ def convert_days_to_weeks(days: float, conservative: bool = False) -> int:
116
+ """
117
+ Convert days to weeks estimate.
118
+
119
+ Args:
120
+ days: Number of person-days
121
+ conservative: If True, use realistic estimate (1 engineer, limited
122
+ parallelization). If False, use optimistic estimate (full
123
+ parallelization)
124
+
125
+ Returns:
126
+ Number of weeks (integer)
127
+
128
+ """
129
+ weeks = days / 3.5 if conservative else days / DAYS_PER_WEEK
130
+ return max(1, int(weeks))
131
+
132
+
133
+ def estimate_effort_for_complexity(
134
+ complexity_score: float, resource_count: int = 1
135
+ ) -> EffortMetrics:
136
+ """
137
+ Estimate effort based on complexity score and resource count.
138
+
139
+ Provides consistent effort estimation across all components.
140
+
141
+ Formula:
142
+ - Base effort: resource_count * 0.125 days per recipe/resource
143
+ - Complexity multiplier: 1.0 + (complexity_score / 100)
144
+ - Final effort: base_effort * complexity_multiplier
145
+
146
+ Args:
147
+ complexity_score: Score from 0-100 (0=simple, 100=complex)
148
+ resource_count: Number of resources to migrate (recipes, templates, etc.)
149
+
150
+ Returns:
151
+ EffortMetrics object with all representations
152
+
153
+ """
154
+ base_effort = resource_count * EFFORT_MULTIPLIER_PER_RESOURCE
155
+ complexity_multiplier = 1.0 + (complexity_score / 100)
156
+ estimated_days = base_effort * complexity_multiplier
157
+
158
+ return EffortMetrics(estimated_days=round(estimated_days, 1))
159
+
160
+
161
+ def categorize_complexity(score: float) -> ComplexityLevel:
162
+ """
163
+ Categorize complexity score into standard levels.
164
+
165
+ Consistent thresholds across all components:
166
+ - Low: 0-29
167
+ - Medium: 30-69
168
+ - High: 70-100
169
+
170
+ Args:
171
+ score: Complexity score from 0-100
172
+
173
+ Returns:
174
+ ComplexityLevel enum value
175
+
176
+ """
177
+ if score < COMPLEXITY_THRESHOLD_LOW:
178
+ return ComplexityLevel.LOW
179
+ elif score < COMPLEXITY_THRESHOLD_HIGH:
180
+ return ComplexityLevel.MEDIUM
181
+ else:
182
+ return ComplexityLevel.HIGH
183
+
184
+
185
+ def get_team_recommendation(total_effort_days: float) -> TeamRecommendation:
186
+ """
187
+ Get team composition and timeline recommendation based on total effort.
188
+
189
+ Consistent recommendations across all components.
190
+
191
+ Args:
192
+ total_effort_days: Total person-days of effort
193
+
194
+ Returns:
195
+ TeamRecommendation with team size and timeline
196
+
197
+ """
198
+ if total_effort_days < 20:
199
+ return TeamRecommendation(
200
+ team_size="1 developer + 1 reviewer",
201
+ timeline_weeks=4,
202
+ description="Single developer with oversight",
203
+ )
204
+ elif total_effort_days < 50:
205
+ return TeamRecommendation(
206
+ team_size="2 developers + 1 senior reviewer",
207
+ timeline_weeks=6,
208
+ description="Small dedicated team",
209
+ )
210
+ else:
211
+ return TeamRecommendation(
212
+ team_size="3-4 developers + 1 tech lead + 1 architect",
213
+ timeline_weeks=10,
214
+ description="Large dedicated migration team",
215
+ )
216
+
217
+
218
+ def get_timeline_weeks(total_effort_days: float, strategy: str = "phased") -> int:
219
+ """
220
+ Calculate recommended timeline in weeks based on effort and strategy.
221
+
222
+ Consistent timeline calculation across planning, dependency mapping, and reports.
223
+
224
+ Args:
225
+ total_effort_days: Total person-days estimated
226
+ strategy: Migration strategy ('phased', 'big_bang', 'parallel')
227
+
228
+ Returns:
229
+ Recommended timeline in weeks
230
+
231
+ """
232
+ # Base calculation: distribute effort across team capacity
233
+ # Assume 3-5 person-days of output per week with normal team capacity
234
+ base_weeks = max(2, int(total_effort_days / 4.5))
235
+
236
+ # Apply strategy adjustments
237
+ if strategy == "phased":
238
+ # Phased adds overhead for testing between phases
239
+ return int(base_weeks * 1.1)
240
+ elif strategy == "big_bang":
241
+ # Big bang is faster but riskier
242
+ return int(base_weeks * 0.9)
243
+ else: # parallel
244
+ # Parallel has some overhead for coordination
245
+ return int(base_weeks * 1.05)
246
+
247
+
248
+ def validate_metrics_consistency(
249
+ days: float, weeks: str, hours: float, complexity: str
250
+ ) -> tuple[bool, list[str]]:
251
+ """
252
+ Validate that different metric representations are consistent.
253
+
254
+ Used for validation reports to catch contradictions.
255
+
256
+ Args:
257
+ days: Days estimate
258
+ weeks: Weeks range string (e.g., "2-4 weeks")
259
+ hours: Hours estimate
260
+ complexity: Complexity level string
261
+
262
+ Returns:
263
+ Tuple of (is_valid, list_of_errors)
264
+
265
+ """
266
+ errors = []
267
+
268
+ # Check hours consistency
269
+ expected_hours = days * 8
270
+ if abs(hours - expected_hours) > 1.0: # Allow 1 hour tolerance
271
+ errors.append(
272
+ f"Hours mismatch: {hours:.1f} hours but {days} days = "
273
+ f"{expected_hours:.1f} hours"
274
+ )
275
+
276
+ # Check weeks consistency (loose check due to range)
277
+ # Valid formats: "1 week", "1-2 weeks"
278
+ if "week" not in weeks.lower():
279
+ errors.append(f"Invalid weeks format: {weeks}")
280
+ elif "-" in weeks:
281
+ # Range format: "X-Y weeks"
282
+ try:
283
+ parts = weeks.replace(" weeks", "").replace(" week", "").split("-")
284
+ week_min = int(parts[0].strip())
285
+ week_max = int(parts[1].strip())
286
+ expected_weeks = int(days / 3.5) # Conservative estimate
287
+
288
+ if not (week_min <= expected_weeks <= week_max + 1):
289
+ errors.append(
290
+ f"Weeks mismatch: {weeks} but {days} days should be "
291
+ f"approximately {expected_weeks} weeks"
292
+ )
293
+ except (ValueError, IndexError):
294
+ errors.append(f"Invalid weeks format: {weeks}")
295
+ else:
296
+ # Single week format: "X week" or "X weeks"
297
+ try:
298
+ num = int(weeks.replace(" weeks", "").replace(" week", "").strip())
299
+ expected_weeks = int(days / 3.5)
300
+ if num != expected_weeks and abs(num - expected_weeks) > 2:
301
+ errors.append(
302
+ f"Weeks mismatch: {weeks} but {days} days should be "
303
+ f"approximately {expected_weeks} weeks"
304
+ )
305
+ except ValueError:
306
+ errors.append(f"Invalid weeks format: {weeks}")
307
+
308
+ # Check complexity is valid
309
+ valid_complexities = {level.value for level in ComplexityLevel}
310
+ if complexity.lower() not in valid_complexities:
311
+ errors.append(f"Invalid complexity level: {complexity}")
312
+
313
+ return len(errors) == 0, errors