mcp-souschef 3.0.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.
- {mcp_souschef-3.0.0.dist-info → mcp_souschef-3.2.0.dist-info}/METADATA +83 -380
- mcp_souschef-3.2.0.dist-info/RECORD +47 -0
- souschef/__init__.py +2 -10
- souschef/assessment.py +336 -181
- souschef/ci/common.py +1 -1
- souschef/cli.py +37 -13
- souschef/converters/playbook.py +119 -48
- souschef/core/__init__.py +6 -1
- souschef/core/path_utils.py +233 -19
- souschef/deployment.py +10 -3
- souschef/generators/__init__.py +13 -0
- souschef/generators/repo.py +695 -0
- souschef/parsers/attributes.py +1 -1
- souschef/parsers/habitat.py +1 -1
- souschef/parsers/inspec.py +25 -2
- souschef/parsers/metadata.py +5 -3
- souschef/parsers/recipe.py +1 -1
- souschef/parsers/resource.py +1 -1
- souschef/parsers/template.py +1 -1
- souschef/server.py +426 -188
- souschef/ui/app.py +24 -30
- souschef/ui/pages/cookbook_analysis.py +837 -163
- mcp_souschef-3.0.0.dist-info/RECORD +0 -46
- souschef/converters/cookbook_specific.py.backup +0 -109
- {mcp_souschef-3.0.0.dist-info → mcp_souschef-3.2.0.dist-info}/WHEEL +0 -0
- {mcp_souschef-3.0.0.dist-info → mcp_souschef-3.2.0.dist-info}/entry_points.txt +0 -0
- {mcp_souschef-3.0.0.dist-info → mcp_souschef-3.2.0.dist-info}/licenses/LICENSE +0 -0
souschef/assessment.py
CHANGED
|
@@ -6,11 +6,17 @@ generating migration plans, analyzing dependencies, and validating conversions.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import json
|
|
9
|
+
import os
|
|
9
10
|
import re
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from typing import Any
|
|
12
13
|
|
|
13
|
-
from souschef.core import
|
|
14
|
+
from souschef.core import (
|
|
15
|
+
METADATA_FILENAME,
|
|
16
|
+
_ensure_within_base_path,
|
|
17
|
+
_normalize_path,
|
|
18
|
+
_safe_join,
|
|
19
|
+
)
|
|
14
20
|
from souschef.core.errors import format_error_with_context
|
|
15
21
|
from souschef.core.metrics import (
|
|
16
22
|
ComplexityLevel,
|
|
@@ -18,6 +24,7 @@ from souschef.core.metrics import (
|
|
|
18
24
|
categorize_complexity,
|
|
19
25
|
estimate_effort_for_complexity,
|
|
20
26
|
)
|
|
27
|
+
from souschef.core.path_utils import _validated_candidate, safe_glob
|
|
21
28
|
from souschef.core.validation import (
|
|
22
29
|
ValidationEngine,
|
|
23
30
|
ValidationLevel,
|
|
@@ -32,13 +39,31 @@ except ImportError:
|
|
|
32
39
|
requests = None
|
|
33
40
|
|
|
34
41
|
try:
|
|
35
|
-
from ibm_watsonx_ai import
|
|
36
|
-
APIClient,
|
|
37
|
-
)
|
|
42
|
+
from ibm_watsonx_ai import APIClient # type: ignore[import-not-found]
|
|
38
43
|
except ImportError:
|
|
39
44
|
APIClient = None
|
|
40
45
|
|
|
41
46
|
|
|
47
|
+
def _normalize_cookbook_root(cookbook_path: Path | str) -> Path:
|
|
48
|
+
"""
|
|
49
|
+
Normalise cookbook paths.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
cookbook_path: User-provided cookbook path.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
A resolved Path.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ValueError: If the path cannot be normalised.
|
|
59
|
+
|
|
60
|
+
"""
|
|
61
|
+
# Normalise the path (resolves symlinks, expands ~, etc.)
|
|
62
|
+
# Safety for accessing files within this cookbook is enforced per-operation
|
|
63
|
+
# using _ensure_within_base_path with the cookbook dir as the base
|
|
64
|
+
return _normalize_path(cookbook_path)
|
|
65
|
+
|
|
66
|
+
|
|
42
67
|
# Optimised patterns to avoid catastrophic backtracking in resource parsing
|
|
43
68
|
RESOURCE_BLOCK_PATTERN = re.compile(r"\w{1,100}\s+['\"]([^'\"\r\n]{0,200})['\"]\s+do")
|
|
44
69
|
|
|
@@ -227,7 +252,6 @@ def _parse_and_assess_cookbooks(cookbook_paths: str) -> tuple[list, str | None]:
|
|
|
227
252
|
|
|
228
253
|
cookbook_assessments = []
|
|
229
254
|
for cookbook_path in valid_paths:
|
|
230
|
-
# deepcode ignore PT: path normalized via _normalize_path
|
|
231
255
|
assessment = _assess_single_cookbook(cookbook_path)
|
|
232
256
|
cookbook_assessments.append(assessment)
|
|
233
257
|
|
|
@@ -330,7 +354,8 @@ def analyse_cookbook_dependencies(
|
|
|
330
354
|
dependency_depth: Analysis depth (direct, transitive, full)
|
|
331
355
|
|
|
332
356
|
Returns:
|
|
333
|
-
Dependency analysis with migration order recommendations
|
|
357
|
+
Dependency analysis with migration order recommendations.
|
|
358
|
+
|
|
334
359
|
|
|
335
360
|
"""
|
|
336
361
|
try:
|
|
@@ -342,15 +367,21 @@ def analyse_cookbook_dependencies(
|
|
|
342
367
|
f"Suggestion: Use one of {', '.join(valid_depths)}"
|
|
343
368
|
)
|
|
344
369
|
|
|
345
|
-
|
|
346
|
-
|
|
370
|
+
# Validate and normalise user-provided path
|
|
371
|
+
# Containment is enforced at filesystem operation level
|
|
372
|
+
try:
|
|
373
|
+
normalized_input: Path = _normalize_path(cookbook_path)
|
|
374
|
+
except (ValueError, OSError) as e:
|
|
375
|
+
return f"Error: Invalid cookbook path '{cookbook_path}': {e}"
|
|
376
|
+
|
|
377
|
+
if not normalized_input.exists():
|
|
347
378
|
return (
|
|
348
379
|
f"Error: Cookbook path not found: {cookbook_path}\n\n"
|
|
349
380
|
"Suggestion: Check that the path exists and points to a cookbook directory"
|
|
350
381
|
)
|
|
351
382
|
|
|
352
|
-
# Analyze dependencies
|
|
353
|
-
dependency_analysis = _analyse_cookbook_dependencies_detailed(
|
|
383
|
+
# Analyze dependencies using normalized path
|
|
384
|
+
dependency_analysis = _analyse_cookbook_dependencies_detailed(normalized_input)
|
|
354
385
|
|
|
355
386
|
# Determine migration order
|
|
356
387
|
migration_order = _determine_migration_order(dependency_analysis)
|
|
@@ -359,7 +390,7 @@ def analyse_cookbook_dependencies(
|
|
|
359
390
|
circular_deps = _identify_circular_dependencies(dependency_analysis)
|
|
360
391
|
|
|
361
392
|
return f"""# Cookbook Dependency Analysis
|
|
362
|
-
# Cookbook: {
|
|
393
|
+
# Cookbook: {normalized_input.name}
|
|
363
394
|
# Analysis Depth: {dependency_depth}
|
|
364
395
|
|
|
365
396
|
## Dependency Overview:
|
|
@@ -559,7 +590,9 @@ def _parse_cookbook_paths(cookbook_paths: str) -> list[Any]:
|
|
|
559
590
|
List of valid Path objects (may be empty)
|
|
560
591
|
|
|
561
592
|
"""
|
|
562
|
-
paths = [
|
|
593
|
+
paths = [
|
|
594
|
+
_normalize_cookbook_root(path.strip()) for path in cookbook_paths.split(",")
|
|
595
|
+
]
|
|
563
596
|
valid_paths = [p for p in paths if p.exists()]
|
|
564
597
|
return valid_paths
|
|
565
598
|
|
|
@@ -587,7 +620,6 @@ def _analyse_cookbook_metrics(
|
|
|
587
620
|
}
|
|
588
621
|
|
|
589
622
|
for cookbook_path in valid_paths:
|
|
590
|
-
# deepcode ignore PT: path normalized via _normalize_path
|
|
591
623
|
assessment = _assess_single_cookbook(cookbook_path)
|
|
592
624
|
cookbook_assessments.append(assessment)
|
|
593
625
|
|
|
@@ -657,35 +689,70 @@ def _format_assessment_report(
|
|
|
657
689
|
"""
|
|
658
690
|
|
|
659
691
|
|
|
660
|
-
def _count_cookbook_artifacts(cookbook_path: Path) -> dict[str, int]:
|
|
692
|
+
def _count_cookbook_artifacts(cookbook_path: Path) -> dict[str, int]: # noqa: C901
|
|
661
693
|
"""Count comprehensive cookbook artifacts including all Chef components."""
|
|
662
|
-
#
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
694
|
+
# Note: cookbook_path is expected to be pre-validated
|
|
695
|
+
base = cookbook_path
|
|
696
|
+
|
|
697
|
+
# Helper function to safely glob within a directory
|
|
698
|
+
def _glob_safe(directory: Path, pattern: str) -> int:
|
|
699
|
+
"""Count files matching a glob pattern within a directory."""
|
|
700
|
+
if not directory.exists() or not directory.is_dir():
|
|
701
|
+
return 0
|
|
702
|
+
try:
|
|
703
|
+
return len(list(directory.glob(pattern)))
|
|
704
|
+
except (OSError, ValueError):
|
|
705
|
+
return 0
|
|
666
706
|
|
|
667
|
-
#
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
templates_dir = (
|
|
675
|
-
cookbook_path / "templates"
|
|
676
|
-
) # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected
|
|
677
|
-
template_count = (
|
|
678
|
-
len(list(templates_dir.glob("**/*.erb"))) if templates_dir.exists() else 0
|
|
679
|
-
)
|
|
707
|
+
# Helper function to check existence safely
|
|
708
|
+
def _exists_safe(path: Path) -> bool:
|
|
709
|
+
"""Check if a path exists."""
|
|
710
|
+
try:
|
|
711
|
+
return path.exists()
|
|
712
|
+
except (OSError, ValueError):
|
|
713
|
+
return False
|
|
680
714
|
|
|
681
|
-
|
|
682
|
-
|
|
715
|
+
# All paths are safe-joined to the validated base
|
|
716
|
+
recipes_dir: Path = _safe_join(base, "recipes")
|
|
717
|
+
recipe_count: int = _glob_safe(recipes_dir, "*.rb")
|
|
683
718
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
)
|
|
719
|
+
templates_dir: Path = _safe_join(base, "templates")
|
|
720
|
+
template_count: int = _glob_safe(templates_dir, "**/*.erb")
|
|
721
|
+
|
|
722
|
+
files_dir: Path = _safe_join(base, "files")
|
|
723
|
+
file_count: int = _glob_safe(files_dir, "**/*")
|
|
724
|
+
|
|
725
|
+
attributes_dir: Path = _safe_join(base, "attributes")
|
|
726
|
+
attributes_count: int = _glob_safe(attributes_dir, "*.rb")
|
|
727
|
+
|
|
728
|
+
libraries_dir: Path = _safe_join(base, "libraries")
|
|
729
|
+
libraries_count: int = _glob_safe(libraries_dir, "*.rb")
|
|
730
|
+
|
|
731
|
+
definitions_dir: Path = _safe_join(base, "definitions")
|
|
732
|
+
definitions_count: int = _glob_safe(definitions_dir, "*.rb")
|
|
733
|
+
|
|
734
|
+
resources_dir: Path = _safe_join(base, "resources")
|
|
735
|
+
resources_count: int = _glob_safe(resources_dir, "*.rb")
|
|
736
|
+
|
|
737
|
+
providers_dir: Path = _safe_join(base, "providers")
|
|
738
|
+
providers_count: int = _glob_safe(providers_dir, "*.rb")
|
|
739
|
+
|
|
740
|
+
berksfile: Path = _safe_join(base, "Berksfile")
|
|
741
|
+
has_berksfile: bool = _exists_safe(berksfile)
|
|
742
|
+
|
|
743
|
+
chefignore: Path = _safe_join(base, "chefignore")
|
|
744
|
+
has_chefignore: bool = _exists_safe(chefignore)
|
|
745
|
+
|
|
746
|
+
thorfile: Path = _safe_join(base, "Thorfile")
|
|
747
|
+
has_thorfile: bool = _exists_safe(thorfile)
|
|
748
|
+
|
|
749
|
+
kitchen_yml: Path = _safe_join(base, ".kitchen.yml")
|
|
750
|
+
kitchen_yaml: Path = _safe_join(base, "kitchen.yml")
|
|
751
|
+
has_kitchen_yml: bool = _exists_safe(kitchen_yml) or _exists_safe(kitchen_yaml)
|
|
752
|
+
|
|
753
|
+
test_dir: Path = _safe_join(base, "test")
|
|
754
|
+
spec_dir: Path = _safe_join(base, "spec")
|
|
755
|
+
has_test_dir: bool = _exists_safe(test_dir) or _exists_safe(spec_dir)
|
|
689
756
|
|
|
690
757
|
libraries_dir = cookbook_path / "libraries"
|
|
691
758
|
libraries_count = (
|
|
@@ -737,10 +804,8 @@ def _count_cookbook_artifacts(cookbook_path: Path) -> dict[str, int]:
|
|
|
737
804
|
|
|
738
805
|
def _analyse_recipe_complexity(cookbook_path: Path) -> dict[str, int]:
|
|
739
806
|
"""Analyse recipe files and other cookbook components for resource counts, Ruby blocks, and custom resources."""
|
|
740
|
-
#
|
|
741
|
-
|
|
742
|
-
Path(cookbook_path) if not isinstance(cookbook_path, Path) else cookbook_path
|
|
743
|
-
)
|
|
807
|
+
# Note: cookbook_path is expected to be pre-validated at function entry points
|
|
808
|
+
# Do not call _normalize_cookbook_root here as it's already a validated Path
|
|
744
809
|
|
|
745
810
|
resource_count = 0
|
|
746
811
|
custom_resources = 0
|
|
@@ -774,56 +839,75 @@ def _analyze_recipes(cookbook_path: Path) -> tuple[int, int, int]:
|
|
|
774
839
|
ruby_blocks = 0
|
|
775
840
|
custom_resources = 0
|
|
776
841
|
|
|
777
|
-
# cookbook_path
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
842
|
+
# Note: cookbook_path is expected to be pre-validated
|
|
843
|
+
# Use it directly with _safe_join to access recipes directory
|
|
844
|
+
recipes_dir: Path = _safe_join(cookbook_path, "recipes")
|
|
845
|
+
try:
|
|
846
|
+
recipe_files: list[Path] = (
|
|
847
|
+
list(recipes_dir.glob("*.rb")) if recipes_dir.exists() else []
|
|
848
|
+
)
|
|
849
|
+
except (OSError, ValueError):
|
|
850
|
+
recipe_files = []
|
|
851
|
+
|
|
852
|
+
for recipe_file in recipe_files:
|
|
853
|
+
try:
|
|
854
|
+
# Validate each glob result
|
|
855
|
+
validated_file: Path = _validated_candidate(recipe_file, cookbook_path)
|
|
856
|
+
except ValueError:
|
|
857
|
+
continue
|
|
858
|
+
try:
|
|
859
|
+
content = validated_file.read_text(encoding="utf-8", errors="ignore")
|
|
860
|
+
resources = len(RESOURCE_BLOCK_PATTERN.findall(content))
|
|
861
|
+
ruby_blocks += len(
|
|
862
|
+
re.findall(r"ruby_block|execute|bash|script", content, re.IGNORECASE)
|
|
863
|
+
)
|
|
864
|
+
custom_resources += len(
|
|
865
|
+
re.findall(
|
|
866
|
+
r"custom_resource|provides|use_inline_resources|lwrp_resource",
|
|
867
|
+
content,
|
|
797
868
|
)
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
869
|
+
)
|
|
870
|
+
resource_count += resources
|
|
871
|
+
except Exception:
|
|
872
|
+
continue
|
|
801
873
|
|
|
802
874
|
return resource_count, ruby_blocks, custom_resources
|
|
803
875
|
|
|
804
876
|
|
|
805
877
|
def _analyze_attributes(cookbook_path: Path) -> int:
|
|
806
|
-
"""
|
|
878
|
+
"""Analyse attribute files for complexity."""
|
|
807
879
|
attribute_complexity = 0
|
|
808
880
|
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
881
|
+
# Note: cookbook_path is expected to be pre-validated
|
|
882
|
+
attributes_dir: Path = _safe_join(cookbook_path, "attributes")
|
|
883
|
+
try:
|
|
884
|
+
attr_files: list[Path] = (
|
|
885
|
+
list(attributes_dir.glob("*.rb")) if attributes_dir.exists() else []
|
|
886
|
+
)
|
|
887
|
+
except (OSError, ValueError):
|
|
888
|
+
attr_files = []
|
|
889
|
+
|
|
890
|
+
for attr_file in attr_files:
|
|
891
|
+
try:
|
|
892
|
+
# Validate each glob result
|
|
893
|
+
validated_file: Path = _validated_candidate(attr_file, cookbook_path)
|
|
894
|
+
except ValueError:
|
|
895
|
+
continue
|
|
896
|
+
try:
|
|
897
|
+
content = validated_file.read_text(encoding="utf-8", errors="ignore")
|
|
898
|
+
assignments = len(
|
|
899
|
+
re.findall(
|
|
900
|
+
r"^[ \t]{0,20}\w+[ \t]{0,10}(?:\[\w*\])?[ \t]{0,10}=",
|
|
901
|
+
content,
|
|
902
|
+
re.MULTILINE,
|
|
823
903
|
)
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
904
|
+
)
|
|
905
|
+
complex_expressions = len(
|
|
906
|
+
re.findall(r"(?:node|default|override)\[", content)
|
|
907
|
+
)
|
|
908
|
+
attribute_complexity += assignments + complex_expressions
|
|
909
|
+
except Exception:
|
|
910
|
+
continue
|
|
827
911
|
|
|
828
912
|
return attribute_complexity
|
|
829
913
|
|
|
@@ -832,77 +916,91 @@ def _analyze_templates(cookbook_path: Path) -> int:
|
|
|
832
916
|
"""Analyze template files for ERB complexity."""
|
|
833
917
|
erb_templates = 0
|
|
834
918
|
|
|
835
|
-
# cookbook_path
|
|
836
|
-
templates_dir = (
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
919
|
+
# Note: cookbook_path is expected to be pre-validated
|
|
920
|
+
templates_dir: Path = _safe_join(cookbook_path, "templates")
|
|
921
|
+
try:
|
|
922
|
+
template_files: list[Path] = (
|
|
923
|
+
list(templates_dir.glob("**/*.erb")) if templates_dir.exists() else []
|
|
924
|
+
)
|
|
925
|
+
except (OSError, ValueError):
|
|
926
|
+
template_files = []
|
|
927
|
+
|
|
928
|
+
for template_file in template_files:
|
|
929
|
+
try:
|
|
930
|
+
# Validate each glob result
|
|
931
|
+
validated_file: Path = _validated_candidate(template_file, cookbook_path)
|
|
932
|
+
except ValueError:
|
|
933
|
+
continue
|
|
934
|
+
|
|
935
|
+
try:
|
|
936
|
+
content = validated_file.read_text(encoding="utf-8", errors="ignore")
|
|
937
|
+
erb_expressions = len(re.findall(r"<%.*?%>", content))
|
|
938
|
+
erb_templates += erb_expressions
|
|
939
|
+
except Exception:
|
|
940
|
+
continue
|
|
848
941
|
|
|
849
942
|
return erb_templates
|
|
850
943
|
|
|
851
944
|
|
|
852
945
|
def _analyze_libraries(cookbook_path: Path) -> int:
|
|
853
|
-
"""
|
|
946
|
+
"""Analyse library files for complexity."""
|
|
854
947
|
library_complexity = 0
|
|
855
948
|
|
|
856
|
-
# cookbook_path
|
|
857
|
-
libraries_dir = (
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
949
|
+
# Note: cookbook_path is expected to be pre-validated
|
|
950
|
+
libraries_dir: Path = _safe_join(cookbook_path, "libraries")
|
|
951
|
+
try:
|
|
952
|
+
lib_files: list[Path] = (
|
|
953
|
+
safe_glob(libraries_dir, "*.rb", cookbook_path)
|
|
954
|
+
if libraries_dir.exists()
|
|
955
|
+
else []
|
|
956
|
+
)
|
|
957
|
+
except (OSError, ValueError):
|
|
958
|
+
lib_files = []
|
|
959
|
+
|
|
960
|
+
for lib_file in lib_files:
|
|
961
|
+
try:
|
|
962
|
+
# lib_file is already validated by safe_glob
|
|
963
|
+
content = lib_file.read_text(encoding="utf-8", errors="ignore")
|
|
964
|
+
classes = len(re.findall(r"class\s+\w+", content))
|
|
965
|
+
methods = len(re.findall(r"def\s+\w+", content))
|
|
966
|
+
library_complexity += classes * 2 + methods
|
|
967
|
+
except Exception:
|
|
968
|
+
continue
|
|
870
969
|
|
|
871
970
|
return library_complexity
|
|
872
971
|
|
|
873
972
|
|
|
874
973
|
def _count_definitions(cookbook_path: Path) -> int:
|
|
875
974
|
"""Count definition files."""
|
|
876
|
-
# cookbook_path
|
|
877
|
-
definitions_dir = (
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
975
|
+
# Note: cookbook_path is expected to be pre-validated
|
|
976
|
+
definitions_dir: Path = _safe_join(cookbook_path, "definitions")
|
|
977
|
+
try:
|
|
978
|
+
def_files: list[Path] = (
|
|
979
|
+
safe_glob(definitions_dir, "*.rb", cookbook_path)
|
|
980
|
+
if definitions_dir.exists()
|
|
981
|
+
else []
|
|
982
|
+
)
|
|
983
|
+
except (OSError, ValueError):
|
|
984
|
+
def_files = []
|
|
985
|
+
return len(def_files)
|
|
883
986
|
|
|
884
987
|
|
|
885
988
|
def _parse_berksfile(cookbook_path: Path) -> dict[str, Any]:
|
|
886
989
|
"""Parse Berksfile for dependency information."""
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
Path(cookbook_path) if not isinstance(cookbook_path, Path) else cookbook_path
|
|
890
|
-
)
|
|
891
|
-
berksfile = cookbook_path / "Berksfile"
|
|
990
|
+
base = _normalize_cookbook_root(cookbook_path)
|
|
991
|
+
berksfile_path = _safe_join(base, "Berksfile")
|
|
892
992
|
|
|
893
|
-
if not
|
|
993
|
+
if not berksfile_path.exists():
|
|
894
994
|
return {"dependencies": [], "external_cookbooks": [], "complexity": 0}
|
|
895
995
|
|
|
896
996
|
try:
|
|
897
|
-
content =
|
|
997
|
+
content = berksfile_path.read_text(encoding="utf-8", errors="ignore")
|
|
898
998
|
|
|
899
|
-
# Parse cookbook dependencies
|
|
900
999
|
cookbook_deps = re.findall(r'cookbook\s+[\'"]([^\'"]+)[\'"]', content)
|
|
901
1000
|
external_deps = re.findall(
|
|
902
1001
|
r'cookbook\s+[\'"]([^\'"]+)[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]', content
|
|
903
1002
|
)
|
|
904
1003
|
|
|
905
|
-
# Count complex dependency specifications (with version constraints, git sources, etc.)
|
|
906
1004
|
complex_deps = len(re.findall(r'cookbook\s+[\'"]([^\'"]+)[\'"]\s*,', content))
|
|
907
1005
|
git_sources = len(re.findall(r"git:", content))
|
|
908
1006
|
path_sources = len(re.findall(r"path:", content))
|
|
@@ -920,21 +1018,20 @@ def _parse_berksfile(cookbook_path: Path) -> dict[str, Any]:
|
|
|
920
1018
|
|
|
921
1019
|
def _parse_chefignore(cookbook_path) -> dict[str, Any]:
|
|
922
1020
|
"""Parse chefignore file for ignore patterns."""
|
|
923
|
-
|
|
924
|
-
|
|
1021
|
+
base = _normalize_cookbook_root(cookbook_path)
|
|
1022
|
+
chefignore_path = _ensure_within_base_path(_safe_join(base, "chefignore"), base)
|
|
925
1023
|
|
|
926
|
-
if not
|
|
1024
|
+
if not chefignore_path.exists():
|
|
927
1025
|
return {"patterns": [], "complexity": 0}
|
|
928
1026
|
|
|
929
1027
|
try:
|
|
930
|
-
content =
|
|
1028
|
+
content = chefignore_path.read_text(encoding="utf-8", errors="ignore")
|
|
931
1029
|
lines = [
|
|
932
1030
|
line.strip()
|
|
933
1031
|
for line in content.split("\n")
|
|
934
1032
|
if line.strip() and not line.startswith("#")
|
|
935
1033
|
]
|
|
936
1034
|
|
|
937
|
-
# Count complex patterns (wildcards, directories, etc.)
|
|
938
1035
|
wildcard_patterns = len([p for p in lines if "*" in p or "?" in p])
|
|
939
1036
|
directory_patterns = len([p for p in lines if p.endswith("/") or "/" in p])
|
|
940
1037
|
|
|
@@ -950,16 +1047,15 @@ def _parse_chefignore(cookbook_path) -> dict[str, Any]:
|
|
|
950
1047
|
|
|
951
1048
|
def _parse_thorfile(cookbook_path) -> dict[str, Any]:
|
|
952
1049
|
"""Parse Thorfile for Thor tasks."""
|
|
953
|
-
|
|
954
|
-
|
|
1050
|
+
base = _normalize_cookbook_root(cookbook_path)
|
|
1051
|
+
thorfile_path = _ensure_within_base_path(_safe_join(base, "Thorfile"), base)
|
|
955
1052
|
|
|
956
|
-
if not
|
|
1053
|
+
if not thorfile_path.exists():
|
|
957
1054
|
return {"tasks": [], "complexity": 0}
|
|
958
1055
|
|
|
959
1056
|
try:
|
|
960
|
-
content =
|
|
1057
|
+
content = thorfile_path.read_text(encoding="utf-8", errors="ignore")
|
|
961
1058
|
|
|
962
|
-
# Count Thor tasks and methods
|
|
963
1059
|
tasks = len(re.findall(r'desc\s+[\'"]([^\'"]+)[\'"]', content))
|
|
964
1060
|
methods = len(re.findall(r"def\s+\w+", content))
|
|
965
1061
|
|
|
@@ -975,10 +1071,10 @@ def _parse_thorfile(cookbook_path) -> dict[str, Any]:
|
|
|
975
1071
|
|
|
976
1072
|
def _parse_metadata_file(cookbook_path) -> dict[str, Any]:
|
|
977
1073
|
"""Parse metadata.rb for cookbook information."""
|
|
978
|
-
|
|
979
|
-
|
|
1074
|
+
base = _normalize_cookbook_root(cookbook_path)
|
|
1075
|
+
metadata_path = _ensure_within_base_path(_safe_join(base, "metadata.rb"), base)
|
|
980
1076
|
|
|
981
|
-
if not
|
|
1077
|
+
if not metadata_path.exists():
|
|
982
1078
|
return {
|
|
983
1079
|
"name": "",
|
|
984
1080
|
"version": "",
|
|
@@ -988,9 +1084,8 @@ def _parse_metadata_file(cookbook_path) -> dict[str, Any]:
|
|
|
988
1084
|
}
|
|
989
1085
|
|
|
990
1086
|
try:
|
|
991
|
-
content =
|
|
1087
|
+
content = metadata_path.read_text(encoding="utf-8", errors="ignore")
|
|
992
1088
|
|
|
993
|
-
# Extract basic metadata
|
|
994
1089
|
name_match = re.search(r'name\s+[\'"]([^\'"]+)[\'"]', content)
|
|
995
1090
|
version_match = re.search(r'version\s+[\'"]([^\'"]+)[\'"]', content)
|
|
996
1091
|
|
|
@@ -1074,8 +1169,7 @@ def _determine_migration_priority(complexity_score: int) -> str:
|
|
|
1074
1169
|
|
|
1075
1170
|
def _assess_single_cookbook(cookbook_path: Path) -> dict:
|
|
1076
1171
|
"""Assess complexity of a single cookbook."""
|
|
1077
|
-
|
|
1078
|
-
cookbook = cookbook_path
|
|
1172
|
+
cookbook = _normalize_cookbook_root(cookbook_path)
|
|
1079
1173
|
|
|
1080
1174
|
# Collect metrics
|
|
1081
1175
|
artifact_counts = _count_cookbook_artifacts(cookbook)
|
|
@@ -1373,38 +1467,92 @@ def _estimate_resource_requirements(metrics: dict, target_platform: str) -> str:
|
|
|
1373
1467
|
• **Training:** 2-3 days Ansible/AWX training for team"""
|
|
1374
1468
|
|
|
1375
1469
|
|
|
1376
|
-
def _analyse_cookbook_dependencies_detailed(cookbook_path) -> dict:
|
|
1377
|
-
"""
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1470
|
+
def _analyse_cookbook_dependencies_detailed(cookbook_path: Path | str) -> dict:
|
|
1471
|
+
"""
|
|
1472
|
+
Analyse cookbook dependencies in detail.
|
|
1473
|
+
|
|
1474
|
+
Args:
|
|
1475
|
+
cookbook_path: Path to the cookbook (may be string or Path).
|
|
1476
|
+
|
|
1477
|
+
Returns:
|
|
1478
|
+
Dictionary with dependency information.
|
|
1479
|
+
|
|
1480
|
+
Raises:
|
|
1481
|
+
ValueError: If the path is invalid.
|
|
1482
|
+
|
|
1483
|
+
"""
|
|
1484
|
+
# Normalize the input path
|
|
1485
|
+
base_path: Path = _normalize_path(cookbook_path)
|
|
1486
|
+
|
|
1487
|
+
# Validate basic accessibility
|
|
1488
|
+
if not base_path.exists():
|
|
1489
|
+
msg = f"Cookbook path does not exist: {cookbook_path}"
|
|
1490
|
+
raise ValueError(msg)
|
|
1491
|
+
if not base_path.is_dir():
|
|
1492
|
+
msg = f"Cookbook path is not a directory: {cookbook_path}"
|
|
1493
|
+
raise ValueError(msg)
|
|
1494
|
+
|
|
1495
|
+
# Collect dependencies from metadata and Berksfile
|
|
1496
|
+
direct_dependencies = _collect_metadata_dependencies(base_path)
|
|
1497
|
+
external_dependencies = _collect_berks_dependencies(base_path)
|
|
1498
|
+
community_cookbooks = _identify_community_cookbooks_from_list(
|
|
1499
|
+
direct_dependencies + external_dependencies
|
|
1500
|
+
)
|
|
1501
|
+
|
|
1502
|
+
return {
|
|
1503
|
+
"cookbook_name": base_path.name,
|
|
1504
|
+
"direct_dependencies": direct_dependencies,
|
|
1381
1505
|
"transitive_dependencies": [],
|
|
1382
|
-
"external_dependencies":
|
|
1383
|
-
"community_cookbooks":
|
|
1506
|
+
"external_dependencies": external_dependencies,
|
|
1507
|
+
"community_cookbooks": community_cookbooks,
|
|
1384
1508
|
"circular_dependencies": [],
|
|
1385
1509
|
}
|
|
1386
1510
|
|
|
1387
|
-
# Read metadata.rb for dependencies
|
|
1388
|
-
metadata_file = _safe_join(cookbook_path, METADATA_FILENAME)
|
|
1389
|
-
if metadata_file.exists():
|
|
1390
|
-
with metadata_file.open("r", encoding="utf-8", errors="ignore") as f:
|
|
1391
|
-
content = f.read()
|
|
1392
1511
|
|
|
1393
|
-
|
|
1512
|
+
def _collect_metadata_dependencies(base_path: Path) -> list[str]:
|
|
1513
|
+
"""Collect dependency declarations from metadata.rb with containment checks."""
|
|
1514
|
+
# Build metadata path safely within the cookbook
|
|
1515
|
+
metadata_path: Path = _safe_join(base_path, METADATA_FILENAME)
|
|
1394
1516
|
|
|
1395
|
-
|
|
1396
|
-
|
|
1517
|
+
if not metadata_path.is_file():
|
|
1518
|
+
return []
|
|
1519
|
+
|
|
1520
|
+
try:
|
|
1521
|
+
# Validate metadata_path is within base_path
|
|
1522
|
+
_validated_candidate(metadata_path, base_path)
|
|
1523
|
+
except ValueError:
|
|
1524
|
+
# metadata.rb is outside cookbook root
|
|
1525
|
+
return []
|
|
1397
1526
|
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
if berksfile.exists():
|
|
1401
|
-
with berksfile.open("r", encoding="utf-8", errors="ignore") as f:
|
|
1402
|
-
content = f.read()
|
|
1527
|
+
with metadata_path.open(encoding="utf-8", errors="ignore") as f:
|
|
1528
|
+
content = f.read()
|
|
1403
1529
|
|
|
1404
|
-
|
|
1405
|
-
analysis["external_dependencies"].extend(cookbook_matches)
|
|
1530
|
+
return re.findall(r'depends\s+[\'"]([^\'"]+)[\'"]', content)
|
|
1406
1531
|
|
|
1407
|
-
|
|
1532
|
+
|
|
1533
|
+
def _collect_berks_dependencies(base_path: Path) -> list[str]:
|
|
1534
|
+
"""Collect dependency declarations from Berksfile with containment checks."""
|
|
1535
|
+
# Build Berksfile path safely within the cookbook
|
|
1536
|
+
berksfile_path: Path = _safe_join(base_path, "Berksfile")
|
|
1537
|
+
|
|
1538
|
+
if not berksfile_path.is_file():
|
|
1539
|
+
return []
|
|
1540
|
+
|
|
1541
|
+
try:
|
|
1542
|
+
# Validate berksfile_path is within base_path
|
|
1543
|
+
_validated_candidate(berksfile_path, base_path)
|
|
1544
|
+
except ValueError:
|
|
1545
|
+
# Berksfile is outside cookbook root
|
|
1546
|
+
return []
|
|
1547
|
+
|
|
1548
|
+
with berksfile_path.open(encoding="utf-8", errors="ignore") as f:
|
|
1549
|
+
content = f.read()
|
|
1550
|
+
|
|
1551
|
+
return re.findall(r'cookbook\s+[\'"]([^\'"]+)[\'"]', content)
|
|
1552
|
+
|
|
1553
|
+
|
|
1554
|
+
def _identify_community_cookbooks_from_list(dependencies: list[str]) -> list[str]:
|
|
1555
|
+
"""Return dependencies considered community cookbooks based on patterns."""
|
|
1408
1556
|
community_cookbook_patterns = [
|
|
1409
1557
|
"apache2",
|
|
1410
1558
|
"nginx",
|
|
@@ -1421,12 +1569,11 @@ def _analyse_cookbook_dependencies_detailed(cookbook_path) -> dict:
|
|
|
1421
1569
|
"users",
|
|
1422
1570
|
]
|
|
1423
1571
|
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
return analysis
|
|
1572
|
+
return [
|
|
1573
|
+
dep
|
|
1574
|
+
for dep in dependencies
|
|
1575
|
+
if any(pattern in dep.lower() for pattern in community_cookbook_patterns)
|
|
1576
|
+
]
|
|
1430
1577
|
|
|
1431
1578
|
|
|
1432
1579
|
def _determine_migration_order(dependency_analysis: dict) -> list:
|
|
@@ -1933,7 +2080,7 @@ def assess_single_cookbook_with_ai(
|
|
|
1933
2080
|
"""
|
|
1934
2081
|
try:
|
|
1935
2082
|
cookbook_path_obj = _normalize_path(cookbook_path)
|
|
1936
|
-
if not cookbook_path_obj.exists():
|
|
2083
|
+
if not cookbook_path_obj.exists(): # Read-only check on normalized path
|
|
1937
2084
|
return {"error": f"Cookbook path not found: {cookbook_path}"}
|
|
1938
2085
|
|
|
1939
2086
|
# Check if AI is available
|
|
@@ -2203,8 +2350,7 @@ def _assess_single_cookbook_with_ai(
|
|
|
2203
2350
|
base_url: str = "",
|
|
2204
2351
|
) -> dict:
|
|
2205
2352
|
"""Assess complexity of a single cookbook using AI analysis."""
|
|
2206
|
-
|
|
2207
|
-
cookbook = cookbook_path
|
|
2353
|
+
cookbook = _normalize_cookbook_root(cookbook_path)
|
|
2208
2354
|
|
|
2209
2355
|
# Collect basic metrics (same as rule-based)
|
|
2210
2356
|
artifact_counts = _count_cookbook_artifacts(cookbook)
|
|
@@ -2331,10 +2477,15 @@ Provide your analysis in JSON format with keys: complexity_score, estimated_effo
|
|
|
2331
2477
|
|
|
2332
2478
|
def _get_recipe_content_sample(cookbook_path: Path) -> str:
|
|
2333
2479
|
"""Get a sample of ALL recipe content for AI analysis."""
|
|
2334
|
-
|
|
2335
|
-
|
|
2480
|
+
# Inline guard directly adjacent to sink
|
|
2481
|
+
base = os.path.realpath(str(cookbook_path)) # noqa: PTH111
|
|
2482
|
+
recipes_dir_str = os.path.realpath(os.path.join(base, "recipes")) # noqa: PTH111, PTH118
|
|
2483
|
+
if os.path.commonpath([base, recipes_dir_str]) != base:
|
|
2484
|
+
raise RuntimeError("Path traversal")
|
|
2485
|
+
if not os.path.exists(recipes_dir_str): # noqa: PTH110
|
|
2336
2486
|
return "No recipes directory found"
|
|
2337
2487
|
|
|
2488
|
+
recipes_dir = Path(recipes_dir_str)
|
|
2338
2489
|
recipe_files = list(recipes_dir.glob("*.rb"))
|
|
2339
2490
|
if not recipe_files:
|
|
2340
2491
|
return "No recipe files found"
|
|
@@ -2372,12 +2523,16 @@ def _get_recipe_content_sample(cookbook_path: Path) -> str:
|
|
|
2372
2523
|
|
|
2373
2524
|
def _get_metadata_content(cookbook_path: Path) -> str:
|
|
2374
2525
|
"""Get metadata content for AI analysis."""
|
|
2375
|
-
|
|
2376
|
-
|
|
2526
|
+
# Inline guard directly adjacent to sink
|
|
2527
|
+
base = os.path.realpath(str(cookbook_path)) # noqa: PTH111
|
|
2528
|
+
metadata_file_str = os.path.realpath(os.path.join(base, METADATA_FILENAME)) # noqa: PTH111, PTH118
|
|
2529
|
+
if os.path.commonpath([base, metadata_file_str]) != base:
|
|
2530
|
+
raise RuntimeError("Path traversal")
|
|
2531
|
+
if not os.path.exists(metadata_file_str): # noqa: PTH110
|
|
2377
2532
|
return "No metadata.rb found"
|
|
2378
2533
|
|
|
2379
2534
|
try:
|
|
2380
|
-
return
|
|
2535
|
+
return Path(metadata_file_str).read_text(encoding="utf-8", errors="ignore")
|
|
2381
2536
|
except Exception:
|
|
2382
2537
|
return "Could not read metadata"
|
|
2383
2538
|
|