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.
- reporails_cli/.env.example +1 -0
- reporails_cli/__init__.py +24 -0
- reporails_cli/bundled/.semgrepignore +51 -0
- reporails_cli/bundled/__init__.py +31 -0
- reporails_cli/bundled/capability-patterns.yml +54 -0
- reporails_cli/bundled/levels.yml +99 -0
- reporails_cli/core/__init__.py +35 -0
- reporails_cli/core/agents.py +147 -0
- reporails_cli/core/applicability.py +150 -0
- reporails_cli/core/bootstrap.py +147 -0
- reporails_cli/core/cache.py +352 -0
- reporails_cli/core/capability.py +245 -0
- reporails_cli/core/discover.py +362 -0
- reporails_cli/core/engine.py +177 -0
- reporails_cli/core/init.py +309 -0
- reporails_cli/core/levels.py +177 -0
- reporails_cli/core/models.py +329 -0
- reporails_cli/core/opengrep/__init__.py +34 -0
- reporails_cli/core/opengrep/runner.py +203 -0
- reporails_cli/core/opengrep/semgrepignore.py +39 -0
- reporails_cli/core/opengrep/templates.py +138 -0
- reporails_cli/core/registry.py +155 -0
- reporails_cli/core/sarif.py +181 -0
- reporails_cli/core/scorer.py +178 -0
- reporails_cli/core/semantic.py +193 -0
- reporails_cli/core/utils.py +139 -0
- reporails_cli/formatters/__init__.py +19 -0
- reporails_cli/formatters/json.py +137 -0
- reporails_cli/formatters/mcp.py +68 -0
- reporails_cli/formatters/text/__init__.py +32 -0
- reporails_cli/formatters/text/box.py +89 -0
- reporails_cli/formatters/text/chars.py +42 -0
- reporails_cli/formatters/text/compact.py +119 -0
- reporails_cli/formatters/text/components.py +117 -0
- reporails_cli/formatters/text/full.py +135 -0
- reporails_cli/formatters/text/rules.py +50 -0
- reporails_cli/formatters/text/violations.py +92 -0
- reporails_cli/interfaces/__init__.py +1 -0
- reporails_cli/interfaces/cli/__init__.py +7 -0
- reporails_cli/interfaces/cli/main.py +352 -0
- reporails_cli/interfaces/mcp/__init__.py +5 -0
- reporails_cli/interfaces/mcp/server.py +194 -0
- reporails_cli/interfaces/mcp/tools.py +136 -0
- reporails_cli/py.typed +0 -0
- reporails_cli/templates/__init__.py +65 -0
- reporails_cli/templates/cli_box.txt +10 -0
- reporails_cli/templates/cli_cta.txt +4 -0
- reporails_cli/templates/cli_delta.txt +1 -0
- reporails_cli/templates/cli_file_header.txt +1 -0
- reporails_cli/templates/cli_legend.txt +1 -0
- reporails_cli/templates/cli_pending.txt +3 -0
- reporails_cli/templates/cli_violation.txt +1 -0
- reporails_cli/templates/cli_working.txt +2 -0
- reporails_cli-0.0.1.dist-info/METADATA +108 -0
- reporails_cli-0.0.1.dist-info/RECORD +58 -0
- reporails_cli-0.0.1.dist-info/WHEEL +4 -0
- reporails_cli-0.0.1.dist-info/entry_points.txt +3 -0
- 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
|