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.
Files changed (58) hide show
  1. adapters/__init__.py +0 -0
  2. adapters/base.py +27 -0
  3. adapters/github_adapter.py +164 -0
  4. adapters/gitlab_adapter.py +207 -0
  5. adapters/local_adapter.py +136 -0
  6. banner.py +71 -0
  7. cli.py +606 -0
  8. config/__init__.py +1 -0
  9. config/rules.yaml +371 -0
  10. core/__init__.py +235 -0
  11. core/ast_detector.py +853 -0
  12. core/change.py +46 -0
  13. core/composer.py +93 -0
  14. core/evaluator.py +15 -0
  15. core/ignore_manager.py +71 -0
  16. core/knowledge.py +77 -0
  17. core/parser.py +181 -0
  18. core/parser_manager.py +104 -0
  19. core/quality_manager.py +117 -0
  20. core/renderer.py +197 -0
  21. core/rule_base.py +98 -0
  22. core/rule_runtime.py +103 -0
  23. core/rules.py +718 -0
  24. core/run_config.py +85 -0
  25. core/semantic_diff.py +359 -0
  26. core/signal_model.py +21 -0
  27. core/signals_registry.py +62 -0
  28. diffsense-2.2.12.dist-info/METADATA +18 -0
  29. diffsense-2.2.12.dist-info/RECORD +58 -0
  30. diffsense-2.2.12.dist-info/WHEEL +5 -0
  31. diffsense-2.2.12.dist-info/entry_points.txt +3 -0
  32. diffsense-2.2.12.dist-info/licenses/LICENSE +176 -0
  33. diffsense-2.2.12.dist-info/top_level.txt +11 -0
  34. diffsense_mcp/__init__.py +1 -0
  35. diffsense_mcp/launcher.py +28 -0
  36. diffsense_mcp/server.py +687 -0
  37. governance/lifecycle.py +54 -0
  38. main.py +318 -0
  39. rules/__init__.py +246 -0
  40. rules/api_compatibility.py +372 -0
  41. rules/collection_handling.py +349 -0
  42. rules/concurrency.py +194 -0
  43. rules/concurrency_adapter.py +250 -0
  44. rules/cross_language_adapter.py +444 -0
  45. rules/exception_handling.py +320 -0
  46. rules/go_rules.py +401 -0
  47. rules/null_safety.py +301 -0
  48. rules/resource_management.py +222 -0
  49. rules/yaml_adapter.py +195 -0
  50. run_audit.py +478 -0
  51. sdk/cpp_adapter.py +238 -0
  52. sdk/go_adapter.py +199 -0
  53. sdk/java_adapter.py +199 -0
  54. sdk/javascript_adapter.py +229 -0
  55. sdk/language_adapter.py +313 -0
  56. sdk/python_adapter.py +195 -0
  57. sdk/rule.py +63 -0
  58. 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()