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
run_audit.py
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from adapters.github_adapter import GitHubAdapter
|
|
5
|
+
from adapters.gitlab_adapter import GitLabAdapter
|
|
6
|
+
from core.parser import DiffParser
|
|
7
|
+
from core.ast_detector import ASTDetector
|
|
8
|
+
from core.rules import RuleEngine
|
|
9
|
+
from core.evaluator import ImpactEvaluator
|
|
10
|
+
from core.composer import DecisionComposer
|
|
11
|
+
from core.renderer import MarkdownRenderer, HtmlRenderer
|
|
12
|
+
from banner import print_banner
|
|
13
|
+
from main import _load_baseline, _save_baseline, _baseline_items, _baseline_set, _baseline_key, _build_inline_comments, _write_json
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run_audit(adapter, rules_path, profile=None, pro_rules_path=None, baseline=False, since_baseline=False, baseline_file=".diffsense-baseline.json", report_json="diffsense-report.json", report_html="diffsense-report.html", comments_json="diffsense-comments.json", quality_auto_tune=False, quality_disable_threshold=0.3, quality_downgrade_threshold=0.5, quality_min_samples=30, experimental=False, experimental_report_only=True):
|
|
17
|
+
print_banner()
|
|
18
|
+
|
|
19
|
+
# Print platform and configuration info
|
|
20
|
+
print(f"{'='*60}")
|
|
21
|
+
print("🚀 DIFFSENSE AUDIT STARTING")
|
|
22
|
+
print(f"{'='*60}")
|
|
23
|
+
print(f"📋 Platform: {type(adapter).__name__}")
|
|
24
|
+
print(f"📋 Profile: {profile or 'default'}")
|
|
25
|
+
print(f"📋 Rules path: {rules_path}")
|
|
26
|
+
if pro_rules_path:
|
|
27
|
+
print(f"📋 Pro rules: {pro_rules_path}")
|
|
28
|
+
print(f"{'='*60}\n")
|
|
29
|
+
|
|
30
|
+
print("Fetching diff...")
|
|
31
|
+
|
|
32
|
+
# Try to fetch diff with error handling
|
|
33
|
+
try:
|
|
34
|
+
diff_content = adapter.fetch_diff()
|
|
35
|
+
except Exception as e:
|
|
36
|
+
print(f"❌ ERROR: Failed to fetch diff: {e}")
|
|
37
|
+
import traceback
|
|
38
|
+
traceback.print_exc()
|
|
39
|
+
# Save error to report
|
|
40
|
+
error_report = {
|
|
41
|
+
"error": str(e),
|
|
42
|
+
"review_level": "error",
|
|
43
|
+
"details": [],
|
|
44
|
+
"_metrics": {"fetch_error": str(e)}
|
|
45
|
+
}
|
|
46
|
+
_write_json(report_json, error_report)
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
print(f"\n{'='*60}")
|
|
50
|
+
print("📊 DIFF FETCH SUMMARY")
|
|
51
|
+
print(f"{'='*60}")
|
|
52
|
+
print(f"✅ Diff fetched successfully")
|
|
53
|
+
print(f"📏 Length: {len(diff_content)} characters")
|
|
54
|
+
print(f"📏 Lines: {len(diff_content.splitlines())}")
|
|
55
|
+
|
|
56
|
+
# Validate diff format
|
|
57
|
+
diff_lines = diff_content.splitlines()
|
|
58
|
+
has_git_diff = any(line.startswith("diff --git") for line in diff_lines)
|
|
59
|
+
has_plus = any(line.startswith("+") and not line.startswith("+++") for line in diff_lines)
|
|
60
|
+
has_minus = any(line.startswith("-") and not line.startswith("---") for line in diff_lines)
|
|
61
|
+
|
|
62
|
+
print(f"\n📋 Diff validation:")
|
|
63
|
+
print(f" - Has 'diff --git' headers: {has_git_diff}")
|
|
64
|
+
print(f" - Has additions (+): {has_plus}")
|
|
65
|
+
print(f" - Has deletions (-): {has_minus}")
|
|
66
|
+
|
|
67
|
+
if not has_git_diff:
|
|
68
|
+
print("\n⚠️ WARNING: Diff doesn't contain expected 'diff --git' headers!")
|
|
69
|
+
print("First 1000 chars preview:")
|
|
70
|
+
print(diff_content[:1000])
|
|
71
|
+
|
|
72
|
+
if len(diff_content) < 500:
|
|
73
|
+
print(f"\n📄 Full diff content:\n{diff_content}")
|
|
74
|
+
else:
|
|
75
|
+
print(f"\n📄 Diff preview (first 500 chars):\n{diff_content[:500]}...")
|
|
76
|
+
|
|
77
|
+
print(f"{'='*60}\n")
|
|
78
|
+
|
|
79
|
+
if not diff_content.strip():
|
|
80
|
+
print("⚠️ Diff is empty, skipping audit.")
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
print("Running Core Analyzer...")
|
|
84
|
+
|
|
85
|
+
# 1. Parse Diff (Structural)
|
|
86
|
+
print("\n🔍 Step 1: Parsing diff...")
|
|
87
|
+
parser = DiffParser()
|
|
88
|
+
diff_data = parser.parse(diff_content)
|
|
89
|
+
|
|
90
|
+
print(f"\n{'='*60}")
|
|
91
|
+
print("📊 DIFF PARSING RESULTS")
|
|
92
|
+
print(f"{'='*60}")
|
|
93
|
+
print(f"✅ Parsed {len(diff_data.get('files', []))} files")
|
|
94
|
+
if diff_data.get('files'):
|
|
95
|
+
print(f"\n📁 Files:")
|
|
96
|
+
for f in diff_data.get('files', [])[:20]: # Show first 20
|
|
97
|
+
print(f" - {f}")
|
|
98
|
+
if len(diff_data.get('files', [])) > 20:
|
|
99
|
+
print(f" ... and {len(diff_data.get('files', [])) - 20} more")
|
|
100
|
+
|
|
101
|
+
print(f"\n📊 Stats:")
|
|
102
|
+
print(f" - Additions: {diff_data.get('stats', {}).get('add', 0)}")
|
|
103
|
+
print(f" - Deletions: {diff_data.get('stats', {}).get('del', 0)}")
|
|
104
|
+
print(f" - New files: {len(diff_data.get('new_files', []))}")
|
|
105
|
+
print(f" - Change types: {diff_data.get('change_types', [])}")
|
|
106
|
+
print(f" - File patches: {len(diff_data.get('file_patches', []))}")
|
|
107
|
+
print(f"{'='*60}\n")
|
|
108
|
+
|
|
109
|
+
# 2. Detect AST Signals (Semantic)
|
|
110
|
+
# This is the "First-Class Signal Source"
|
|
111
|
+
print("\n🔍 Step 2: Detecting AST Signals...")
|
|
112
|
+
ast_detector = ASTDetector()
|
|
113
|
+
ast_signals = ast_detector.detect_signals(diff_data)
|
|
114
|
+
|
|
115
|
+
print(f"\n{'='*60}")
|
|
116
|
+
print("📊 AST SIGNALS DETECTED")
|
|
117
|
+
print(f"{'='*60}")
|
|
118
|
+
print(f"✅ Found {len(ast_signals)} AST signals")
|
|
119
|
+
if ast_signals:
|
|
120
|
+
print(f"\n📋 Signals by file:")
|
|
121
|
+
signals_by_file = {}
|
|
122
|
+
for sig in ast_signals:
|
|
123
|
+
if sig.file not in signals_by_file:
|
|
124
|
+
signals_by_file[sig.file] = []
|
|
125
|
+
signals_by_file[sig.file].append(sig.id)
|
|
126
|
+
|
|
127
|
+
for file, signal_ids in signals_by_file.items():
|
|
128
|
+
print(f" 📁 {file}:")
|
|
129
|
+
for sid in signal_ids:
|
|
130
|
+
print(f" - {sid}")
|
|
131
|
+
print(f"{'='*60}\n")
|
|
132
|
+
|
|
133
|
+
# 3. Evaluate Rules (Policy / Context)
|
|
134
|
+
# Rules now consume both diff_data (structure) and ast_signals (semantics)
|
|
135
|
+
print("\n🔍 Step 3: Loading rules and evaluating impacts...")
|
|
136
|
+
quality_config = {
|
|
137
|
+
"auto_tune": quality_auto_tune,
|
|
138
|
+
"disable_threshold": quality_disable_threshold,
|
|
139
|
+
"degrade_threshold": quality_downgrade_threshold,
|
|
140
|
+
"min_samples": quality_min_samples
|
|
141
|
+
}
|
|
142
|
+
# 正式运行:若未显式传入 pro_rules_path,则从配置/环境/默认解析,使超级规则可被加载
|
|
143
|
+
if pro_rules_path is None:
|
|
144
|
+
try:
|
|
145
|
+
from core.run_config import get_pro_rules_path
|
|
146
|
+
pro_rules_path = get_pro_rules_path(os.getcwd())
|
|
147
|
+
except Exception:
|
|
148
|
+
pro_rules_path = None
|
|
149
|
+
run_cfg = {}
|
|
150
|
+
try:
|
|
151
|
+
from core.run_config import get_run_config
|
|
152
|
+
run_cfg = get_run_config(os.getcwd())
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
engine_config = {
|
|
156
|
+
"rule_quality": quality_config,
|
|
157
|
+
"experimental": {"enabled": experimental, "report_only": experimental_report_only},
|
|
158
|
+
}
|
|
159
|
+
if run_cfg.get("dependency_versions"):
|
|
160
|
+
engine_config["dependency_versions"] = run_cfg["dependency_versions"]
|
|
161
|
+
|
|
162
|
+
print(f"\n📋 Rule configuration:")
|
|
163
|
+
print(f" - Rules path: {rules_path}")
|
|
164
|
+
print(f" - Profile: {profile or 'default'}")
|
|
165
|
+
print(f" - Pro rules path: {pro_rules_path or 'not specified'}")
|
|
166
|
+
print(f" - Quality auto-tune: {quality_auto_tune}")
|
|
167
|
+
|
|
168
|
+
engine = RuleEngine(
|
|
169
|
+
rules_path,
|
|
170
|
+
profile=profile,
|
|
171
|
+
config=engine_config,
|
|
172
|
+
pro_rules_path=pro_rules_path,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Get rule stats before evaluation
|
|
176
|
+
rule_stats = engine.get_rule_stats()
|
|
177
|
+
print(f"\n📊 Rules loaded:")
|
|
178
|
+
print(f" - Total rules: {rule_stats.get('total_rules', 'N/A')}")
|
|
179
|
+
print(f" - Enabled rules: {rule_stats.get('enabled_rules', 'N/A')}")
|
|
180
|
+
print(f" - Rule profiles: {rule_stats.get('profiles', 'N/A')}")
|
|
181
|
+
|
|
182
|
+
evaluator = ImpactEvaluator(engine)
|
|
183
|
+
|
|
184
|
+
print("\n⚡ Evaluating rules against diff and AST signals...")
|
|
185
|
+
impacts = evaluator.evaluate(diff_data, ast_signals=ast_signals)
|
|
186
|
+
|
|
187
|
+
print(f"\n{'='*60}")
|
|
188
|
+
print("📊 RULE EVALUATION RESULTS")
|
|
189
|
+
print(f"{'='*60}")
|
|
190
|
+
print(f"🎯 Total impacts found: {len(impacts)}")
|
|
191
|
+
|
|
192
|
+
if impacts:
|
|
193
|
+
# Group by file
|
|
194
|
+
print(f"\n📁 Files with triggered rules:")
|
|
195
|
+
severity_rank = {"critical": 0, "high": 1, "medium": 2, "low": 3, "unknown": 4}
|
|
196
|
+
files_with_issues = {}
|
|
197
|
+
for r in impacts:
|
|
198
|
+
file_path = r.get("matched_file", "unknown")
|
|
199
|
+
if file_path not in files_with_issues:
|
|
200
|
+
files_with_issues[file_path] = []
|
|
201
|
+
files_with_issues[file_path].append(r)
|
|
202
|
+
|
|
203
|
+
for file_path in sorted(files_with_issues.keys(), key=lambda x: (x == "unknown", x)):
|
|
204
|
+
issues = files_with_issues[file_path]
|
|
205
|
+
issues_count = len(issues)
|
|
206
|
+
max_severity = min(issues, key=lambda x: severity_rank.get(x.get("severity", "unknown"), 4))["severity"]
|
|
207
|
+
print(f"\n 📁 {file_path}")
|
|
208
|
+
print(f" Issues: {issues_count} | Max severity: {max_severity.upper()}")
|
|
209
|
+
for issue in sorted(issues, key=lambda x: severity_rank.get(x.get("severity", "unknown"), 4)):
|
|
210
|
+
print(f" - [{issue.get('severity', 'unknown').upper()}] {issue.get('id', 'N/A')}: {issue.get('title', 'N/A')[:60]}")
|
|
211
|
+
|
|
212
|
+
# Group by rule
|
|
213
|
+
print(f"\n🎯 Triggered rules summary:")
|
|
214
|
+
rules_triggered = {}
|
|
215
|
+
for r in impacts:
|
|
216
|
+
rule_id = r.get("id", "unknown")
|
|
217
|
+
if rule_id not in rules_triggered:
|
|
218
|
+
rules_triggered[rule_id] = {"count": 0, "files": [], "severity": r.get("severity", "unknown")}
|
|
219
|
+
rules_triggered[rule_id]["count"] += 1
|
|
220
|
+
rules_triggered[rule_id]["files"].append(r.get("matched_file", "unknown"))
|
|
221
|
+
|
|
222
|
+
for rule_id in sorted(rules_triggered.keys(), key=lambda x: severity_rank.get(rules_triggered[x]["severity"], 4)):
|
|
223
|
+
rule_info = rules_triggered[rule_id]
|
|
224
|
+
print(f"\n 🎯 {rule_id} [{rule_info['severity'].upper()}]")
|
|
225
|
+
print(f" Triggered: {rule_info['count']} times")
|
|
226
|
+
print(f" Files: {', '.join(set(rule_info['files']))[:100]}")
|
|
227
|
+
else:
|
|
228
|
+
print("\n⚠️ No rules were triggered!")
|
|
229
|
+
print("Possible reasons:")
|
|
230
|
+
print(" 1. No files matched rule patterns")
|
|
231
|
+
print(" 2. No AST signals detected")
|
|
232
|
+
print(" 3. Rules are disabled or filtered out")
|
|
233
|
+
print(" 4. Diff doesn't contain risky changes")
|
|
234
|
+
|
|
235
|
+
print(f"\n{'='*60}\n")
|
|
236
|
+
|
|
237
|
+
if baseline:
|
|
238
|
+
_save_baseline(baseline_file, _baseline_items(impacts))
|
|
239
|
+
if since_baseline:
|
|
240
|
+
baseline_data = _load_baseline(baseline_file)
|
|
241
|
+
baseline_keys = _baseline_set(baseline_data)
|
|
242
|
+
impacts = [r for r in impacts if _baseline_key(r) not in baseline_keys]
|
|
243
|
+
|
|
244
|
+
composer = DecisionComposer()
|
|
245
|
+
# Ensure composer result matches structure expected by renderer
|
|
246
|
+
# result keys: review_level, details
|
|
247
|
+
result_decision = composer.compose(impacts)
|
|
248
|
+
|
|
249
|
+
# Prepare Output for Renderer
|
|
250
|
+
# Renderer expects 'result' dict to have 'review_level' and 'details' directly?
|
|
251
|
+
# Or renderer.render takes the whole output_data?
|
|
252
|
+
# Looking at renderer.render:
|
|
253
|
+
# review_level = result.get("review_level", "unknown").capitalize()
|
|
254
|
+
# details = result.get("details", [])
|
|
255
|
+
|
|
256
|
+
# So we need to construct a dict that matches this structure.
|
|
257
|
+
# composer.compose returns decision dict.
|
|
258
|
+
|
|
259
|
+
render_input = {
|
|
260
|
+
"review_level": result_decision.get("review_level", "unknown"),
|
|
261
|
+
"details": impacts # Assuming impacts is a list of impact details
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
renderer = MarkdownRenderer()
|
|
265
|
+
report = renderer.render(render_input)
|
|
266
|
+
render_input["_metrics"] = dict(engine.get_metrics())
|
|
267
|
+
render_input["_metrics"]["cache"] = {"diff": parser.metrics, "ast": ast_detector.metrics}
|
|
268
|
+
render_input["_metrics"]["rule_stats"] = engine.get_rule_stats()
|
|
269
|
+
render_input["_rule_quality"] = engine.get_rule_quality_metrics()
|
|
270
|
+
render_input["_quality_warnings"] = engine.get_quality_warnings()
|
|
271
|
+
engine.persist_rule_quality()
|
|
272
|
+
_write_json(report_json, render_input)
|
|
273
|
+
html_report = HtmlRenderer().render(render_input)
|
|
274
|
+
with open(report_html, "w", encoding="utf-8") as f:
|
|
275
|
+
f.write(html_report)
|
|
276
|
+
inline_comments = _build_inline_comments(impacts, diff_data)
|
|
277
|
+
_write_json(comments_json, inline_comments)
|
|
278
|
+
|
|
279
|
+
# Print comprehensive summary to stderr for CI logs
|
|
280
|
+
import sys
|
|
281
|
+
sys.stderr.write("\n" + "="*80 + "\n")
|
|
282
|
+
sys.stderr.write("🔍 DIFFSENSE AUDIT COMPLETE\n")
|
|
283
|
+
sys.stderr.write("="*80 + "\n")
|
|
284
|
+
|
|
285
|
+
if impacts:
|
|
286
|
+
sys.stderr.write(f"\n📊 SUMMARY: {len(impacts)} issue(s) found in {len(files_with_issues)} file(s)\n\n")
|
|
287
|
+
|
|
288
|
+
severity_rank = {"critical": 0, "high": 1, "medium": 2, "low": 3, "unknown": 4}
|
|
289
|
+
|
|
290
|
+
# Detailed file breakdown
|
|
291
|
+
sys.stderr.write("📁 FILES WITH ISSUES:\n")
|
|
292
|
+
for file_path in sorted(files_with_issues.keys(), key=lambda x: (x == "unknown", x)):
|
|
293
|
+
if file_path != "unknown":
|
|
294
|
+
issues = files_with_issues[file_path]
|
|
295
|
+
issues_count = len(issues)
|
|
296
|
+
max_severity = min(issues, key=lambda x: severity_rank.get(x.get("severity", "unknown"), 4))["severity"]
|
|
297
|
+
sys.stderr.write(f"\n 📁 {file_path}\n")
|
|
298
|
+
sys.stderr.write(f" └─ {issues_count} issue(s), max severity: {max_severity.upper()}\n")
|
|
299
|
+
for idx, issue in enumerate(sorted(issues, key=lambda x: severity_rank.get(x.get("severity", "unknown"), 4)), 1):
|
|
300
|
+
sys.stderr.write(f" {idx}. [{issue.get('severity', 'unknown').upper()}] {issue.get('id', '')}\n")
|
|
301
|
+
sys.stderr.write(f" └─ {issue.get('title', 'N/A')[:70]}\n")
|
|
302
|
+
|
|
303
|
+
# Rule breakdown
|
|
304
|
+
sys.stderr.write("\n🎯 TRIGGERED RULES:\n")
|
|
305
|
+
rules_triggered = {}
|
|
306
|
+
for r in impacts:
|
|
307
|
+
rule_id = r.get("id", "unknown")
|
|
308
|
+
if rule_id not in rules_triggered:
|
|
309
|
+
rules_triggered[rule_id] = {"count": 0, "files": set(), "severity": r.get("severity", "unknown")}
|
|
310
|
+
rules_triggered[rule_id]["count"] += 1
|
|
311
|
+
rules_triggered[rule_id]["files"].add(r.get("matched_file", "unknown"))
|
|
312
|
+
|
|
313
|
+
for rule_id in sorted(rules_triggered.keys(), key=lambda x: severity_rank.get(rules_triggered[x]["severity"], 4)):
|
|
314
|
+
rule_info = rules_triggered[rule_id]
|
|
315
|
+
files_list = ", ".join(sorted(rule_info["files"]))
|
|
316
|
+
if len(files_list) > 80:
|
|
317
|
+
files_list = files_list[:77] + "..."
|
|
318
|
+
sys.stderr.write(f"\n 🎯 {rule_id} [{rule_info['severity'].upper()}]\n")
|
|
319
|
+
sys.stderr.write(f" └─ Triggered {rule_info['count']} time(s) in: {files_list}\n")
|
|
320
|
+
|
|
321
|
+
# Severity breakdown
|
|
322
|
+
sys.stderr.write("\n📊 SEVERITY BREAKDOWN:\n")
|
|
323
|
+
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "unknown": 0}
|
|
324
|
+
for r in impacts:
|
|
325
|
+
sev = r.get("severity", "unknown")
|
|
326
|
+
if sev in severity_counts:
|
|
327
|
+
severity_counts[sev] += 1
|
|
328
|
+
|
|
329
|
+
for sev in ["critical", "high", "medium", "low", "unknown"]:
|
|
330
|
+
if severity_counts[sev] > 0:
|
|
331
|
+
sys.stderr.write(f" └─ {sev.upper()}: {severity_counts[sev]}\n")
|
|
332
|
+
else:
|
|
333
|
+
sys.stderr.write("\n⚠️ NO ISSUES FOUND\n")
|
|
334
|
+
sys.stderr.write(" Possible reasons:\n")
|
|
335
|
+
sys.stderr.write(" - No files matched rule patterns\n")
|
|
336
|
+
sys.stderr.write(" - No AST signals detected\n")
|
|
337
|
+
sys.stderr.write(" - Rules are disabled or filtered out\n")
|
|
338
|
+
sys.stderr.write(" - Diff doesn't contain risky changes\n")
|
|
339
|
+
|
|
340
|
+
sys.stderr.write("\n" + "="*80 + "\n\n")
|
|
341
|
+
|
|
342
|
+
print("Posting comment...")
|
|
343
|
+
adapter.post_comment(report)
|
|
344
|
+
# GitLab只保留一条主评论,避免额外的Inline摘要评论造成噪音;
|
|
345
|
+
# GitHub仍可保留inline评论能力。
|
|
346
|
+
if hasattr(adapter, "post_inline_comments") and type(adapter).__name__ != "GitLabAdapter":
|
|
347
|
+
try:
|
|
348
|
+
adapter.post_inline_comments(inline_comments)
|
|
349
|
+
except Exception:
|
|
350
|
+
pass
|
|
351
|
+
|
|
352
|
+
# Enforcement Logic: Click-to-Ack (Approve-to-Ack)
|
|
353
|
+
# Only CRITICAL level blocks CI, HIGH and below only report in comments
|
|
354
|
+
review_level = result_decision.get("review_level", "normal")
|
|
355
|
+
if review_level == "critical":
|
|
356
|
+
print(f"Risk level: {review_level}. Checking for approval or acknowledgement...")
|
|
357
|
+
|
|
358
|
+
is_approved = adapter.is_approved()
|
|
359
|
+
has_reaction = False
|
|
360
|
+
|
|
361
|
+
# Check for reaction if adapter supports it
|
|
362
|
+
if hasattr(adapter, 'has_ack_reaction'):
|
|
363
|
+
has_reaction = adapter.has_ack_reaction()
|
|
364
|
+
|
|
365
|
+
if is_approved:
|
|
366
|
+
print("✅ PR is approved. Risk acknowledged. CI Passed.")
|
|
367
|
+
elif has_reaction:
|
|
368
|
+
print("✅ Risk acknowledged via reaction (👍). CI Passed.")
|
|
369
|
+
else:
|
|
370
|
+
print("🚨 Risk elevated. Waiting for Approval OR Reaction (👍) on the report comment.")
|
|
371
|
+
print("CI Failed to ensure awareness.")
|
|
372
|
+
sys.exit(1)
|
|
373
|
+
|
|
374
|
+
for w in render_input["_quality_warnings"]:
|
|
375
|
+
print(f"⚠️ Low quality rule: {w.get('rule_id')} precision {w.get('precision'):.2f} (hits {w.get('hits')})")
|
|
376
|
+
print("Audit finished successfully.")
|
|
377
|
+
|
|
378
|
+
def main():
|
|
379
|
+
parser = argparse.ArgumentParser(description="DiffSense Audit Runner")
|
|
380
|
+
parser.add_argument("--platform", choices=["github", "gitlab"], required=True, help="CI Platform")
|
|
381
|
+
parser.add_argument("--token", required=True, help="Platform Access Token")
|
|
382
|
+
|
|
383
|
+
# GitHub Specific
|
|
384
|
+
parser.add_argument("--repo", help="GitHub Repo Name (owner/repo)")
|
|
385
|
+
parser.add_argument("--pr", type=int, help="GitHub PR Number")
|
|
386
|
+
|
|
387
|
+
# GitLab Specific
|
|
388
|
+
parser.add_argument("--gitlab-url", default="https://gitlab.com", help="GitLab Instance URL")
|
|
389
|
+
parser.add_argument("--project-id", help="GitLab Project ID")
|
|
390
|
+
parser.add_argument("--mr-iid", type=int, help="GitLab Merge Request IID")
|
|
391
|
+
|
|
392
|
+
# Config
|
|
393
|
+
parser.add_argument("--rules", default="config", help="Path to rules: single YAML file or directory")
|
|
394
|
+
parser.add_argument("--profile", default=None, help="Profile: strict or lightweight")
|
|
395
|
+
parser.add_argument("--baseline", action="store_true", help="Generate baseline file for existing issues")
|
|
396
|
+
parser.add_argument("--since-baseline", action="store_true", help="Only report findings not in baseline")
|
|
397
|
+
parser.add_argument("--baseline-file", default=".diffsense-baseline.json", help="Baseline file path")
|
|
398
|
+
parser.add_argument("--report-json", default="diffsense-report.json", help="Report JSON output path")
|
|
399
|
+
parser.add_argument("--report-html", default="diffsense-report.html", help="Report HTML output path")
|
|
400
|
+
parser.add_argument("--comments-json", default="diffsense-comments.json", help="Inline comments JSON output path")
|
|
401
|
+
parser.add_argument("--quality-auto-tune", action="store_true", help="Enable quality auto tune (skip/downgrade)")
|
|
402
|
+
parser.add_argument("--quality-disable-threshold", type=float, default=0.3, help="Disable threshold")
|
|
403
|
+
parser.add_argument("--quality-downgrade-threshold", type=float, default=0.5, help="Downgrade threshold")
|
|
404
|
+
parser.add_argument("--quality-min-samples", type=int, default=30, help="Minimum samples before actions")
|
|
405
|
+
parser.add_argument("--experimental", action="store_true", help="Include experimental rules (report-only by default)")
|
|
406
|
+
parser.add_argument("--experimental-report-only", dest="experimental_report_only", action="store_true", default=True, help="Do not affect decision with experimental rules")
|
|
407
|
+
parser.add_argument("--experimental-affect-decision", dest="experimental_report_only", action="store_false", help="Allow experimental rules to affect decision")
|
|
408
|
+
|
|
409
|
+
args = parser.parse_args()
|
|
410
|
+
|
|
411
|
+
# Official recommended config from .diffsense.yaml (CLI overrides when provided)
|
|
412
|
+
try:
|
|
413
|
+
from core.run_config import get_run_config
|
|
414
|
+
run_cfg = get_run_config(os.getcwd())
|
|
415
|
+
if args.profile is None and run_cfg.get("profile"):
|
|
416
|
+
args.profile = run_cfg["profile"]
|
|
417
|
+
if not args.quality_auto_tune and run_cfg.get("auto_tune"):
|
|
418
|
+
args.quality_auto_tune = True
|
|
419
|
+
rq = run_cfg.get("rule_quality") or {}
|
|
420
|
+
if args.quality_downgrade_threshold == 0.5 and "degrade_threshold" in rq:
|
|
421
|
+
try:
|
|
422
|
+
args.quality_downgrade_threshold = float(rq["degrade_threshold"])
|
|
423
|
+
except (TypeError, ValueError):
|
|
424
|
+
pass
|
|
425
|
+
if args.quality_disable_threshold == 0.3 and "disable_threshold" in rq:
|
|
426
|
+
try:
|
|
427
|
+
args.quality_disable_threshold = float(rq["disable_threshold"])
|
|
428
|
+
except (TypeError, ValueError):
|
|
429
|
+
pass
|
|
430
|
+
if args.quality_min_samples == 30 and "min_samples" in rq:
|
|
431
|
+
try:
|
|
432
|
+
args.quality_min_samples = int(rq["min_samples"])
|
|
433
|
+
except (TypeError, ValueError):
|
|
434
|
+
pass
|
|
435
|
+
except Exception:
|
|
436
|
+
pass
|
|
437
|
+
|
|
438
|
+
adapter = None
|
|
439
|
+
|
|
440
|
+
if args.platform == "github":
|
|
441
|
+
if not args.repo or not args.pr:
|
|
442
|
+
print("Error: --repo and --pr are required for GitHub")
|
|
443
|
+
sys.exit(1)
|
|
444
|
+
adapter = GitHubAdapter(args.token, args.repo, args.pr)
|
|
445
|
+
|
|
446
|
+
elif args.platform == "gitlab":
|
|
447
|
+
if not args.project_id or not args.mr_iid:
|
|
448
|
+
print("Error: --project-id and --mr-iid are required for GitLab")
|
|
449
|
+
sys.exit(1)
|
|
450
|
+
adapter = GitLabAdapter(args.gitlab_url, args.token, args.project_id, args.mr_iid)
|
|
451
|
+
|
|
452
|
+
# Run
|
|
453
|
+
# Handle rules path absolute/relative
|
|
454
|
+
rules_path = args.rules
|
|
455
|
+
if not os.path.exists(rules_path):
|
|
456
|
+
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
457
|
+
rules_path = os.path.join(script_dir, args.rules)
|
|
458
|
+
|
|
459
|
+
run_audit(
|
|
460
|
+
adapter,
|
|
461
|
+
rules_path,
|
|
462
|
+
profile=args.profile,
|
|
463
|
+
baseline=args.baseline,
|
|
464
|
+
since_baseline=args.since_baseline,
|
|
465
|
+
baseline_file=args.baseline_file,
|
|
466
|
+
report_json=args.report_json,
|
|
467
|
+
report_html=args.report_html,
|
|
468
|
+
comments_json=args.comments_json,
|
|
469
|
+
quality_auto_tune=args.quality_auto_tune,
|
|
470
|
+
quality_disable_threshold=args.quality_disable_threshold,
|
|
471
|
+
quality_downgrade_threshold=args.quality_downgrade_threshold,
|
|
472
|
+
quality_min_samples=args.quality_min_samples,
|
|
473
|
+
experimental=args.experimental,
|
|
474
|
+
experimental_report_only=args.experimental_report_only,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
if __name__ == "__main__":
|
|
478
|
+
main()
|