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.
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,177 @@
1
+ """Validation engine - orchestration only, no domain logic.
2
+
3
+ Coordinates other modules to run validation. Target: <200 lines.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import contextlib
9
+ import time
10
+ from pathlib import Path
11
+
12
+ from reporails_cli.bundled import get_capability_patterns_path
13
+ from reporails_cli.core.applicability import detect_features_filesystem, get_applicable_rules
14
+ from reporails_cli.core.bootstrap import get_agent_vars, get_opengrep_bin, is_initialized
15
+ from reporails_cli.core.cache import record_scan
16
+ from reporails_cli.core.capability import (
17
+ detect_features_content,
18
+ determine_capability_level,
19
+ )
20
+ from reporails_cli.core.discover import generate_backbone_yaml, run_discovery, save_backbone
21
+ from reporails_cli.core.init import run_init
22
+ from reporails_cli.core.models import PendingSemantic, Rule, RuleType, ValidationResult
23
+ from reporails_cli.core.opengrep import get_rule_yml_paths, run_opengrep
24
+ from reporails_cli.core.registry import load_rules
25
+ from reporails_cli.core.sarif import dedupe_violations, parse_sarif
26
+ from reporails_cli.core.scorer import calculate_score, estimate_friction
27
+ from reporails_cli.core.semantic import build_semantic_requests
28
+
29
+
30
+ def run_validation(
31
+ target: Path,
32
+ rules: dict[str, Rule] | None = None,
33
+ opengrep_path: Path | None = None,
34
+ rules_dir: Path | None = None,
35
+ use_cache: bool = True,
36
+ record_analytics: bool = True,
37
+ agent: str = "",
38
+ ) -> ValidationResult:
39
+ """Run full validation on target directory.
40
+
41
+ Two-pass approach:
42
+ 1. Capability detection (small pattern set) → determines final level
43
+ 2. Rule validation (filtered by final level) → violations + score
44
+
45
+ Args:
46
+ target: Directory or file to validate
47
+ rules: Pre-loaded rules (optional, loads from rules_dir if not provided)
48
+ opengrep_path: Path to OpenGrep binary (optional, auto-detects)
49
+ rules_dir: Directory containing rules (optional)
50
+ use_cache: Whether to use cached results
51
+ record_analytics: Whether to record scan analytics
52
+ agent: Agent identifier for loading template vars (empty = no agent-specific vars)
53
+ """
54
+ start_time = time.perf_counter()
55
+ project_root = target.parent if target.is_file() else target
56
+
57
+ # Auto-init if needed
58
+ if not is_initialized():
59
+ run_init()
60
+ if opengrep_path is None:
61
+ opengrep_path = get_opengrep_bin()
62
+
63
+ # Auto-create backbone if missing
64
+ backbone_path = project_root / ".reporails" / "backbone.yml"
65
+ if not backbone_path.exists():
66
+ save_backbone(project_root, generate_backbone_yaml(run_discovery(project_root)))
67
+
68
+ # Get template vars from agent config for yml placeholder resolution
69
+ template_context = get_agent_vars(agent) if agent else {}
70
+
71
+ # Load rules if not provided
72
+ if rules is None:
73
+ rules = load_rules(rules_dir)
74
+
75
+ # =========================================================================
76
+ # PASS 1: Capability Detection (determines final level)
77
+ # =========================================================================
78
+
79
+ # Filesystem feature detection (fast)
80
+ features = detect_features_filesystem(project_root)
81
+
82
+ # Content feature detection via OpenGrep (capability patterns only)
83
+ capability_patterns = get_capability_patterns_path()
84
+ capability_sarif = {}
85
+ if capability_patterns.exists():
86
+ capability_sarif = run_opengrep(
87
+ [capability_patterns], target, opengrep_path, template_context
88
+ )
89
+
90
+ content_features = detect_features_content(capability_sarif)
91
+
92
+ # Determine FINAL capability level (filesystem + content)
93
+ capability = determine_capability_level(features, content_features)
94
+ final_level = capability.level
95
+
96
+ # =========================================================================
97
+ # PASS 2: Rule Validation (filtered by final level)
98
+ # =========================================================================
99
+
100
+ # Filter rules by FINAL level - this ensures scoring matches displayed level
101
+ applicable_rules = get_applicable_rules(rules, final_level)
102
+
103
+ # Run OpenGrep on applicable rules only
104
+ rule_yml_paths = get_rule_yml_paths(applicable_rules)
105
+ rule_sarif = {}
106
+ if rule_yml_paths:
107
+ rule_sarif = run_opengrep(
108
+ rule_yml_paths, target, opengrep_path, template_context
109
+ )
110
+
111
+ # Split by type
112
+ deterministic = {k: v for k, v in applicable_rules.items() if v.type == RuleType.DETERMINISTIC}
113
+ semantic = {k: v for k, v in applicable_rules.items() if v.type == RuleType.SEMANTIC}
114
+
115
+ # Parse violations from rule SARIF (only deterministic rules)
116
+ violations = parse_sarif(rule_sarif, deterministic)
117
+
118
+ # Build semantic requests from rule SARIF (only semantic rules)
119
+ judgment_requests = build_semantic_requests(rule_sarif, semantic, project_root)
120
+
121
+ # =========================================================================
122
+ # Scoring (uses same rules that were filtered by final level)
123
+ # =========================================================================
124
+
125
+ unique_violations = dedupe_violations(violations)
126
+ score = calculate_score(len(applicable_rules), unique_violations)
127
+ friction = estimate_friction(unique_violations)
128
+ rules_failed = len({v.rule_id for v in unique_violations})
129
+
130
+ # Record analytics
131
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
132
+ if record_analytics:
133
+ with contextlib.suppress(OSError):
134
+ record_scan(target, score, final_level.value, len(violations),
135
+ len(applicable_rules), elapsed_ms, features.instruction_file_count)
136
+
137
+ # Build pending semantic summary
138
+ pending_semantic = None
139
+ if judgment_requests:
140
+ unique_rules = sorted({jr.rule_id for jr in judgment_requests})
141
+ unique_files = {jr.location.rsplit(":", 1)[0] for jr in judgment_requests}
142
+ pending_semantic = PendingSemantic(
143
+ rule_count=len(unique_rules),
144
+ file_count=len(unique_files),
145
+ rules=tuple(unique_rules),
146
+ )
147
+
148
+ return ValidationResult(
149
+ score=score,
150
+ level=final_level,
151
+ violations=tuple(violations),
152
+ judgment_requests=tuple(judgment_requests),
153
+ rules_checked=len(applicable_rules),
154
+ rules_passed=len(applicable_rules) - rules_failed,
155
+ rules_failed=rules_failed,
156
+ feature_summary=capability.feature_summary,
157
+ friction=friction,
158
+ is_partial=bool(judgment_requests), # Partial if semantic rules pending
159
+ pending_semantic=pending_semantic,
160
+ )
161
+
162
+
163
+ def run_validation_sync(
164
+ target: Path,
165
+ rules: dict[str, Rule] | None = None,
166
+ opengrep_path: Path | None = None,
167
+ rules_dir: Path | None = None,
168
+ use_cache: bool = True,
169
+ record_analytics: bool = True,
170
+ agent: str = "",
171
+ checks_dir: Path | None = None, # Legacy alias
172
+ ) -> ValidationResult:
173
+ """Legacy alias for run_validation (now sync)."""
174
+ # Support legacy checks_dir parameter
175
+ if checks_dir is not None and rules_dir is None:
176
+ rules_dir = checks_dir
177
+ return run_validation(target, rules, opengrep_path, rules_dir, use_cache, record_analytics, agent)
@@ -0,0 +1,309 @@
1
+ """Init command - downloads opengrep and syncs rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.resources
6
+ import platform
7
+ import shutil
8
+ import stat
9
+ from pathlib import Path
10
+ from tempfile import TemporaryDirectory
11
+
12
+ import httpx
13
+
14
+ from reporails_cli.core.bootstrap import get_global_config, get_opengrep_bin, get_reporails_home
15
+
16
+ # Hardcoded version - no env var handling
17
+ OPENGREP_VERSION = "1.15.1"
18
+
19
+ OPENGREP_URLS: dict[tuple[str, str], str] = {
20
+ ("linux", "x86_64"): (
21
+ "https://github.com/opengrep/opengrep/releases/download/"
22
+ f"v{OPENGREP_VERSION}/opengrep_manylinux_x86"
23
+ ),
24
+ ("linux", "aarch64"): (
25
+ "https://github.com/opengrep/opengrep/releases/download/"
26
+ f"v{OPENGREP_VERSION}/opengrep_manylinux_aarch64"
27
+ ),
28
+ ("darwin", "x86_64"): (
29
+ "https://github.com/opengrep/opengrep/releases/download/"
30
+ f"v{OPENGREP_VERSION}/opengrep_osx_x86"
31
+ ),
32
+ ("darwin", "arm64"): (
33
+ "https://github.com/opengrep/opengrep/releases/download/"
34
+ f"v{OPENGREP_VERSION}/opengrep_osx_arm64"
35
+ ),
36
+ ("windows", "x86_64"): (
37
+ "https://github.com/opengrep/opengrep/releases/download/"
38
+ f"v{OPENGREP_VERSION}/opengrep-core_windows_x86.zip"
39
+ ),
40
+ }
41
+
42
+ RULES_VERSION = "v0.0.1"
43
+ RULES_TARBALL_URL = "https://github.com/reporails/rules/releases/download/{version}/reporails-rules-{version}.tar.gz"
44
+
45
+
46
+ def get_platform() -> tuple[str, str]:
47
+ """Detect current platform."""
48
+ system = platform.system().lower()
49
+ machine = platform.machine().lower()
50
+
51
+ if system == "darwin":
52
+ os_name = "darwin"
53
+ elif system == "linux":
54
+ os_name = "linux"
55
+ elif system == "windows":
56
+ os_name = "windows"
57
+ else:
58
+ msg = f"Unsupported operating system: {system}"
59
+ raise RuntimeError(msg)
60
+
61
+ if machine in ("x86_64", "amd64"):
62
+ arch = "x86_64"
63
+ elif machine in ("arm64", "aarch64"):
64
+ arch = "arm64" if os_name == "darwin" else "aarch64"
65
+ else:
66
+ msg = f"Unsupported architecture: {machine}"
67
+ raise RuntimeError(msg)
68
+
69
+ return os_name, arch
70
+
71
+
72
+ def download_opengrep() -> Path:
73
+ """Download opengrep binary to ~/.reporails/bin/opengrep."""
74
+ os_name, arch = get_platform()
75
+ key = (os_name, arch)
76
+
77
+ if key not in OPENGREP_URLS:
78
+ msg = f"Unsupported platform: {os_name}/{arch}"
79
+ raise RuntimeError(msg)
80
+
81
+ url = OPENGREP_URLS[key]
82
+ bin_path = get_opengrep_bin()
83
+
84
+ # Create bin directory
85
+ bin_path.parent.mkdir(parents=True, exist_ok=True)
86
+
87
+ # Download
88
+ with httpx.Client(follow_redirects=True, timeout=120.0) as client:
89
+ response = client.get(url)
90
+ response.raise_for_status()
91
+
92
+ # Write binary directly (raw binary, not archive for non-windows)
93
+ bin_path.write_bytes(response.content)
94
+
95
+ # Make executable on Unix
96
+ if os_name != "windows":
97
+ bin_path.chmod(bin_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
98
+
99
+ return bin_path
100
+
101
+
102
+ def get_bundled_checks_path() -> Path | None:
103
+ """
104
+ Get path to bundled checks (.yml files) in installed package.
105
+
106
+ Returns:
107
+ Path to bundled_checks directory, or None if not found
108
+ """
109
+ try:
110
+ # Use importlib.resources to find bundled checks
111
+ files = importlib.resources.files("reporails_cli")
112
+ bundled = files / "bundled_checks"
113
+ # Convert to Path - this works for installed packages
114
+ with importlib.resources.as_file(bundled) as path:
115
+ if path.exists():
116
+ return path
117
+ except (TypeError, FileNotFoundError):
118
+ pass
119
+ return None
120
+
121
+
122
+ def copy_bundled_yml_files(dest: Path) -> int:
123
+ """
124
+ Copy bundled .yml files from package to destination.
125
+
126
+ Args:
127
+ dest: Destination directory
128
+
129
+ Returns:
130
+ Number of .yml files copied
131
+ """
132
+ bundled_path = get_bundled_checks_path()
133
+ if bundled_path is None:
134
+ return 0
135
+
136
+ dest.mkdir(parents=True, exist_ok=True)
137
+ count = 0
138
+
139
+ for yml_file in bundled_path.rglob("*.yml"):
140
+ # Preserve directory structure
141
+ relative = yml_file.relative_to(bundled_path)
142
+ dest_file = dest / relative
143
+ dest_file.parent.mkdir(parents=True, exist_ok=True)
144
+ shutil.copy2(yml_file, dest_file)
145
+ count += 1
146
+
147
+ return count
148
+
149
+
150
+ def copy_local_framework(source: Path) -> tuple[Path, int]:
151
+ """
152
+ Copy rules from local framework directory to ~/.reporails/rules/.
153
+
154
+ Used in dev mode when framework_path is configured in ~/.reporails/config.yml.
155
+
156
+ Local framework structure:
157
+ source/
158
+ ├── core/ # Core rules
159
+ ├── agents/ # Agent-specific rules
160
+ │ └── claude/
161
+ │ └── rules/ # Claude-specific rules
162
+ ├── schemas/
163
+ └── docs/
164
+
165
+ Args:
166
+ source: Local framework directory path
167
+
168
+ Returns:
169
+ Tuple of (rules_path, total_file_count)
170
+ """
171
+ rules_path = get_reporails_home() / "rules"
172
+
173
+ # Clear existing rules
174
+ if rules_path.exists():
175
+ shutil.rmtree(rules_path)
176
+
177
+ rules_path.mkdir(parents=True, exist_ok=True)
178
+ count = 0
179
+
180
+ # Directories to copy from framework root
181
+ dirs_to_copy = ["core", "agents", "schemas", "docs"]
182
+
183
+ for dir_name in dirs_to_copy:
184
+ source_dir = source / dir_name
185
+ if source_dir.exists() and source_dir.is_dir():
186
+ dest_dir = rules_path / dir_name
187
+ shutil.copytree(source_dir, dest_dir)
188
+ # Count files copied
189
+ count += sum(1 for _ in dest_dir.rglob("*") if _.is_file())
190
+
191
+ return rules_path, count
192
+
193
+
194
+ def download_rules_tarball(dest: Path) -> int:
195
+ """
196
+ Download rules from GitHub release tarball.
197
+
198
+ Args:
199
+ dest: Destination directory (~/.reporails/rules/)
200
+
201
+ Returns:
202
+ Number of files extracted
203
+ """
204
+ import tarfile
205
+
206
+ url = RULES_TARBALL_URL.format(version=RULES_VERSION)
207
+
208
+ with httpx.Client(follow_redirects=True, timeout=120.0) as client:
209
+ response = client.get(url)
210
+ response.raise_for_status()
211
+
212
+ with TemporaryDirectory() as tmpdir:
213
+ tarball_path = Path(tmpdir) / "rules.tar.gz"
214
+ tarball_path.write_bytes(response.content)
215
+
216
+ # Extract
217
+ with tarfile.open(tarball_path, "r:gz") as tar:
218
+ tar.extractall(path=dest)
219
+
220
+ # Count files
221
+ count = sum(1 for _ in dest.rglob("*") if _.is_file())
222
+
223
+ return count
224
+
225
+
226
+ def download_from_github() -> tuple[Path, int]:
227
+ """
228
+ Setup rules from GitHub at ~/.reporails/rules/.
229
+
230
+ Merges two sources:
231
+ 1. Bundled .yml files (OpenGrep patterns) from package
232
+ 2. Downloaded files from GitHub release tarball
233
+
234
+ Returns:
235
+ Tuple of (rules_path, total_file_count)
236
+ """
237
+ rules_path = get_reporails_home() / "rules"
238
+
239
+ # Clear existing rules
240
+ if rules_path.exists():
241
+ shutil.rmtree(rules_path)
242
+
243
+ rules_path.mkdir(parents=True, exist_ok=True)
244
+
245
+ # 1. Copy bundled .yml files
246
+ yml_count = copy_bundled_yml_files(rules_path)
247
+
248
+ # 2. Download from GitHub release tarball
249
+ tarball_count = download_rules_tarball(rules_path)
250
+
251
+ return rules_path, yml_count + tarball_count
252
+
253
+
254
+ def download_rules() -> tuple[Path, int]:
255
+ """
256
+ Setup rules at ~/.reporails/rules/.
257
+
258
+ Checks for local framework_path in config first (dev mode),
259
+ otherwise downloads from GitHub.
260
+
261
+ Returns:
262
+ Tuple of (rules_path, total_file_count)
263
+ """
264
+ # Check for local framework override (dev mode)
265
+ config = get_global_config()
266
+ if config.framework_path and config.framework_path.exists():
267
+ return copy_local_framework(config.framework_path)
268
+
269
+ # Otherwise download from GitHub
270
+ return download_from_github()
271
+
272
+
273
+ def sync_rules_to_local(local_checks_dir: Path) -> int:
274
+ """
275
+ Sync rules from GitHub release tarball to local checks directory.
276
+
277
+ For development: downloads rules from release tarball.
278
+
279
+ Args:
280
+ local_checks_dir: Local checks directory (e.g., ./checks/)
281
+
282
+ Returns:
283
+ Number of files synced
284
+ """
285
+ return download_rules_tarball(local_checks_dir)
286
+
287
+
288
+ def run_init() -> dict[str, str | int | Path]:
289
+ """
290
+ Run global initialization.
291
+
292
+ 1. Download opengrep binary to ~/.reporails/bin/
293
+ 2. Setup rules at ~/.reporails/rules/ (from local framework or GitHub)
294
+
295
+ Returns dict with status info.
296
+ """
297
+ results: dict[str, str | int | Path] = {}
298
+
299
+ # 1. Download opengrep
300
+ bin_path = download_opengrep()
301
+ results["opengrep_path"] = bin_path
302
+ results["opengrep_version"] = OPENGREP_VERSION
303
+
304
+ # 2. Setup rules (check local framework_path first, then GitHub)
305
+ rules_path, rule_count = download_rules()
306
+ results["rules_path"] = rules_path
307
+ results["rule_count"] = rule_count
308
+
309
+ return results
@@ -0,0 +1,177 @@
1
+ """Level configuration and rule-to-level mapping.
2
+
3
+ Loads from bundled levels.yml. All functions are pure after initial load.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from functools import lru_cache
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ import yaml
12
+
13
+ from reporails_cli.bundled import get_levels_path
14
+ from reporails_cli.core.models import Level
15
+
16
+ if TYPE_CHECKING:
17
+ from reporails_cli.core.models import DetectedFeatures
18
+
19
+ # Level labels - must match levels.yml
20
+ LEVEL_LABELS: dict[Level, str] = {
21
+ Level.L1: "Absent",
22
+ Level.L2: "Basic",
23
+ Level.L3: "Structured",
24
+ Level.L4: "Abstracted",
25
+ Level.L5: "Governed",
26
+ Level.L6: "Adaptive",
27
+ }
28
+
29
+
30
+ @lru_cache(maxsize=1)
31
+ def get_level_config() -> dict[str, Any]:
32
+ """Load bundled levels.yml configuration.
33
+
34
+ Cached for performance.
35
+
36
+ Returns:
37
+ Parsed levels.yml content
38
+ """
39
+ levels_path = get_levels_path()
40
+ if not levels_path.exists():
41
+ return {"levels": {}, "score_thresholds": {}, "detection": {}}
42
+
43
+ content = levels_path.read_text(encoding="utf-8")
44
+ config: dict[str, Any] = yaml.safe_load(content) or {}
45
+ return config
46
+
47
+
48
+ def get_rules_for_level(level: Level) -> set[str]:
49
+ """Get all rule IDs required for a given level.
50
+
51
+ Includes rules from all levels up to and including the given level.
52
+
53
+ Args:
54
+ level: Target capability level
55
+
56
+ Returns:
57
+ Set of rule IDs applicable at this level
58
+ """
59
+ config = get_level_config()
60
+ levels_data = config.get("levels", {})
61
+
62
+ # Build rules set by traversing level inheritance
63
+ all_rules: set[str] = set()
64
+ level_order = [Level.L1, Level.L2, Level.L3, Level.L4, Level.L5, Level.L6]
65
+ target_index = level_order.index(level)
66
+
67
+ for lvl in level_order[: target_index + 1]:
68
+ level_key = lvl.value
69
+ if level_key in levels_data:
70
+ rules = levels_data[level_key].get("required_rules", [])
71
+ all_rules.update(rules)
72
+
73
+ return all_rules
74
+
75
+
76
+ def get_level_label(level: Level) -> str:
77
+ """Get human-readable label for level.
78
+
79
+ Args:
80
+ level: Capability level
81
+
82
+ Returns:
83
+ Label string (e.g., "Abstracted")
84
+ """
85
+ return LEVEL_LABELS.get(level, "Unknown")
86
+
87
+
88
+ def get_level_includes(level: Level) -> list[Level]:
89
+ """Get levels included by inheritance.
90
+
91
+ Args:
92
+ level: Target level
93
+
94
+ Returns:
95
+ List of included levels (lower levels)
96
+ """
97
+ config = get_level_config()
98
+ levels_data = config.get("levels", {})
99
+
100
+ level_key = level.value
101
+ if level_key not in levels_data:
102
+ return []
103
+
104
+ includes = levels_data[level_key].get("includes", [])
105
+ return [Level(inc) for inc in includes if inc in [lv.value for lv in Level]]
106
+
107
+
108
+ def get_score_threshold(level: Level) -> int:
109
+ """Get capability score threshold for a level.
110
+
111
+ Args:
112
+ level: Target level
113
+
114
+ Returns:
115
+ Minimum score required for this level
116
+ """
117
+ config = get_level_config()
118
+ thresholds = config.get("score_thresholds", {})
119
+ result = thresholds.get(level.value, 0)
120
+ return int(result)
121
+
122
+
123
+ def capability_score_to_level(score: int) -> Level:
124
+ """Map capability score to level.
125
+
126
+ Args:
127
+ score: Capability score (0-12)
128
+
129
+ Returns:
130
+ Corresponding level
131
+ """
132
+ config = get_level_config()
133
+ thresholds = config.get("score_thresholds", {})
134
+
135
+ # Default thresholds if not in config
136
+ if not thresholds:
137
+ thresholds = {"L1": 0, "L2": 1, "L3": 3, "L4": 5, "L5": 7, "L6": 10}
138
+
139
+ # Find highest level where score meets threshold
140
+ level_order = [Level.L6, Level.L5, Level.L4, Level.L3, Level.L2, Level.L1]
141
+ for level in level_order:
142
+ threshold = thresholds.get(level.value, 0)
143
+ if score >= threshold:
144
+ return level
145
+
146
+ return Level.L1
147
+
148
+
149
+ def detect_orphan_features(features: DetectedFeatures, base_level: Level) -> bool:
150
+ """Check if project has features from levels above base level.
151
+
152
+ Example: L3 project with backbone.yml (L6 feature) → has_orphan = True
153
+ Display as "L3+" to indicate advanced features present.
154
+
155
+ Args:
156
+ features: Detected project features
157
+ base_level: Base capability level
158
+
159
+ Returns:
160
+ True if features above base level are present
161
+ """
162
+ level_features: dict[Level, list[bool]] = {
163
+ Level.L6: [features.has_backbone],
164
+ Level.L5: [features.component_count >= 3, features.has_shared_files],
165
+ Level.L4: [features.has_rules_dir],
166
+ Level.L3: [features.has_imports, features.has_multiple_instruction_files],
167
+ }
168
+
169
+ level_order = [Level.L1, Level.L2, Level.L3, Level.L4, Level.L5, Level.L6]
170
+ base_index = level_order.index(base_level)
171
+
172
+ # Check features from levels above base
173
+ for level in level_order[base_index + 1 :]:
174
+ if level in level_features and any(level_features[level]):
175
+ return True
176
+
177
+ return False