reporails-cli 0.0.1__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.

Potentially problematic release.


This version of reporails-cli might be problematic. Click here for more details.

Files changed (58) hide show
  1. reporails_cli/.env.example +1 -0
  2. reporails_cli/__init__.py +24 -0
  3. reporails_cli/bundled/.semgrepignore +51 -0
  4. reporails_cli/bundled/__init__.py +31 -0
  5. reporails_cli/bundled/capability-patterns.yml +54 -0
  6. reporails_cli/bundled/levels.yml +99 -0
  7. reporails_cli/core/__init__.py +35 -0
  8. reporails_cli/core/agents.py +147 -0
  9. reporails_cli/core/applicability.py +150 -0
  10. reporails_cli/core/bootstrap.py +147 -0
  11. reporails_cli/core/cache.py +352 -0
  12. reporails_cli/core/capability.py +245 -0
  13. reporails_cli/core/discover.py +362 -0
  14. reporails_cli/core/engine.py +177 -0
  15. reporails_cli/core/init.py +309 -0
  16. reporails_cli/core/levels.py +177 -0
  17. reporails_cli/core/models.py +329 -0
  18. reporails_cli/core/opengrep/__init__.py +34 -0
  19. reporails_cli/core/opengrep/runner.py +203 -0
  20. reporails_cli/core/opengrep/semgrepignore.py +39 -0
  21. reporails_cli/core/opengrep/templates.py +138 -0
  22. reporails_cli/core/registry.py +155 -0
  23. reporails_cli/core/sarif.py +181 -0
  24. reporails_cli/core/scorer.py +178 -0
  25. reporails_cli/core/semantic.py +193 -0
  26. reporails_cli/core/utils.py +139 -0
  27. reporails_cli/formatters/__init__.py +19 -0
  28. reporails_cli/formatters/json.py +137 -0
  29. reporails_cli/formatters/mcp.py +68 -0
  30. reporails_cli/formatters/text/__init__.py +32 -0
  31. reporails_cli/formatters/text/box.py +89 -0
  32. reporails_cli/formatters/text/chars.py +42 -0
  33. reporails_cli/formatters/text/compact.py +119 -0
  34. reporails_cli/formatters/text/components.py +117 -0
  35. reporails_cli/formatters/text/full.py +135 -0
  36. reporails_cli/formatters/text/rules.py +50 -0
  37. reporails_cli/formatters/text/violations.py +92 -0
  38. reporails_cli/interfaces/__init__.py +1 -0
  39. reporails_cli/interfaces/cli/__init__.py +7 -0
  40. reporails_cli/interfaces/cli/main.py +352 -0
  41. reporails_cli/interfaces/mcp/__init__.py +5 -0
  42. reporails_cli/interfaces/mcp/server.py +194 -0
  43. reporails_cli/interfaces/mcp/tools.py +136 -0
  44. reporails_cli/py.typed +0 -0
  45. reporails_cli/templates/__init__.py +65 -0
  46. reporails_cli/templates/cli_box.txt +10 -0
  47. reporails_cli/templates/cli_cta.txt +4 -0
  48. reporails_cli/templates/cli_delta.txt +1 -0
  49. reporails_cli/templates/cli_file_header.txt +1 -0
  50. reporails_cli/templates/cli_legend.txt +1 -0
  51. reporails_cli/templates/cli_pending.txt +3 -0
  52. reporails_cli/templates/cli_violation.txt +1 -0
  53. reporails_cli/templates/cli_working.txt +2 -0
  54. reporails_cli-0.0.1.dist-info/METADATA +108 -0
  55. reporails_cli-0.0.1.dist-info/RECORD +58 -0
  56. reporails_cli-0.0.1.dist-info/WHEEL +4 -0
  57. reporails_cli-0.0.1.dist-info/entry_points.txt +3 -0
  58. reporails_cli-0.0.1.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,138 @@
1
+ """Template resolution for OpenGrep rule files.
2
+
3
+ Handles {{placeholder}} substitution in .yml rule configs.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from pathlib import Path
10
+
11
+ # Template placeholder pattern
12
+ TEMPLATE_PATTERN = re.compile(r"\{\{(\w+)\}\}")
13
+
14
+
15
+ def _glob_to_regex(glob_pattern: str, for_yaml: bool = True) -> str:
16
+ """Convert a glob pattern to a regex pattern.
17
+
18
+ Handles common glob syntax:
19
+ - ** -> .* (match any path)
20
+ - * -> [^/]* (match any chars except /)
21
+ - . -> \\. (escape literal dot)
22
+ - Other special regex chars are escaped
23
+
24
+ Args:
25
+ glob_pattern: Glob pattern like "**/CLAUDE.md"
26
+ for_yaml: If True, double-escape backslashes for YAML double-quoted strings
27
+
28
+ Returns:
29
+ Regex pattern like ".*CLAUDE\\.md"
30
+ """
31
+ # Remove leading **/ (matches any directory prefix)
32
+ pattern = glob_pattern
33
+ if pattern.startswith("**/"):
34
+ pattern = pattern[3:]
35
+
36
+ # Escape regex special chars except * and ?
37
+ result = ""
38
+ i = 0
39
+ while i < len(pattern):
40
+ c = pattern[i]
41
+ if c == "*":
42
+ if i + 1 < len(pattern) and pattern[i + 1] == "*":
43
+ # ** matches anything including /
44
+ result += ".*"
45
+ i += 2
46
+ # Skip trailing / after **
47
+ if i < len(pattern) and pattern[i] == "/":
48
+ i += 1
49
+ else:
50
+ # * matches anything except /
51
+ result += "[^/]*"
52
+ i += 1
53
+ elif c == "?":
54
+ result += "."
55
+ i += 1
56
+ elif c in ".^$+{}[]|()":
57
+ # Escape for regex, double-escape for YAML if needed
58
+ escape = "\\\\" if for_yaml else "\\"
59
+ result += escape + c
60
+ i += 1
61
+ else:
62
+ result += c
63
+ i += 1
64
+
65
+ return result
66
+
67
+
68
+ def has_templates(yml_path: Path) -> bool:
69
+ """Check if yml file contains template placeholders.
70
+
71
+ Args:
72
+ yml_path: Path to yml file
73
+
74
+ Returns:
75
+ True if templates found
76
+ """
77
+ try:
78
+ content = yml_path.read_text(encoding="utf-8")
79
+ return bool(TEMPLATE_PATTERN.search(content))
80
+ except OSError:
81
+ return False
82
+
83
+
84
+ def resolve_templates(yml_path: Path, context: dict[str, str | list[str]]) -> str:
85
+ """Resolve template placeholders in yml content.
86
+
87
+ Replaces {{placeholder}} with values from context.
88
+ Context-aware resolution:
89
+ - In array context (paths.include), expands list to multiple items
90
+ - In pattern-regex context, converts globs to regex and joins with |
91
+ - For string values, does simple substitution
92
+
93
+ Args:
94
+ yml_path: Path to yml file
95
+ context: Dict mapping placeholder names to string or list values
96
+
97
+ Returns:
98
+ Resolved yml content
99
+ """
100
+ content = yml_path.read_text(encoding="utf-8")
101
+
102
+ for key, value in context.items():
103
+ placeholder = "{{" + key + "}}"
104
+ if placeholder not in content:
105
+ continue
106
+
107
+ if isinstance(value, list):
108
+ # Find the line with the placeholder and its indentation
109
+ lines = content.split("\n")
110
+ new_lines = []
111
+ for line in lines:
112
+ if placeholder in line:
113
+ stripped = line.lstrip()
114
+ indent = len(line) - len(stripped)
115
+ indent_str = " " * indent
116
+
117
+ # Check context: array (starts with -) or pattern-regex
118
+ if stripped.startswith("- "):
119
+ # Array context: expand to multiple list items
120
+ for item in value:
121
+ new_lines.append(f'{indent_str}- "{item}"')
122
+ elif "pattern-regex:" in line or "pattern-not-regex:" in line:
123
+ # Regex context: convert globs to regex, join with |
124
+ regex_patterns = [_glob_to_regex(g) for g in value]
125
+ combined = "(" + "|".join(regex_patterns) + ")"
126
+ new_lines.append(line.replace(placeholder, combined))
127
+ else:
128
+ # Other scalar context: use first item
129
+ first_item = value[0] if value else ""
130
+ new_lines.append(line.replace(placeholder, first_item))
131
+ else:
132
+ new_lines.append(line)
133
+ content = "\n".join(new_lines)
134
+ else:
135
+ # Simple string substitution
136
+ content = content.replace(placeholder, str(value))
137
+
138
+ return content
@@ -0,0 +1,155 @@
1
+ """Rule loading from markdown frontmatter. Pure functions where possible."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from reporails_cli.core.bootstrap import get_rules_path
9
+ from reporails_cli.core.models import Category, Check, Rule, RuleType, Severity
10
+ from reporails_cli.core.utils import parse_frontmatter
11
+
12
+
13
+ def get_rules_dir() -> Path:
14
+ """Get rules directory (~/.reporails/rules/).
15
+
16
+ Returns:
17
+ Path to rules directory
18
+ """
19
+ return get_rules_path()
20
+
21
+
22
+ def load_rules(rules_dir: Path | None = None) -> dict[str, Rule]:
23
+ """Load all rules from rules directory.
24
+
25
+ Scans rules/**/*.md, parses frontmatter, links to .yml files.
26
+
27
+ Args:
28
+ rules_dir: Path to rules directory (default: ~/.reporails/rules/)
29
+
30
+ Returns:
31
+ Dict mapping rule ID to Rule object
32
+ """
33
+ if rules_dir is None:
34
+ rules_dir = get_rules_dir()
35
+
36
+ if not rules_dir.exists():
37
+ return {}
38
+
39
+ rules: dict[str, Rule] = {}
40
+
41
+ for md_path in rules_dir.rglob("*.md"):
42
+ try:
43
+ content = md_path.read_text(encoding="utf-8")
44
+ frontmatter = parse_frontmatter(content)
45
+
46
+ if not frontmatter:
47
+ continue
48
+
49
+ # Look for corresponding .yml file
50
+ yml_path_candidate = md_path.with_suffix(".yml")
51
+ yml_path: Path | None = yml_path_candidate if yml_path_candidate.exists() else None
52
+
53
+ rule = build_rule(frontmatter, md_path, yml_path)
54
+ rules[rule.id] = rule
55
+
56
+ except (ValueError, KeyError):
57
+ # Skip files without valid frontmatter
58
+ continue
59
+
60
+ return rules
61
+
62
+
63
+ def build_rule(frontmatter: dict[str, Any], md_path: Path, yml_path: Path | None) -> Rule:
64
+ """Build Rule object from parsed frontmatter.
65
+
66
+ Pure function — validates and constructs.
67
+
68
+ Args:
69
+ frontmatter: Parsed YAML dict
70
+ md_path: Path to source .md file
71
+ yml_path: Path to .yml file (optional)
72
+
73
+ Returns:
74
+ Rule object
75
+
76
+ Raises:
77
+ KeyError: If required fields missing
78
+ ValueError: If field values invalid
79
+ """
80
+ # Parse checks (formerly antipatterns)
81
+ checks = []
82
+ # Support both "checks" and legacy "antipatterns" field names
83
+ check_data = frontmatter.get("checks", frontmatter.get("antipatterns", []))
84
+ for item in check_data:
85
+ check = Check(
86
+ id=item.get("id", ""),
87
+ name=item.get("name", ""),
88
+ severity=Severity(item.get("severity", "medium")),
89
+ )
90
+ checks.append(check)
91
+
92
+ return Rule(
93
+ id=frontmatter["id"],
94
+ title=frontmatter["title"],
95
+ category=Category(frontmatter["category"]),
96
+ type=RuleType(frontmatter["type"]),
97
+ level=frontmatter.get("level", "L2"), # Default to L2 (Basic) if not specified
98
+ checks=checks,
99
+ detection=frontmatter.get("detection"),
100
+ sources=frontmatter.get("sources", []),
101
+ see_also=frontmatter.get("see_also", []),
102
+ scoring=frontmatter.get("scoring", 0),
103
+ validation=frontmatter.get("validation"),
104
+ question=frontmatter.get("question"),
105
+ criteria=frontmatter.get("criteria"),
106
+ choices=frontmatter.get("choices"),
107
+ pass_value=frontmatter.get("pass_value"),
108
+ examples=frontmatter.get("examples"),
109
+ md_path=md_path,
110
+ yml_path=yml_path,
111
+ )
112
+
113
+
114
+ def get_rules_by_type(rules: dict[str, Rule], rule_type: RuleType) -> dict[str, Rule]:
115
+ """Filter rules by type.
116
+
117
+ Pure function.
118
+
119
+ Args:
120
+ rules: Dict of rules
121
+ rule_type: Type to filter by
122
+
123
+ Returns:
124
+ Filtered dict of rules
125
+ """
126
+ return {k: v for k, v in rules.items() if v.type == rule_type}
127
+
128
+
129
+ def get_rules_by_category(rules: dict[str, Rule], category: Category) -> dict[str, Rule]:
130
+ """Filter rules by category.
131
+
132
+ Pure function.
133
+
134
+ Args:
135
+ rules: Dict of rules
136
+ category: Category to filter by
137
+
138
+ Returns:
139
+ Filtered dict of rules
140
+ """
141
+ return {k: v for k, v in rules.items() if v.category == category}
142
+
143
+
144
+ def get_rule_yml_paths(rules: dict[str, Rule]) -> list[Path]:
145
+ """Get list of .yml paths for rules that have them.
146
+
147
+ Pure function.
148
+
149
+ Args:
150
+ rules: Dict of rules
151
+
152
+ Returns:
153
+ List of paths to .yml files
154
+ """
155
+ return [r.yml_path for r in rules.values() if r.yml_path is not None]
@@ -0,0 +1,181 @@
1
+ """SARIF parsing - converts OpenGrep output to domain objects.
2
+
3
+ All functions are pure (no I/O).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from typing import Any
10
+
11
+ from reporails_cli.core.models import Rule, Severity, Violation
12
+
13
+ # Severity weights for scoring
14
+ SEVERITY_WEIGHTS: dict[Severity, float] = {
15
+ Severity.CRITICAL: 5.5,
16
+ Severity.HIGH: 4.0,
17
+ Severity.MEDIUM: 2.5,
18
+ Severity.LOW: 1.0,
19
+ }
20
+
21
+
22
+ def extract_rule_id(sarif_rule_id: str) -> str:
23
+ """Extract short rule ID from OpenGrep SARIF ruleId.
24
+
25
+ OpenGrep formats rule IDs as: checks.{category}.{id}-{slug}
26
+ Example: checks.structure.S1-many-h2-headings -> S1
27
+
28
+ Args:
29
+ sarif_rule_id: Full ruleId from SARIF output
30
+
31
+ Returns:
32
+ Short rule ID (e.g., S1, C10, M7)
33
+ """
34
+ # Pattern: extract the ID part (letter + digits) from the last segment
35
+ match = re.search(r"\.([A-Z]\d+)-", sarif_rule_id)
36
+ if match:
37
+ return match.group(1)
38
+ return sarif_rule_id
39
+
40
+
41
+ def extract_check_slug(sarif_rule_id: str) -> str | None:
42
+ """Extract check slug from OpenGrep SARIF ruleId.
43
+
44
+ OpenGrep formats rule IDs as: checks.{category}.{id}-{slug}
45
+ Example: checks.structure.S1-many-sections -> many-sections
46
+
47
+ Args:
48
+ sarif_rule_id: Full ruleId from SARIF output
49
+
50
+ Returns:
51
+ Check slug (e.g., "many-sections") or None
52
+ """
53
+ match = re.search(r"\.[A-Z]\d+-(.+)$", sarif_rule_id)
54
+ if match:
55
+ return match.group(1)
56
+ return None
57
+
58
+
59
+ def get_location(result: dict[str, Any]) -> str:
60
+ """Extract location string from SARIF result.
61
+
62
+ Args:
63
+ result: SARIF result object
64
+
65
+ Returns:
66
+ Location string in format "file:line"
67
+ """
68
+ locations = result.get("locations", [])
69
+ if not locations:
70
+ return "unknown"
71
+
72
+ loc = locations[0].get("physicalLocation", {})
73
+ artifact = loc.get("artifactLocation", {}).get("uri", "unknown")
74
+ region = loc.get("region", {})
75
+ line = region.get("startLine", 0)
76
+ return f"{artifact}:{line}"
77
+
78
+
79
+ def get_severity(rule: Rule | None, check_slug: str | None) -> Severity:
80
+ """Get severity for a violation.
81
+
82
+ Looks up severity from rule's checks list, falls back to MEDIUM.
83
+
84
+ Args:
85
+ rule: Rule object (may be None)
86
+ check_slug: Check slug from SARIF (may be None)
87
+
88
+ Returns:
89
+ Severity level
90
+ """
91
+ if rule is None:
92
+ return Severity.MEDIUM
93
+
94
+ # Try to find matching check
95
+ for check in rule.checks:
96
+ if check_slug and check_slug in check.id:
97
+ return check.severity
98
+ # Return first check's severity as default
99
+ return check.severity
100
+
101
+ return Severity.MEDIUM
102
+
103
+
104
+ def parse_sarif(sarif: dict[str, Any], rules: dict[str, Rule]) -> list[Violation]:
105
+ """Parse OpenGrep SARIF output into Violation objects.
106
+
107
+ Pure function — no I/O. Skips INFO/note level findings.
108
+
109
+ Args:
110
+ sarif: Parsed SARIF JSON
111
+ rules: Dict of rules for metadata lookup
112
+
113
+ Returns:
114
+ List of Violation objects
115
+ """
116
+ violations = []
117
+
118
+ for run in sarif.get("runs", []):
119
+ # Build map of rule levels from tool definitions
120
+ rule_levels: dict[str, str] = {}
121
+ tool = run.get("tool", {}).get("driver", {})
122
+ for rule_def in tool.get("rules", []):
123
+ rule_id = rule_def.get("id", "")
124
+ level = rule_def.get("defaultConfiguration", {}).get("level", "warning")
125
+ rule_levels[rule_id] = level
126
+
127
+ for result in run.get("results", []):
128
+ sarif_rule_id = result.get("ruleId", "")
129
+
130
+ # Skip INFO/note level findings
131
+ rule_level = rule_levels.get(sarif_rule_id, "warning")
132
+ if rule_level in ("note", "none"):
133
+ continue
134
+
135
+ short_rule_id = extract_rule_id(sarif_rule_id)
136
+ check_slug = extract_check_slug(sarif_rule_id)
137
+ message = result.get("message", {}).get("text", "")
138
+ location = get_location(result)
139
+
140
+ # Get rule metadata
141
+ rule = rules.get(short_rule_id)
142
+ title = rule.title if rule else sarif_rule_id
143
+ severity = get_severity(rule, check_slug)
144
+
145
+ violations.append(
146
+ Violation(
147
+ rule_id=short_rule_id,
148
+ rule_title=title,
149
+ location=location,
150
+ message=message,
151
+ severity=severity,
152
+ check_id=check_slug,
153
+ )
154
+ )
155
+
156
+ return violations
157
+
158
+
159
+ def dedupe_violations(violations: list[Violation]) -> list[Violation]:
160
+ """Deduplicate violations by (file, rule_id).
161
+
162
+ Keeps first occurrence of each unique (file, rule_id) pair.
163
+
164
+ Args:
165
+ violations: List of violations (may have duplicates)
166
+
167
+ Returns:
168
+ Deduplicated list of violations
169
+ """
170
+ seen: set[tuple[str, str]] = set()
171
+ result: list[Violation] = []
172
+
173
+ for v in violations:
174
+ file_path = v.location.rsplit(":", 1)[0] if ":" in v.location else v.location
175
+ key = (file_path, v.rule_id)
176
+
177
+ if key not in seen:
178
+ seen.add(key)
179
+ result.append(v)
180
+
181
+ return result
@@ -0,0 +1,178 @@
1
+ """Scoring functions for reporails. All pure functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from reporails_cli.core.models import FrictionEstimate, Level, Severity, Violation
6
+
7
+ # Severity weights for scoring (higher = more impact)
8
+ SEVERITY_WEIGHTS: dict[Severity, float] = {
9
+ Severity.CRITICAL: 5.5,
10
+ Severity.HIGH: 4.0,
11
+ Severity.MEDIUM: 2.5,
12
+ Severity.LOW: 1.0,
13
+ }
14
+
15
+ # Default weight for rules (used when calculating total possible points)
16
+ DEFAULT_RULE_WEIGHT: float = 2.5
17
+
18
+ # Level labels - must match levels.yml
19
+ LEVEL_LABELS: dict[Level, str] = {
20
+ Level.L1: "Absent",
21
+ Level.L2: "Basic",
22
+ Level.L3: "Structured",
23
+ Level.L4: "Abstracted",
24
+ Level.L5: "Governed",
25
+ Level.L6: "Adaptive",
26
+ }
27
+
28
+
29
+ def dedupe_violations(violations: list[Violation]) -> list[Violation]:
30
+ """Deduplicate violations by (file, rule_id).
31
+
32
+ Keeps first occurrence of each unique (file, rule_id) pair.
33
+
34
+ Args:
35
+ violations: List of violations (may have duplicates)
36
+
37
+ Returns:
38
+ Deduplicated list of violations
39
+ """
40
+ seen: set[tuple[str, str]] = set()
41
+ result: list[Violation] = []
42
+
43
+ for v in violations:
44
+ file_path = v.location.rsplit(":", 1)[0] if ":" in v.location else v.location
45
+ key = (file_path, v.rule_id)
46
+
47
+ if key not in seen:
48
+ seen.add(key)
49
+ result.append(v)
50
+
51
+ return result
52
+
53
+
54
+ def calculate_score(rules_checked: int, violations: list[Violation]) -> float:
55
+ """Calculate display score on 0-10 scale.
56
+
57
+ Score = (earned_points / possible_points) × 10
58
+
59
+ Lost points are capped per rule - multiple violations of the same rule
60
+ don't deduct more than the rule's weight (DEFAULT_RULE_WEIGHT).
61
+
62
+ Args:
63
+ rules_checked: Total number of rules checked
64
+ violations: List of violations found
65
+
66
+ Returns:
67
+ Score between 0.0 and 10.0
68
+ """
69
+ if rules_checked == 0:
70
+ return 10.0 # No rules = perfect
71
+
72
+ # Total possible points
73
+ possible = rules_checked * DEFAULT_RULE_WEIGHT
74
+
75
+ # Group violations by rule_id, cap lost points per rule
76
+ unique_violations = dedupe_violations(violations)
77
+ by_rule: dict[str, float] = {}
78
+ for v in unique_violations:
79
+ weight = SEVERITY_WEIGHTS.get(v.severity, DEFAULT_RULE_WEIGHT)
80
+ # Accumulate but cap at rule weight
81
+ current = by_rule.get(v.rule_id, 0.0)
82
+ by_rule[v.rule_id] = min(current + weight, DEFAULT_RULE_WEIGHT)
83
+
84
+ lost = sum(by_rule.values())
85
+
86
+ # Earned = possible - lost (floor at 0)
87
+ earned = max(0.0, possible - lost)
88
+
89
+ # Score on 0-10 scale
90
+ score = (earned / possible) * 10
91
+ return round(score, 1)
92
+
93
+
94
+ def get_severity_weight(severity: Severity) -> float:
95
+ """Get weight for a severity level.
96
+
97
+ Args:
98
+ severity: Severity level
99
+
100
+ Returns:
101
+ Weight value
102
+ """
103
+ return SEVERITY_WEIGHTS.get(severity, DEFAULT_RULE_WEIGHT)
104
+
105
+
106
+ def estimate_friction(violations: list[Violation]) -> FrictionEstimate:
107
+ """Estimate friction from violations.
108
+
109
+ Friction levels based on violation severity:
110
+ - extreme: Any critical violation
111
+ - high: 2+ high severity OR 5+ total violations
112
+ - medium: 1 high severity OR 3-4 violations
113
+ - small: 1-2 violations (medium/low severity)
114
+ - none: No violations
115
+
116
+ Args:
117
+ violations: List of violations
118
+
119
+ Returns:
120
+ FrictionEstimate with level
121
+ """
122
+ unique = dedupe_violations(violations)
123
+
124
+ if not unique:
125
+ return FrictionEstimate(level="none")
126
+
127
+ # Count by severity
128
+ critical_count = sum(1 for v in unique if v.severity == Severity.CRITICAL)
129
+ high_count = sum(1 for v in unique if v.severity == Severity.HIGH)
130
+ total_count = len(unique)
131
+
132
+ # Determine level
133
+ if critical_count > 0:
134
+ level = "extreme"
135
+ elif high_count >= 2 or total_count >= 5:
136
+ level = "high"
137
+ elif high_count >= 1 or total_count >= 3:
138
+ level = "medium"
139
+ else:
140
+ level = "small"
141
+
142
+ return FrictionEstimate(level=level)
143
+
144
+
145
+ def get_level_label(level: Level) -> str:
146
+ """Get human-readable label for level.
147
+
148
+ Args:
149
+ level: Maturity level
150
+
151
+ Returns:
152
+ Label string (e.g., "Abstracted")
153
+ """
154
+ return LEVEL_LABELS.get(level, "Unknown")
155
+
156
+
157
+ def has_critical_violations(violations: list[Violation]) -> bool:
158
+ """Check if any violation is critical.
159
+
160
+ Args:
161
+ violations: List of violations
162
+
163
+ Returns:
164
+ True if any violation has CRITICAL severity
165
+ """
166
+ return any(v.severity == Severity.CRITICAL for v in violations)
167
+
168
+
169
+ # Legacy compatibility
170
+ def get_severity_points(severity: Severity) -> int:
171
+ """Legacy: Get point deduction for a severity level."""
172
+ points_map = {
173
+ Severity.CRITICAL: -25,
174
+ Severity.HIGH: -15,
175
+ Severity.MEDIUM: -10,
176
+ Severity.LOW: -5,
177
+ }
178
+ return points_map.get(severity, -5)