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
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
|