diffsense 2.2.12__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.
- adapters/__init__.py +0 -0
- adapters/base.py +27 -0
- adapters/github_adapter.py +164 -0
- adapters/gitlab_adapter.py +207 -0
- adapters/local_adapter.py +136 -0
- banner.py +71 -0
- cli.py +606 -0
- config/__init__.py +1 -0
- config/rules.yaml +371 -0
- core/__init__.py +235 -0
- core/ast_detector.py +853 -0
- core/change.py +46 -0
- core/composer.py +93 -0
- core/evaluator.py +15 -0
- core/ignore_manager.py +71 -0
- core/knowledge.py +77 -0
- core/parser.py +181 -0
- core/parser_manager.py +104 -0
- core/quality_manager.py +117 -0
- core/renderer.py +197 -0
- core/rule_base.py +98 -0
- core/rule_runtime.py +103 -0
- core/rules.py +718 -0
- core/run_config.py +85 -0
- core/semantic_diff.py +359 -0
- core/signal_model.py +21 -0
- core/signals_registry.py +62 -0
- diffsense-2.2.12.dist-info/METADATA +18 -0
- diffsense-2.2.12.dist-info/RECORD +58 -0
- diffsense-2.2.12.dist-info/WHEEL +5 -0
- diffsense-2.2.12.dist-info/entry_points.txt +3 -0
- diffsense-2.2.12.dist-info/licenses/LICENSE +176 -0
- diffsense-2.2.12.dist-info/top_level.txt +11 -0
- diffsense_mcp/__init__.py +1 -0
- diffsense_mcp/launcher.py +28 -0
- diffsense_mcp/server.py +687 -0
- governance/lifecycle.py +54 -0
- main.py +318 -0
- rules/__init__.py +246 -0
- rules/api_compatibility.py +372 -0
- rules/collection_handling.py +349 -0
- rules/concurrency.py +194 -0
- rules/concurrency_adapter.py +250 -0
- rules/cross_language_adapter.py +444 -0
- rules/exception_handling.py +320 -0
- rules/go_rules.py +401 -0
- rules/null_safety.py +301 -0
- rules/resource_management.py +222 -0
- rules/yaml_adapter.py +195 -0
- run_audit.py +478 -0
- sdk/cpp_adapter.py +238 -0
- sdk/go_adapter.py +199 -0
- sdk/java_adapter.py +199 -0
- sdk/javascript_adapter.py +229 -0
- sdk/language_adapter.py +313 -0
- sdk/python_adapter.py +195 -0
- sdk/rule.py +63 -0
- sdk/signal.py +14 -0
core/quality_manager.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
from typing import Dict, Any, Tuple, List
|
|
5
|
+
|
|
6
|
+
class RuleQualityManager:
|
|
7
|
+
def __init__(self, path: str, auto_tune: bool, degrade_threshold: float, disable_threshold: float, min_samples: int):
|
|
8
|
+
self.path = path
|
|
9
|
+
self.auto_tune = auto_tune
|
|
10
|
+
self.degrade_threshold = degrade_threshold
|
|
11
|
+
self.disable_threshold = disable_threshold
|
|
12
|
+
self.min_samples = min_samples
|
|
13
|
+
self.data = self._load()
|
|
14
|
+
|
|
15
|
+
def _load(self) -> Dict[str, Any]:
|
|
16
|
+
if not os.path.exists(self.path):
|
|
17
|
+
return {"rules": {}}
|
|
18
|
+
try:
|
|
19
|
+
with open(self.path, "r", encoding="utf-8") as f:
|
|
20
|
+
data = json.load(f)
|
|
21
|
+
if isinstance(data, dict) and isinstance(data.get("rules"), dict):
|
|
22
|
+
return data
|
|
23
|
+
except Exception:
|
|
24
|
+
return {"rules": {}}
|
|
25
|
+
return {"rules": {}}
|
|
26
|
+
|
|
27
|
+
def persist(self) -> None:
|
|
28
|
+
self.data["updated_at"] = int(time.time())
|
|
29
|
+
try:
|
|
30
|
+
with open(self.path, "w", encoding="utf-8") as f:
|
|
31
|
+
json.dump(self.data, f, ensure_ascii=False, indent=2)
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
def get_metrics(self) -> Dict[str, Any]:
|
|
36
|
+
return self.data.get("rules", {})
|
|
37
|
+
|
|
38
|
+
def _entry(self, rule_id: str) -> Dict[str, Any]:
|
|
39
|
+
rules = self.data.setdefault("rules", {})
|
|
40
|
+
entry = rules.get(rule_id)
|
|
41
|
+
if not isinstance(entry, dict):
|
|
42
|
+
entry = {"hits": 0, "confirmed": 0, "false_positive": 0, "precision": 1.0}
|
|
43
|
+
rules[rule_id] = entry
|
|
44
|
+
if "precision" not in entry:
|
|
45
|
+
entry["precision"] = self._precision(entry)
|
|
46
|
+
return entry
|
|
47
|
+
|
|
48
|
+
def _precision(self, entry: Dict[str, Any]) -> float:
|
|
49
|
+
hits = int(entry.get("hits", 0))
|
|
50
|
+
confirmed = int(entry.get("confirmed", 0))
|
|
51
|
+
return (confirmed / hits) if hits else 1.0
|
|
52
|
+
|
|
53
|
+
def record_hit(self, rule_id: str) -> Dict[str, Any]:
|
|
54
|
+
entry = self._entry(rule_id)
|
|
55
|
+
entry["hits"] = int(entry.get("hits", 0)) + 1
|
|
56
|
+
entry["confirmed"] = int(entry.get("confirmed", 0)) + 1
|
|
57
|
+
entry["precision"] = self._precision(entry)
|
|
58
|
+
return entry
|
|
59
|
+
|
|
60
|
+
def record_false_positive(self, rule_id: str) -> Dict[str, Any]:
|
|
61
|
+
entry = self._entry(rule_id)
|
|
62
|
+
entry["hits"] = int(entry.get("hits", 0)) + 1
|
|
63
|
+
entry["false_positive"] = int(entry.get("false_positive", 0)) + 1
|
|
64
|
+
entry["precision"] = self._precision(entry)
|
|
65
|
+
return entry
|
|
66
|
+
|
|
67
|
+
def status(self, rule_id: str) -> Tuple[str, float, int]:
|
|
68
|
+
entry = self._entry(rule_id)
|
|
69
|
+
hits = int(entry.get("hits", 0))
|
|
70
|
+
precision = float(entry.get("precision", 1.0))
|
|
71
|
+
if hits < self.min_samples:
|
|
72
|
+
return "insufficient", precision, hits
|
|
73
|
+
if precision < self.disable_threshold:
|
|
74
|
+
return "disabled", precision, hits
|
|
75
|
+
if precision < self.degrade_threshold:
|
|
76
|
+
return "degraded", precision, hits
|
|
77
|
+
return "normal", precision, hits
|
|
78
|
+
|
|
79
|
+
def should_skip(self, rule_id: str) -> bool:
|
|
80
|
+
"""
|
|
81
|
+
[Architecture Principle Violation] NEVER automatically skip a rule based on quality.
|
|
82
|
+
Decision must be human-led.
|
|
83
|
+
"""
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
def adjust_severity(self, severity: str, rule_id: str) -> str:
|
|
87
|
+
"""
|
|
88
|
+
[Architecture Principle Violation] NEVER automatically downgrade severity.
|
|
89
|
+
Severity defines risk semantics, not frequency.
|
|
90
|
+
"""
|
|
91
|
+
return severity
|
|
92
|
+
|
|
93
|
+
def warnings(self) -> List[Dict[str, Any]]:
|
|
94
|
+
rows = []
|
|
95
|
+
for rule_id, entry in self.get_metrics().items():
|
|
96
|
+
if not isinstance(entry, dict):
|
|
97
|
+
continue
|
|
98
|
+
status, precision, hits = self.status(rule_id)
|
|
99
|
+
if status in ["degraded", "disabled"]:
|
|
100
|
+
rows.append({
|
|
101
|
+
"rule_id": rule_id,
|
|
102
|
+
"precision": precision,
|
|
103
|
+
"hits": hits,
|
|
104
|
+
"false_positive": entry.get("false_positive", 0),
|
|
105
|
+
"confirmed": entry.get("confirmed", 0),
|
|
106
|
+
"status": status
|
|
107
|
+
})
|
|
108
|
+
return sorted(rows, key=lambda r: (r["status"], r["precision"]))
|
|
109
|
+
|
|
110
|
+
def update_report(self, metrics: Dict[str, Dict[str, Any]], confidences: Dict[str, float]) -> None:
|
|
111
|
+
for rule_id, m in metrics.items():
|
|
112
|
+
entry = self._entry(rule_id)
|
|
113
|
+
calls = int(m.get("calls", 0))
|
|
114
|
+
time_ns = int(m.get("time_ns", 0))
|
|
115
|
+
avg_time_ms = (time_ns / 1_000_000 / calls) if calls else 0.0
|
|
116
|
+
entry["avg_time_ms"] = avg_time_ms
|
|
117
|
+
entry["confidence"] = float(confidences.get(rule_id, entry.get("confidence", 1.0)))
|
core/renderer.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
from typing import Dict, Any
|
|
2
|
+
import html as _html
|
|
3
|
+
|
|
4
|
+
class MarkdownRenderer:
|
|
5
|
+
def render(self, result: Dict[str, Any]) -> str:
|
|
6
|
+
"""
|
|
7
|
+
Renders the audit result into a Markdown string.
|
|
8
|
+
"""
|
|
9
|
+
review_level = result.get("review_level", "unknown").capitalize()
|
|
10
|
+
details = result.get("details", [])
|
|
11
|
+
|
|
12
|
+
lines = []
|
|
13
|
+
|
|
14
|
+
if review_level in ["Elevated", "Critical"]:
|
|
15
|
+
lines.append(f"# 🚨 DiffSense Risk Signal: {review_level}")
|
|
16
|
+
else:
|
|
17
|
+
lines.append(f"# ✅ DiffSense Audit: {review_level}")
|
|
18
|
+
lines.append("")
|
|
19
|
+
|
|
20
|
+
if not details:
|
|
21
|
+
lines.append("No warnings detected.")
|
|
22
|
+
rule_stats = (result.get("_metrics") or {}).get("rule_stats", {})
|
|
23
|
+
total_rules = rule_stats.get("total_rules", 0)
|
|
24
|
+
executed_count = rule_stats.get("executed_count", 0)
|
|
25
|
+
if total_rules or executed_count:
|
|
26
|
+
lines.append("")
|
|
27
|
+
lines.append(f"Rules executed: {executed_count} / {total_rules}")
|
|
28
|
+
return "\n".join(lines)
|
|
29
|
+
|
|
30
|
+
severity_rank = {
|
|
31
|
+
"critical": 0,
|
|
32
|
+
"high": 1,
|
|
33
|
+
"medium": 2,
|
|
34
|
+
"low": 3,
|
|
35
|
+
"unknown": 4
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
grouped = {}
|
|
39
|
+
for d in details:
|
|
40
|
+
file_path = d.get("file") or d.get("matched_file") or "unknown"
|
|
41
|
+
rule_id = d.get("rule_id") or d.get("id") or "unknown"
|
|
42
|
+
severity = (d.get("severity") or "unknown").lower()
|
|
43
|
+
impact = d.get("impact") or "unknown"
|
|
44
|
+
rationale = d.get("rationale") or ""
|
|
45
|
+
grouped.setdefault(file_path, []).append({
|
|
46
|
+
"rule_id": rule_id,
|
|
47
|
+
"severity": severity,
|
|
48
|
+
"impact": impact,
|
|
49
|
+
"rationale": rationale
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
# Print risky files to stderr for CI logs
|
|
53
|
+
import sys
|
|
54
|
+
sys.stderr.write("\n" + "="*40 + "\n")
|
|
55
|
+
sys.stderr.write("🔍 DiffSense Risk Files\n")
|
|
56
|
+
sys.stderr.write("="*40 + "\n")
|
|
57
|
+
for file_path in sorted(grouped.keys()):
|
|
58
|
+
if file_path != "unknown":
|
|
59
|
+
issues_count = len(grouped[file_path])
|
|
60
|
+
max_severity = min(grouped[file_path], key=lambda x: severity_rank.get(x["severity"], 4))["severity"]
|
|
61
|
+
sys.stderr.write(f" 📁 {file_path} ({issues_count} issue(s), severity: {max_severity.upper()})\n")
|
|
62
|
+
sys.stderr.write("="*40 + "\n\n")
|
|
63
|
+
|
|
64
|
+
lines.append("## ⚠️ Warnings by File")
|
|
65
|
+
for file_path in sorted(grouped.keys()):
|
|
66
|
+
lines.append("")
|
|
67
|
+
lines.append(f"### `{file_path}`")
|
|
68
|
+
for item in sorted(grouped[file_path], key=lambda x: severity_rank.get(x["severity"], 4)):
|
|
69
|
+
sev_label = item["severity"].upper() if item["severity"] else "UNKNOWN"
|
|
70
|
+
lines.append(f"- **{sev_label}** `{item['rule_id']}` ({item['impact']})")
|
|
71
|
+
if item["rationale"]:
|
|
72
|
+
lines.append(f" - {item['rationale']}")
|
|
73
|
+
|
|
74
|
+
if review_level in ["Elevated", "Critical"]:
|
|
75
|
+
lines.append("")
|
|
76
|
+
lines.append("---")
|
|
77
|
+
lines.append("**Required action:**")
|
|
78
|
+
lines.append("This is a risk signal, not a block.")
|
|
79
|
+
lines.append("")
|
|
80
|
+
lines.append("👉 **Approve this PR** OR **React with 👍** to this comment, then **Re-run this job** to pass.")
|
|
81
|
+
|
|
82
|
+
rule_stats = (result.get("_metrics") or {}).get("rule_stats", {})
|
|
83
|
+
total_rules = rule_stats.get("total_rules", 0)
|
|
84
|
+
executed_count = rule_stats.get("executed_count", 0)
|
|
85
|
+
if total_rules or executed_count:
|
|
86
|
+
lines.append("")
|
|
87
|
+
lines.append(f"Rules executed: {executed_count} / {total_rules}")
|
|
88
|
+
return "\n".join(lines)
|
|
89
|
+
|
|
90
|
+
class HtmlRenderer:
|
|
91
|
+
def render(self, result: Dict[str, Any]) -> str:
|
|
92
|
+
review_level = result.get("review_level", "unknown")
|
|
93
|
+
details = result.get("details", [])
|
|
94
|
+
metrics = result.get("_metrics", {})
|
|
95
|
+
rule_metrics = result.get("_metrics", {})
|
|
96
|
+
rule_quality = result.get("_rule_quality", {})
|
|
97
|
+
|
|
98
|
+
def esc(value: Any) -> str:
|
|
99
|
+
return _html.escape(str(value))
|
|
100
|
+
|
|
101
|
+
rows = []
|
|
102
|
+
for d in details:
|
|
103
|
+
rows.append(
|
|
104
|
+
"<tr>"
|
|
105
|
+
f"<td>{esc(d.get('rule_id', ''))}</td>"
|
|
106
|
+
f"<td>{esc(d.get('severity', ''))}</td>"
|
|
107
|
+
f"<td>{esc(d.get('file', ''))}</td>"
|
|
108
|
+
f"<td>{esc(d.get('impact', ''))}</td>"
|
|
109
|
+
f"<td>{esc(d.get('rationale', ''))}</td>"
|
|
110
|
+
f"<td>{esc(d.get('precision', ''))}</td>"
|
|
111
|
+
f"<td>{esc(d.get('quality_status', ''))}</td>"
|
|
112
|
+
"</tr>"
|
|
113
|
+
)
|
|
114
|
+
detail_table = "\n".join(rows) if rows else "<tr><td colspan='7'>No warnings detected.</td></tr>"
|
|
115
|
+
|
|
116
|
+
cache = metrics.get("cache", {})
|
|
117
|
+
diff_cache = cache.get("diff", {})
|
|
118
|
+
ast_cache = cache.get("ast", {})
|
|
119
|
+
d_total = diff_cache.get("hits", 0) + diff_cache.get("misses", 0)
|
|
120
|
+
a_total = ast_cache.get("hits", 0) + ast_cache.get("misses", 0)
|
|
121
|
+
d_rate = (diff_cache.get("hits", 0) / d_total * 100) if d_total else 0
|
|
122
|
+
a_rate = (ast_cache.get("hits", 0) / a_total * 100) if a_total else 0
|
|
123
|
+
rule_stats = metrics.get("rule_stats", {})
|
|
124
|
+
total_rules = rule_stats.get("total_rules", 0)
|
|
125
|
+
executed_count = rule_stats.get("executed_count", 0)
|
|
126
|
+
exec_pct = (executed_count / total_rules * 100) if total_rules else 0
|
|
127
|
+
rules_executed_line = f"<div>Rules executed: {executed_count} / {total_rules} ({exec_pct:.0f}%)</div>"
|
|
128
|
+
|
|
129
|
+
rule_rows = []
|
|
130
|
+
for rule_id, m in rule_metrics.items():
|
|
131
|
+
if rule_id in ("cache", "rule_stats"):
|
|
132
|
+
continue
|
|
133
|
+
time_ms = (m.get("time_ns", 0) / 1_000_000) if isinstance(m, dict) else 0
|
|
134
|
+
hits = m.get("hits", 0) if isinstance(m, dict) else 0
|
|
135
|
+
ignores = m.get("ignores", 0) if isinstance(m, dict) else 0
|
|
136
|
+
errors = m.get("errors", 0) if isinstance(m, dict) else 0
|
|
137
|
+
precision = ""
|
|
138
|
+
q = rule_quality.get(rule_id)
|
|
139
|
+
if isinstance(q, dict):
|
|
140
|
+
precision = q.get("precision", "")
|
|
141
|
+
rule_rows.append(
|
|
142
|
+
"<tr>"
|
|
143
|
+
f"<td>{esc(rule_id)}</td>"
|
|
144
|
+
f"<td>{esc(hits)}</td>"
|
|
145
|
+
f"<td>{esc(ignores)}</td>"
|
|
146
|
+
f"<td>{esc(errors)}</td>"
|
|
147
|
+
f"<td>{esc(time_ms)}</td>"
|
|
148
|
+
f"<td>{esc(precision)}</td>"
|
|
149
|
+
"</tr>"
|
|
150
|
+
)
|
|
151
|
+
rule_table = "\n".join(rule_rows) if rule_rows else "<tr><td colspan='6'>No rule metrics.</td></tr>"
|
|
152
|
+
|
|
153
|
+
return f"""<!DOCTYPE html>
|
|
154
|
+
<html>
|
|
155
|
+
<head>
|
|
156
|
+
<meta charset="utf-8">
|
|
157
|
+
<title>DiffSense Report</title>
|
|
158
|
+
<style>
|
|
159
|
+
body{{font-family:Arial,sans-serif;margin:20px}}
|
|
160
|
+
table{{border-collapse:collapse;width:100%}}
|
|
161
|
+
th,td{{border:1px solid #ddd;padding:8px;text-align:left}}
|
|
162
|
+
th{{background:#f4f4f4}}
|
|
163
|
+
.summary{{margin-bottom:16px}}
|
|
164
|
+
</style>
|
|
165
|
+
</head>
|
|
166
|
+
<body>
|
|
167
|
+
<h1>DiffSense Report</h1>
|
|
168
|
+
<div class="summary">
|
|
169
|
+
<div>Review Level: {esc(review_level)}</div>
|
|
170
|
+
<div>Diff Cache Hit: {d_rate:.1f}% ({diff_cache.get("hits", 0)}/{d_total})</div>
|
|
171
|
+
<div>AST Cache Hit: {a_rate:.1f}% ({ast_cache.get("hits", 0)}/{a_total})</div>
|
|
172
|
+
{rules_executed_line}
|
|
173
|
+
</div>
|
|
174
|
+
<h2>Findings</h2>
|
|
175
|
+
<table>
|
|
176
|
+
<thead>
|
|
177
|
+
<tr>
|
|
178
|
+
<th>Rule</th><th>Severity</th><th>File</th><th>Impact</th><th>Rationale</th><th>Precision</th><th>Quality</th>
|
|
179
|
+
</tr>
|
|
180
|
+
</thead>
|
|
181
|
+
<tbody>
|
|
182
|
+
{detail_table}
|
|
183
|
+
</tbody>
|
|
184
|
+
</table>
|
|
185
|
+
<h2>Rule Metrics</h2>
|
|
186
|
+
<table>
|
|
187
|
+
<thead>
|
|
188
|
+
<tr>
|
|
189
|
+
<th>Rule</th><th>Hits</th><th>Ignores</th><th>Errors</th><th>Time(ms)</th><th>Precision</th>
|
|
190
|
+
</tr>
|
|
191
|
+
</thead>
|
|
192
|
+
<tbody>
|
|
193
|
+
{rule_table}
|
|
194
|
+
</tbody>
|
|
195
|
+
</table>
|
|
196
|
+
</body>
|
|
197
|
+
</html>"""
|
core/rule_base.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Dict, Any, Optional, List
|
|
3
|
+
|
|
4
|
+
class Rule(ABC):
|
|
5
|
+
"""
|
|
6
|
+
Abstract Base Class for all DiffSense Rules.
|
|
7
|
+
This defines the Plugin Interface (SDK).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
@property
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def id(self) -> str:
|
|
13
|
+
"""Unique Rule ID (e.g., 'runtime.concurrency.lock_removed')"""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def severity(self) -> str:
|
|
19
|
+
"""Severity level: critical, high, medium, low"""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def impact(self) -> str:
|
|
25
|
+
"""Impact dimension: security, runtime, data, maintenance"""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def rationale(self) -> str:
|
|
31
|
+
"""Explanation of why this rule exists and what risk it prevents"""
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def title(self) -> str:
|
|
36
|
+
"""Human-readable title for the rule (optional, defaults to rule id)"""
|
|
37
|
+
return self.id
|
|
38
|
+
|
|
39
|
+
# Optional metadata (defaults for built-in rules; YamlRule overrides from YAML)
|
|
40
|
+
@property
|
|
41
|
+
def category(self) -> str:
|
|
42
|
+
"""Rule category: concurrency, performance, reliability, security, general"""
|
|
43
|
+
return "general"
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def confidence(self) -> float:
|
|
47
|
+
"""Confidence score 0.0-1.0"""
|
|
48
|
+
return 1.0
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def tags(self) -> List[str]:
|
|
52
|
+
"""Optional tags for filtering"""
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def enabled(self) -> bool:
|
|
57
|
+
"""Whether this rule is enabled (engine skips if False)"""
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def language(self) -> str:
|
|
62
|
+
"""Language scope: * for all, or java, go, js, etc."""
|
|
63
|
+
return "*"
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def scope(self) -> str:
|
|
67
|
+
"""File scope pattern (e.g. ** or **/core/**)"""
|
|
68
|
+
return "**"
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def rule_type(self) -> str:
|
|
72
|
+
"""Rule type: regression (depends on diff history) or absolute (context-independent)"""
|
|
73
|
+
return "absolute"
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def is_blocking(self) -> bool:
|
|
77
|
+
"""If True, any hit will force a 'critical' review level and suggested 'block_pr' action"""
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def status(self) -> str:
|
|
82
|
+
"""Lifecycle status: experimental, beta, stable, deprecated, disabled. Engine skips disabled."""
|
|
83
|
+
return "stable"
|
|
84
|
+
|
|
85
|
+
@abstractmethod
|
|
86
|
+
def evaluate(self, diff_data: Dict[str, Any], ast_signals: List[Any]) -> Optional[Dict[str, Any]]:
|
|
87
|
+
"""
|
|
88
|
+
Execute the rule logic against the diff and signals.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
diff_data: The raw diff parsing result
|
|
92
|
+
ast_signals: List of AST signals detected by ASTDetector
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Dict with match details (must contain 'file' key) if matched,
|
|
96
|
+
None if not matched.
|
|
97
|
+
"""
|
|
98
|
+
pass
|
core/rule_runtime.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Legacy rule runtime. Prefer core.rules.RuleEngine as the single entry point;
|
|
3
|
+
RuleEngine now integrates LifecycleManager, profile, directory/entry_point loading.
|
|
4
|
+
"""
|
|
5
|
+
import yaml
|
|
6
|
+
import time
|
|
7
|
+
from typing import Dict, List, Any, Optional
|
|
8
|
+
from sdk.rule import BaseRule
|
|
9
|
+
from sdk.signal import Signal
|
|
10
|
+
from governance.lifecycle import LifecycleManager
|
|
11
|
+
from rules.concurrency import (
|
|
12
|
+
ThreadPoolSemanticChangeRule,
|
|
13
|
+
ConcurrencyRegressionRule,
|
|
14
|
+
ThreadSafetyRemovalRule,
|
|
15
|
+
LatchMisuseRule
|
|
16
|
+
)
|
|
17
|
+
from rules.yaml_adapter import YamlRule
|
|
18
|
+
|
|
19
|
+
class RuleRuntime:
|
|
20
|
+
"""
|
|
21
|
+
The orchestrator for executing rules.
|
|
22
|
+
Handles Lifecycle, Metrics, Suppression, and Feedback.
|
|
23
|
+
"""
|
|
24
|
+
def __init__(self, rules_path: str, config: Dict[str, Any] = None):
|
|
25
|
+
self.rules: List[BaseRule] = []
|
|
26
|
+
self.metrics: Dict[str, Dict[str, Any]] = {}
|
|
27
|
+
self.lifecycle = LifecycleManager(config)
|
|
28
|
+
|
|
29
|
+
# 1. Register Built-in Rules (Plugins)
|
|
30
|
+
self._register_builtins()
|
|
31
|
+
|
|
32
|
+
# 2. Load YAML Rules (Plugins)
|
|
33
|
+
self._load_yaml_rules(rules_path)
|
|
34
|
+
|
|
35
|
+
def _register_builtins(self):
|
|
36
|
+
"""
|
|
37
|
+
Registers core rules. In a real OS, this would be a dynamic plugin loader.
|
|
38
|
+
"""
|
|
39
|
+
self.rules.append(ThreadPoolSemanticChangeRule())
|
|
40
|
+
self.rules.append(ConcurrencyRegressionRule())
|
|
41
|
+
self.rules.append(ThreadSafetyRemovalRule())
|
|
42
|
+
self.rules.append(LatchMisuseRule())
|
|
43
|
+
|
|
44
|
+
def _load_yaml_rules(self, path: str):
|
|
45
|
+
try:
|
|
46
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
47
|
+
data = yaml.safe_load(f) or {}
|
|
48
|
+
raw_rules = data.get('rules', [])
|
|
49
|
+
for r in raw_rules:
|
|
50
|
+
self.rules.append(YamlRule(r))
|
|
51
|
+
except FileNotFoundError:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
def execute(self, diff_data: Dict[str, Any], signals: List[Signal]) -> List[Dict[str, Any]]:
|
|
55
|
+
"""
|
|
56
|
+
Main execution pipeline.
|
|
57
|
+
"""
|
|
58
|
+
findings = []
|
|
59
|
+
|
|
60
|
+
for rule in self.rules:
|
|
61
|
+
# 1. Lifecycle Check
|
|
62
|
+
if not self.lifecycle.should_run(rule):
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
rule_id = rule.id
|
|
66
|
+
if rule_id not in self.metrics:
|
|
67
|
+
self.metrics[rule_id] = {"calls": 0, "hits": 0, "time_ns": 0, "errors": 0}
|
|
68
|
+
|
|
69
|
+
self.metrics[rule_id]["calls"] += 1
|
|
70
|
+
|
|
71
|
+
start_time = time.time_ns()
|
|
72
|
+
match_details = None
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
# 2. Execute Rule
|
|
76
|
+
match_details = rule.evaluate(diff_data, signals)
|
|
77
|
+
except Exception:
|
|
78
|
+
self.metrics[rule_id]["errors"] += 1
|
|
79
|
+
finally:
|
|
80
|
+
duration = time.time_ns() - start_time
|
|
81
|
+
self.metrics[rule_id]["time_ns"] += duration
|
|
82
|
+
|
|
83
|
+
if match_details:
|
|
84
|
+
# 3. Suppress Check (TODO)
|
|
85
|
+
# if self.suppress.is_suppressed(rule_id, match_details): continue
|
|
86
|
+
|
|
87
|
+
self.metrics[rule_id]["hits"] += 1
|
|
88
|
+
|
|
89
|
+
# 4. Severity Adjustment
|
|
90
|
+
severity = self.lifecycle.adjust_severity(rule, rule.severity)
|
|
91
|
+
|
|
92
|
+
findings.append({
|
|
93
|
+
"id": rule.id,
|
|
94
|
+
"severity": severity,
|
|
95
|
+
"impact": rule.impact,
|
|
96
|
+
"rationale": rule.rationale,
|
|
97
|
+
"matched_file": match_details.get('file', 'unknown')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
return findings
|
|
101
|
+
|
|
102
|
+
def get_metrics(self) -> Dict[str, Any]:
|
|
103
|
+
return self.metrics
|