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
cli.py ADDED
@@ -0,0 +1,606 @@
1
+ """
2
+ DiffSense 统一 CLI:diffsense audit | replay | rules list | signals
3
+ """
4
+ import os
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+
9
+ import typer
10
+
11
+ app = typer.Typer(help="DiffSense: MR/PR risk audit. Use 'diffsense audit' in CI.")
12
+
13
+
14
+ def _default_rules_path() -> str:
15
+ # 开发: 同目录下 config/;安装后: 使用 config 包位置
16
+ try:
17
+ import config as config_pkg
18
+ return str(Path(config_pkg.__file__).resolve().parent)
19
+ except Exception:
20
+ return str(Path(__file__).resolve().parent / "config")
21
+
22
+
23
+ @app.command()
24
+ def audit(
25
+ platform: str = typer.Option(..., "--platform", "-p", help="CI platform: github | gitlab"),
26
+ token: str = typer.Option(..., "--token", "-t", help="Platform API token", envvar="DIFFSENSE_TOKEN"),
27
+ repo: str = typer.Option(None, "--repo", help="GitHub: owner/repo"),
28
+ pr: int = typer.Option(None, "--pr", help="GitHub PR number"),
29
+ gitlab_url: str = typer.Option("https://gitlab.com", "--gitlab-url", help="GitLab instance URL"),
30
+ project_id: str = typer.Option(None, "--project-id", help="GitLab project ID"),
31
+ mr_iid: int = typer.Option(None, "--mr-iid", help="GitLab merge request IID"),
32
+ rules: str = typer.Option(None, "--rules", help="Path to rules: single YAML file or directory of YAML files"),
33
+ profile: str = typer.Option(None, "--profile", help="Profile: strict (all rules) or lightweight (critical only)"),
34
+ baseline: bool = typer.Option(False, "--baseline", help="Generate baseline file for existing issues"),
35
+ since_baseline: bool = typer.Option(False, "--since-baseline", help="Only report findings not in baseline"),
36
+ baseline_file: str = typer.Option(".diffsense-baseline.json", "--baseline-file", help="Baseline file path"),
37
+ report_json: str = typer.Option("diffsense-report.json", "--report-json", help="Report JSON output path"),
38
+ report_html: str = typer.Option("diffsense-report.html", "--report-html", help="Report HTML output path"),
39
+ comments_json: str = typer.Option("diffsense-comments.json", "--comments-json", help="Inline comments JSON output path"),
40
+ quality_auto_tune: bool = typer.Option(False, "--quality-auto-tune", help="[DEPRECATED] Enable quality auto tune (now only affects reporting)"),
41
+ quality_disable_threshold: float = typer.Option(0.3, "--quality-disable-threshold", help="Disable threshold for reporting"),
42
+ quality_downgrade_threshold: float = typer.Option(0.5, "--quality-downgrade-threshold", help="Downgrade threshold for reporting"),
43
+ quality_min_samples: int = typer.Option(30, "--quality-min-samples", help="Minimum samples before quality warnings"),
44
+ experimental: bool = typer.Option(False, "--experimental", help="Include experimental rules (report-only by default)"),
45
+ experimental_report_only: bool = typer.Option(True, "--experimental-report-only/--experimental-affect-decision", help="Do not affect decision with experimental rules"),
46
+ ) -> None:
47
+ """Run MR/PR risk audit (GitLab or GitHub). Use in CI with image: ghcr.io/xxx/diffsense:1.0."""
48
+ from adapters.github_adapter import GitHubAdapter
49
+ from adapters.gitlab_adapter import GitLabAdapter
50
+ from run_audit import run_audit as do_audit
51
+
52
+ rules_path = rules
53
+ if not rules_path or not os.path.exists(rules_path):
54
+ rules_path = _default_rules_path()
55
+
56
+ # Official recommended config from .diffsense.yaml (CLI overrides when provided)
57
+ try:
58
+ from core.run_config import get_run_config
59
+ run_cfg = get_run_config(os.getcwd())
60
+ if not profile and run_cfg.get("profile"):
61
+ profile = run_cfg["profile"]
62
+ if not quality_auto_tune and run_cfg.get("auto_tune"):
63
+ quality_auto_tune = True
64
+ except Exception:
65
+ pass
66
+
67
+ if platform == "github":
68
+ if not repo or pr is None:
69
+ typer.echo("Error: --repo and --pr are required for GitHub", err=True)
70
+ raise typer.Exit(1)
71
+ adapter = GitHubAdapter(token, repo, pr)
72
+ elif platform == "gitlab":
73
+ if not project_id or mr_iid is None:
74
+ typer.echo("Error: --project-id and --mr-iid are required for GitLab", err=True)
75
+ raise typer.Exit(1)
76
+ adapter = GitLabAdapter(gitlab_url, token, project_id, mr_iid)
77
+ else:
78
+ typer.echo(f"Error: platform must be github or gitlab, got {platform}", err=True)
79
+ raise typer.Exit(1)
80
+
81
+ do_audit(
82
+ adapter,
83
+ rules_path,
84
+ profile=profile,
85
+ baseline=baseline,
86
+ since_baseline=since_baseline,
87
+ baseline_file=baseline_file,
88
+ report_json=report_json,
89
+ report_html=report_html,
90
+ comments_json=comments_json,
91
+ quality_auto_tune=quality_auto_tune,
92
+ quality_disable_threshold=quality_disable_threshold,
93
+ quality_downgrade_threshold=quality_downgrade_threshold,
94
+ quality_min_samples=quality_min_samples,
95
+ experimental=experimental,
96
+ experimental_report_only=experimental_report_only,
97
+ )
98
+
99
+
100
+ @app.command()
101
+ def replay(
102
+ diff_file: str = typer.Argument(..., help="Path to .diff file"),
103
+ rules: str = typer.Option(None, "--rules", help="Path to rules: single YAML file or directory of YAML files"),
104
+ format: str = typer.Option("json", "--format", "-f", help="Output: json | markdown"),
105
+ profile: str = typer.Option(None, "--profile", help="Profile: strict or lightweight"),
106
+ baseline: bool = typer.Option(False, "--baseline", help="Generate baseline file for existing issues"),
107
+ since_baseline: bool = typer.Option(False, "--since-baseline", help="Only report findings not in baseline"),
108
+ baseline_file: str = typer.Option(".diffsense-baseline.json", "--baseline-file", help="Baseline file path"),
109
+ report_json: str = typer.Option("diffsense-report.json", "--report-json", help="Report JSON output path"),
110
+ report_html: str = typer.Option("diffsense-report.html", "--report-html", help="Report HTML output path"),
111
+ comments_json: str = typer.Option("diffsense-comments.json", "--comments-json", help="Inline comments JSON output path"),
112
+ quality_auto_tune: bool = typer.Option(False, "--quality-auto-tune", help="[DEPRECATED] Enable quality auto tune (now only affects reporting)"),
113
+ quality_disable_threshold: float = typer.Option(0.3, "--quality-disable-threshold", help="Disable threshold for reporting"),
114
+ quality_downgrade_threshold: float = typer.Option(0.5, "--quality-downgrade-threshold", help="Downgrade threshold for reporting"),
115
+ quality_min_samples: int = typer.Option(30, "--quality-min-samples", help="Minimum samples before quality warnings"),
116
+ experimental: bool = typer.Option(False, "--experimental", help="Include experimental rules (report-only by default)"),
117
+ experimental_report_only: bool = typer.Option(True, "--experimental-report-only/--experimental-affect-decision", help="Do not affect decision with experimental rules"),
118
+ ) -> None:
119
+ """Run audit on a local diff file (for replay/offline)."""
120
+ rules_path = rules
121
+ if not rules_path or not os.path.exists(rules_path):
122
+ rules_path = _default_rules_path()
123
+
124
+ # Official recommended config from .diffsense.yaml (CLI overrides when provided)
125
+ try:
126
+ from core.run_config import get_run_config
127
+ run_cfg = get_run_config(os.getcwd())
128
+ if not profile and run_cfg.get("profile"):
129
+ profile = run_cfg["profile"]
130
+ if not quality_auto_tune and run_cfg.get("auto_tune"):
131
+ quality_auto_tune = True
132
+ except Exception:
133
+ pass
134
+
135
+ args = ["diffsense", diff_file, "--rules", rules_path, "--format", format, "--baseline-file", baseline_file, "--report-json", report_json, "--report-html", report_html, "--comments-json", comments_json, "--quality-disable-threshold", str(quality_disable_threshold), "--quality-downgrade-threshold", str(quality_downgrade_threshold), "--quality-min-samples", str(quality_min_samples)]
136
+ if profile:
137
+ args.extend(["--profile", profile])
138
+ if baseline:
139
+ args.append("--baseline")
140
+ if since_baseline:
141
+ args.append("--since-baseline")
142
+ if quality_auto_tune:
143
+ args.append("--quality-auto-tune")
144
+ if experimental:
145
+ args.append("--experimental")
146
+ if not experimental_report_only:
147
+ args.append("--experimental-affect-decision")
148
+ sys.argv = args
149
+ from main import main as replay_main
150
+ replay_main()
151
+
152
+
153
+ rules_app = typer.Typer(help="Manage rules. Use 'rules report' for rule quality from replay JSON; use 'rules health' for persisted rule_metrics.json.")
154
+
155
+ @rules_app.command("list")
156
+ def rules_list(
157
+ rules_path: str = typer.Option(None, "--rules", help="Path to rules: single YAML file or directory of YAML files"),
158
+ profile: str = typer.Option(None, "--profile", help="Profile: strict or lightweight (list only rules active in that profile)"),
159
+ ) -> None:
160
+ """List loaded rule IDs (built-in + YAML)."""
161
+ from core.rules import RuleEngine
162
+
163
+ path = rules_path
164
+ if not path or not os.path.exists(path):
165
+ path = _default_rules_path()
166
+ pro_path = None
167
+ try:
168
+ from core.run_config import get_pro_rules_path
169
+ pro_path = get_pro_rules_path(os.getcwd())
170
+ except Exception:
171
+ pass
172
+ engine = RuleEngine(path, profile=profile, pro_rules_path=pro_path)
173
+ for r in engine.rules:
174
+ typer.echo(r.id)
175
+
176
+ @rules_app.command("packs")
177
+ def rules_packs() -> None:
178
+ from importlib.metadata import entry_points
179
+ eps = []
180
+ try:
181
+ eps = entry_points(group="diffsense.rules")
182
+ except TypeError:
183
+ eps = entry_points().get("diffsense.rules", [])
184
+ if not eps:
185
+ typer.echo("No rule packs installed.")
186
+ return
187
+ for ep in eps:
188
+ typer.echo(f"{ep.name} -> {ep.value}")
189
+
190
+
191
+ @rules_app.command("report")
192
+ def rules_report(
193
+ input_file: str = typer.Option(None, "--input", "-i", help="JSON file from replay (must contain _metrics). Default: stdin"),
194
+ noisy: int = typer.Option(0, "--noisy", "-n", help="Mark rules with fp_rate >= this percent as noisy (0 = show all)"),
195
+ ) -> None:
196
+ """Rule quality / rule health: hits, accepts, ignores, fp_rate from a single replay. Input = replay JSON with _metrics."""
197
+ import json
198
+ from core.rules import RuleEngine
199
+
200
+ if input_file:
201
+ with open(input_file, "r", encoding="utf-8-sig") as f:
202
+ data = json.load(f)
203
+ else:
204
+ data = json.load(sys.stdin)
205
+ metrics = data.get("_metrics") or {}
206
+ rows = RuleEngine.quality_report_from_metrics(metrics)
207
+ if not rows:
208
+ typer.echo("No metrics (run replay first and pass JSON with --input or stdin).")
209
+ return
210
+ # Table header
211
+ typer.echo(f"{'rule_id':<45} {'hits':>6} {'accepts':>7} {'ignores':>7} {'fp_rate':>8}")
212
+ typer.echo("-" * 76)
213
+ for r in rows:
214
+ fp_pct = r["fp_rate"] * 100
215
+ flag = " noisy" if noisy and fp_pct >= noisy else ""
216
+ typer.echo(f"{r['rule_id']:<45} {r['hits']:>6} {r['accepts']:>7} {r['ignores']:>7} {fp_pct:>6.0f}%{flag}")
217
+
218
+
219
+ @rules_app.command("health")
220
+ def rules_health(
221
+ metrics_file: str = typer.Option(None, "--metrics", "-m", help="Path to rule_metrics.json (default: DIFFSENSE_RULE_METRICS or ./rule_metrics.json)"),
222
+ ) -> None:
223
+ """Rule health from persisted rule_metrics.json (hits, confirmed, false_positive, precision, quality_status)."""
224
+ import json
225
+ path = metrics_file or os.environ.get("DIFFSENSE_RULE_METRICS") or os.path.join(os.getcwd(), "rule_metrics.json")
226
+ if not os.path.exists(path):
227
+ typer.echo(f"No rule_metrics.json at {path}. Run audit/replay with quality tracking first.", err=True)
228
+ raise typer.Exit(1)
229
+ with open(path, "r", encoding="utf-8") as f:
230
+ data = json.load(f)
231
+ rules = data.get("rules") or {}
232
+ if not rules:
233
+ typer.echo("No rule entries in rule_metrics.json.")
234
+ return
235
+ typer.echo(f"{'rule_id':<45} {'hits':>6} {'confirmed':>9} {'false_positive':>14} {'precision':>9} {'status':>12}")
236
+ typer.echo("-" * 96)
237
+ for rule_id, entry in sorted(rules.items()):
238
+ if not isinstance(entry, dict):
239
+ continue
240
+ hits = entry.get("hits", 0)
241
+ confirmed = entry.get("confirmed", 0)
242
+ fp = entry.get("false_positive", 0)
243
+ prec = entry.get("precision", 1.0)
244
+ prec_str = f"{prec:.2f}" if isinstance(prec, (int, float)) else str(prec)
245
+ status = "normal"
246
+ if hits >= 30:
247
+ if prec < 0.3:
248
+ status = "disabled"
249
+ elif prec < 0.5:
250
+ status = "degraded"
251
+ elif hits > 0:
252
+ status = "insufficient"
253
+ typer.echo(f"{rule_id:<45} {hits:>6} {confirmed:>9} {fp:>14} {prec_str:>9} {status:>12}")
254
+
255
+
256
+ @rules_app.command("sdk")
257
+ def rules_sdk() -> None:
258
+ typer.echo("Minimal SDK example:")
259
+ typer.echo("from diffsense.core.rule_base import Rule")
260
+ typer.echo("")
261
+ typer.echo("class MyRule(Rule):")
262
+ typer.echo(" @property")
263
+ typer.echo(" def id(self): return \"custom.rule\"")
264
+ typer.echo(" @property")
265
+ typer.echo(" def severity(self): return \"high\"")
266
+ typer.echo(" @property")
267
+ typer.echo(" def impact(self): return \"runtime\"")
268
+ typer.echo(" @property")
269
+ typer.echo(" def rationale(self): return \"why this matters\"")
270
+ typer.echo(" @property")
271
+ typer.echo(" def status(self): return \"experimental\"")
272
+ typer.echo(" def evaluate(self, diff_data, ast_signals):")
273
+ typer.echo(" return None")
274
+
275
+
276
+ @app.command("replay-coverage")
277
+ def replay_coverage(
278
+ replay_dir: str = typer.Option(None, "--replay-dir", "-d", help="Directory of replay JSON outputs to aggregate"),
279
+ input_list: str = typer.Option(None, "--input", "-i", help="File listing replay JSON paths (one per line)"),
280
+ ) -> None:
281
+ """Replay coverage: aggregate many replay runs to compute hit rate (e.g. %% of MRs with risky diffs caught).
282
+ Input: directory of replay JSONs, or a file listing paths. Output: total MRs, triggered count, hit rate.
283
+ Example: diffsense replay-coverage --replay-dir ./replay_reports/
284
+ (Implementation: aggregate review_level and _metrics from each JSON; report total, triggered, hit_rate.)"""
285
+ if not replay_dir and not input_list:
286
+ typer.echo("Use --replay-dir or --input to pass replay results.")
287
+ raise typer.Exit(0)
288
+ paths = []
289
+ if replay_dir:
290
+ for root, _, files in os.walk(replay_dir):
291
+ for name in files:
292
+ if name.endswith(".json"):
293
+ paths.append(os.path.join(root, name))
294
+ if input_list:
295
+ with open(input_list, "r", encoding="utf-8") as f:
296
+ for line in f:
297
+ p = line.strip()
298
+ if p:
299
+ paths.append(p)
300
+ if not paths:
301
+ typer.echo("No replay JSON files found.")
302
+ raise typer.Exit(0)
303
+ total = 0
304
+ triggered = 0
305
+ for path in paths:
306
+ try:
307
+ import json
308
+ with open(path, "r", encoding="utf-8-sig") as f:
309
+ data = json.load(f)
310
+ total += 1
311
+ level = str(data.get("review_level", "normal")).lower()
312
+ if level in ["elevated", "critical"]:
313
+ triggered += 1
314
+ except Exception:
315
+ continue
316
+ hit_rate = (triggered / total * 100) if total else 0
317
+ typer.echo(f"Total: {total}")
318
+ typer.echo(f"Triggered: {triggered}")
319
+ typer.echo(f"Hit Rate: {hit_rate:.1f}%")
320
+
321
+
322
+ def _get_cache_root() -> str:
323
+ """Return cache root directory (parent of CACHE_VERSION/diff and CACHE_VERSION/ast)."""
324
+ from core.parser import DiffParser
325
+ p = DiffParser()
326
+ # cache_dir is .../cache/<CACHE_VERSION>/diff or .../cache/<CACHE_VERSION>/ast
327
+ return os.path.dirname(os.path.dirname(p.cache_dir))
328
+
329
+
330
+ cache_app = typer.Typer(help="Cache: prune by age to limit disk growth.")
331
+
332
+ @cache_app.command("prune")
333
+ def cache_prune(
334
+ max_age_days: float = typer.Option(7.0, "--max-age-days", help="Remove cache entries older than this many days"),
335
+ dry_run: bool = typer.Option(False, "--dry-run", help="Only print what would be removed"),
336
+ ) -> None:
337
+ """Remove cache files older than --max-age-days. Use in CI or cron to limit disk usage."""
338
+ import time
339
+ root = _get_cache_root()
340
+ if not os.path.isdir(root):
341
+ typer.echo(f"Cache root not found: {root}")
342
+ return
343
+ cutoff = time.time() - max_age_days * 86400
344
+ removed = 0
345
+ for dirpath, _, filenames in os.walk(root, topdown=False):
346
+ for name in filenames:
347
+ if name.endswith(".tmp"):
348
+ continue
349
+ path = os.path.join(dirpath, name)
350
+ try:
351
+ if os.path.getmtime(path) < cutoff:
352
+ if not dry_run:
353
+ os.remove(path)
354
+ removed += 1
355
+ except OSError:
356
+ pass
357
+ if dry_run:
358
+ typer.echo(f"Would remove {removed} file(s) older than {max_age_days} days under {root}")
359
+ else:
360
+ typer.echo(f"Removed {removed} file(s) older than {max_age_days} days under {root}")
361
+
362
+
363
+ @app.command("benchmark-cold-hot")
364
+ def benchmark_cold_hot(
365
+ diff_file: str = typer.Argument(..., help="Path to a .diff file (e.g. tests/fixtures/ast_cases/p0_concurrency.diff)"),
366
+ output: str = typer.Option("benchmark-cold-hot-result.json", "--output", "-o", help="Write cold_s, hot_s and thresholds here"),
367
+ fail_if_over: bool = typer.Option(False, "--fail-if-over", help="Exit with code 1 if cold_s > 10 or hot_s > 3"),
368
+ cold_threshold_s: float = typer.Option(10.0, "--cold-threshold", help="Cold run threshold (seconds)"),
369
+ hot_threshold_s: float = typer.Option(3.0, "--hot-threshold", help="Hot run threshold (seconds)"),
370
+ ) -> None:
371
+ """Run audit twice (cold then hot) and report timings. For CI: use --fail-if-over to enforce DoD."""
372
+ import subprocess
373
+ import tempfile
374
+ import json as _json
375
+ pkg_dir = os.path.dirname(os.path.abspath(__file__))
376
+ if not os.path.isabs(diff_file):
377
+ for base in [os.getcwd(), pkg_dir, os.path.join(pkg_dir, "..")]:
378
+ candidate = os.path.join(base, diff_file)
379
+ if os.path.isfile(candidate):
380
+ diff_file = candidate
381
+ break
382
+ if not os.path.isfile(diff_file):
383
+ typer.echo(f"Diff file not found: {diff_file}", err=True)
384
+ raise typer.Exit(1)
385
+ tmp = tempfile.mkdtemp(prefix="diffsense_bench_")
386
+ env = {**os.environ, "DIFFSENSE_CACHE_DIR": tmp}
387
+ report_path = os.path.join(tmp, "report.json")
388
+ main_py = os.path.join(pkg_dir, "main.py")
389
+ cmd = [sys.executable, main_py, diff_file, "--report-json", report_path, "--format", "json"] if os.path.isfile(main_py) else [sys.executable, "-m", "main", diff_file, "--report-json", report_path, "--format", "json"]
390
+ try:
391
+ t0 = time.perf_counter()
392
+ r0 = subprocess.run(cmd, env=env, cwd=pkg_dir, capture_output=True, timeout=60)
393
+ cold_s = time.perf_counter() - t0
394
+ t1 = time.perf_counter()
395
+ r1 = subprocess.run(cmd, env=env, cwd=pkg_dir, capture_output=True, timeout=60)
396
+ hot_s = time.perf_counter() - t1
397
+ except subprocess.TimeoutExpired:
398
+ typer.echo("Run timed out (60s).", err=True)
399
+ raise typer.Exit(1)
400
+ except Exception as e:
401
+ typer.echo(f"Run failed: {e}", err=True)
402
+ raise typer.Exit(1)
403
+ finally:
404
+ try:
405
+ import shutil
406
+ shutil.rmtree(tmp, ignore_errors=True)
407
+ except Exception:
408
+ pass
409
+ result = {
410
+ "cold_s": round(cold_s, 3),
411
+ "hot_s": round(hot_s, 3),
412
+ "cold_threshold_s": cold_threshold_s,
413
+ "hot_threshold_s": hot_threshold_s,
414
+ "cold_ok": cold_s <= cold_threshold_s,
415
+ "hot_ok": hot_s <= hot_threshold_s,
416
+ }
417
+ out_dir = os.path.dirname(output)
418
+ if out_dir:
419
+ os.makedirs(out_dir, exist_ok=True)
420
+ with open(output, "w", encoding="utf-8") as f:
421
+ _json.dump(result, f, indent=2)
422
+ typer.echo(f"Cold: {cold_s:.2f}s (threshold {cold_threshold_s}s) — {'OK' if result['cold_ok'] else 'OVER'}")
423
+ typer.echo(f"Hot: {hot_s:.2f}s (threshold {hot_threshold_s}s) — {'OK' if result['hot_ok'] else 'OVER'}")
424
+ typer.echo(f"Written: {output}")
425
+ if fail_if_over and (not result["cold_ok"] or not result["hot_ok"]):
426
+ raise typer.Exit(1)
427
+
428
+
429
+ @app.command("benchmark")
430
+ def benchmark(
431
+ manifest: str = typer.Option("benchmarks/manifest.yaml", "--manifest", "-m", help="Benchmark manifest YAML path"),
432
+ output: str = typer.Option("benchmarks/benchmark_report.json", "--output", "-o", help="Benchmark report JSON output path"),
433
+ rules: str = typer.Option("config", "--rules", help="Path to rules: single YAML file or directory of YAML files"),
434
+ profile: str = typer.Option(None, "--profile", help="Profile: strict or lightweight"),
435
+ experimental: bool = typer.Option(False, "--experimental", help="Include experimental rules (report-only by default)"),
436
+ experimental_report_only: bool = typer.Option(True, "--experimental-report-only/--experimental-affect-decision", help="Do not affect decision with experimental rules"),
437
+ ) -> None:
438
+ import json
439
+ import time
440
+ import tracemalloc
441
+ import yaml
442
+ from core.parser import DiffParser
443
+ from core.ast_detector import ASTDetector
444
+ from core.rules import RuleEngine
445
+
446
+ if not os.path.exists(manifest):
447
+ typer.echo(f"Manifest not found: {manifest}")
448
+ raise typer.Exit(1)
449
+ with open(manifest, "r", encoding="utf-8") as f:
450
+ data = yaml.safe_load(f) or {}
451
+ cases = data.get("cases", [])
452
+ if not isinstance(cases, list) or not cases:
453
+ typer.echo("No benchmark cases found.")
454
+ raise typer.Exit(1)
455
+ base_dir = os.path.dirname(os.path.abspath(manifest))
456
+ rules_path = rules
457
+ if not os.path.exists(rules_path):
458
+ rules_path = _default_rules_path()
459
+ pro_path = None
460
+ try:
461
+ from core.run_config import get_pro_rules_path
462
+ pro_path = get_pro_rules_path(os.getcwd())
463
+ except Exception:
464
+ pass
465
+ engine = RuleEngine(
466
+ rules_path,
467
+ profile=profile,
468
+ config={"experimental": {"enabled": experimental, "report_only": experimental_report_only}},
469
+ pro_rules_path=pro_path,
470
+ )
471
+ parser = DiffParser()
472
+ detector = ASTDetector()
473
+ total_tp = 0
474
+ total_fp = 0
475
+ total_expected = 0
476
+ total_cases = 0
477
+ runtimes = []
478
+ peak_mem_kb = []
479
+ case_reports = []
480
+ for case in cases:
481
+ fixture = case.get("fixture")
482
+ if not fixture:
483
+ continue
484
+ path = fixture if os.path.isabs(fixture) else os.path.join(base_dir, fixture)
485
+ if not os.path.exists(path):
486
+ continue
487
+ content = Path(path).read_text(encoding="utf-8")
488
+ start = time.perf_counter()
489
+ tracemalloc.start()
490
+ diff_data = parser.parse(content)
491
+ if not diff_data.get("file_patches") and content.strip():
492
+ diff_data.setdefault("file_patches", [])
493
+ fname = case.get("file_for_patch", "Dummy.java")
494
+ diff_data["file_patches"].append({"file": fname, "patch": content})
495
+ if "raw_diff" not in diff_data:
496
+ diff_data["raw_diff"] = content
497
+ signals = detector.detect_signals(diff_data)
498
+ triggered = engine.evaluate(diff_data, signals)
499
+ current, peak = tracemalloc.get_traced_memory()
500
+ tracemalloc.stop()
501
+ elapsed_ms = (time.perf_counter() - start) * 1000
502
+ runtimes.append(elapsed_ms)
503
+ peak_mem_kb.append(peak / 1024)
504
+ expected = case.get("expect_rules", [])
505
+ expected_contains = case.get("expect_rules_contain", [])
506
+ expected = expected if isinstance(expected, list) else []
507
+ expected_contains = expected_contains if isinstance(expected_contains, list) else []
508
+ actual_ids = [r.get("id") for r in triggered]
509
+ tp = 0
510
+ for rule_id in actual_ids:
511
+ if rule_id in expected:
512
+ tp += 1
513
+ else:
514
+ for sub in expected_contains:
515
+ if sub and sub in rule_id:
516
+ tp += 1
517
+ break
518
+ fp = max(0, len(actual_ids) - tp)
519
+ total_tp += tp
520
+ total_fp += fp
521
+ total_expected += len(expected) + len(expected_contains)
522
+ total_cases += 1
523
+ precision = (tp / (tp + fp)) if (tp + fp) else 1.0
524
+ case_reports.append({
525
+ "id": case.get("id", os.path.basename(path)),
526
+ "fixture": fixture,
527
+ "runtime_ms": elapsed_ms,
528
+ "peak_mem_kb": peak / 1024,
529
+ "precision": precision,
530
+ "triggered": len(actual_ids),
531
+ })
532
+ avg_runtime = (sum(runtimes) / len(runtimes)) if runtimes else 0.0
533
+ avg_mem = (sum(peak_mem_kb) / len(peak_mem_kb)) if peak_mem_kb else 0.0
534
+ precision = (total_tp / (total_tp + total_fp)) if (total_tp + total_fp) else 1.0
535
+ report = {
536
+ "total_cases": total_cases,
537
+ "precision": precision,
538
+ "avg_runtime_ms": avg_runtime,
539
+ "avg_peak_mem_kb": avg_mem,
540
+ "cases": case_reports,
541
+ }
542
+ out_dir = os.path.dirname(output)
543
+ if out_dir:
544
+ os.makedirs(out_dir, exist_ok=True)
545
+ with open(output, "w", encoding="utf-8") as f:
546
+ json.dump(report, f, ensure_ascii=False, indent=2)
547
+ typer.echo(f"Benchmark report saved: {output}")
548
+
549
+
550
+ @app.command("profile-rules")
551
+ def profile_rules(
552
+ rules_path: str = typer.Option(None, "--rules", help="Path to rules: file or directory of YAML"),
553
+ diff_file: str = typer.Option(None, "--diff", "-d", help="Optional .diff file to run against; else minimal diff"),
554
+ ) -> None:
555
+ """Print top slow rules by execution time (from one run). Use after replay to see which rules cost the most."""
556
+ from core.rules import RuleEngine
557
+
558
+ path = rules_path
559
+ if not path or not os.path.exists(path):
560
+ path = _default_rules_path()
561
+ pro_path = None
562
+ try:
563
+ from core.run_config import get_pro_rules_path
564
+ pro_path = get_pro_rules_path(os.getcwd())
565
+ except Exception:
566
+ pass
567
+ engine = RuleEngine(path, pro_rules_path=pro_path)
568
+ if diff_file and os.path.exists(diff_file):
569
+ with open(diff_file, "r", encoding="utf-8") as f:
570
+ content = f.read()
571
+ from core.parser import DiffParser
572
+ from core.ast_detector import ASTDetector
573
+ parser = DiffParser()
574
+ diff_data = parser.parse(content)
575
+ ast_signals = ASTDetector().detect_signals(diff_data)
576
+ else:
577
+ diff_data = {"files": [], "raw_diff": ""}
578
+ ast_signals = []
579
+ engine.evaluate(diff_data, ast_signals)
580
+ metrics = engine.get_metrics()
581
+ # Sort by time_ns desc, show ms
582
+ items = [(rid, m.get("time_ns", 0)) for rid, m in metrics.items()]
583
+ items.sort(key=lambda x: -x[1])
584
+ typer.echo("Top slow rules (this run):")
585
+ for i, (rule_id, time_ns) in enumerate(items[:20], 1):
586
+ ms = time_ns / 1_000_000
587
+ typer.echo(f" {i:>2}. {rule_id:<45} {ms:>8.2f} ms")
588
+
589
+
590
+ @app.command("signals")
591
+ def signals_cmd() -> None:
592
+ """List available signals (emitted by semantic analyzers; rules only consume them)."""
593
+ from core.signals_registry import get_signals_by_group
594
+ typer.echo("Available Signals:")
595
+ typer.echo("")
596
+ for group, ids in get_signals_by_group().items():
597
+ for sid in ids:
598
+ typer.echo(sid)
599
+ typer.echo("")
600
+
601
+
602
+ app.add_typer(rules_app, name="rules")
603
+ app.add_typer(cache_app, name="cache")
604
+
605
+ if __name__ == "__main__":
606
+ app()
config/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # Config package: rules.yaml loaded by RuleEngine