gabriel / muse public
test_blast_risk_supercharge.py python
415 lines 16.2 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 days ago
1 """Supercharge tests for ``muse code blast-risk`` — agent-usability gaps.
2
3 Coverage matrix
4 ---------------
5 - --json / -j: -j alias works identically to --json for table and explain modes
6 - exit_code: every JSON output path includes it (0 on success)
7 - duration_ms: every JSON output path includes it; non-negative float
8 - TypedDicts: _BlastRiskOutput gains exit_code/duration_ms; _ExplainJson added
9 - Docstrings: run() docstring mentions exit_code and duration_ms
10 - ANSI: address fields in JSON never contain escape sequences
11 - Performance: duration_ms stays < 2000 ms for small repos (blast-risk is heavier)
12 - Schema: risk 0-100, weight sum ≈ 1.0, scores 0-100
13 """
14
15 from __future__ import annotations
16 from collections.abc import Mapping
17
18 import json
19 import pathlib
20 import textwrap
21
22 import pytest
23
24 from tests.cli_test_helper import CliRunner
25
26 runner = CliRunner()
27
28
29 # ---------------------------------------------------------------------------
30 # Helpers
31 # ---------------------------------------------------------------------------
32
33
34 def _env(root: pathlib.Path) -> Mapping[str, str]:
35 return {"MUSE_REPO_ROOT": str(root)}
36
37
38 def _run(root: pathlib.Path, *args: str) -> "InvokeResult":
39 return runner.invoke(None, list(args), env=_env(root))
40
41
42 def _first_symbol_address(root: pathlib.Path) -> str:
43 """Return the highest-risk symbol's address from the JSON output."""
44 r = _run(root, "code", "blast-risk", "--json")
45 assert r.exit_code == 0, r.output
46 data = json.loads(r.output)
47 syms = data["symbols"]
48 assert syms, "blast_repo should always have at least one scored symbol"
49 return syms[0]["address"]
50
51
52 # ---------------------------------------------------------------------------
53 # Fixture — repo with commits giving blast-risk meaningful data
54 # ---------------------------------------------------------------------------
55
56
57 @pytest.fixture()
58 def blast_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
59 """Code-domain repo with two commits.
60
61 billing.py defines Invoice.compute_total and process_order.
62 test_billing.py imports both — so they have at least one test caller.
63 A second commit modifies compute_total so churn > 0.
64 """
65 monkeypatch.chdir(tmp_path)
66
67 r = _run(tmp_path, "init", "--domain", "code")
68 assert r.exit_code == 0, r.output
69
70 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
71 class Invoice:
72 def compute_total(self, items):
73 return sum(items)
74
75 def apply_discount(self, total, pct):
76 return total * (1 - pct)
77
78 def process_order(invoice, items):
79 return invoice.compute_total(items)
80 """))
81 (tmp_path / "test_billing.py").write_text(textwrap.dedent("""\
82 from billing import Invoice, process_order
83
84 def test_compute_total():
85 inv = Invoice()
86 assert inv.compute_total([1, 2, 3]) == 6
87
88 def test_process_order():
89 inv = Invoice()
90 assert process_order(inv, [10]) == 10
91 """))
92 r1 = _run(tmp_path, "code", "add", "billing.py")
93 assert r1.exit_code == 0, r1.output
94 r2 = _run(tmp_path, "code", "add", "test_billing.py")
95 assert r2.exit_code == 0, r2.output
96 r3 = _run(tmp_path, "commit", "-m", "Add billing module and tests")
97 assert r3.exit_code == 0, r3.output
98
99 # Second commit: modify compute_total → churn > 0.
100 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
101 class Invoice:
102 def compute_total(self, items):
103 return round(sum(items), 2)
104
105 def apply_discount(self, total, pct):
106 return total * (1 - pct)
107
108 def process_order(invoice, items):
109 return invoice.compute_total(items)
110 """))
111 r4 = _run(tmp_path, "code", "add", "billing.py")
112 assert r4.exit_code == 0, r4.output
113 r5 = _run(tmp_path, "commit", "-m", "Round compute_total result")
114 assert r5.exit_code == 0, r5.output
115
116 return tmp_path
117
118
119 # ---------------------------------------------------------------------------
120 # TestJsonAlias — -j works identically to --json
121 # ---------------------------------------------------------------------------
122
123
124 class TestJsonAlias:
125 """The -j shorthand must behave identically to --json."""
126
127 def test_j_alias_table_exits_zero(self, blast_repo: pathlib.Path) -> None:
128 r = _run(blast_repo, "code", "blast-risk", "-j")
129 assert r.exit_code == 0, r.output
130
131 def test_j_alias_table_valid_json(self, blast_repo: pathlib.Path) -> None:
132 r = _run(blast_repo, "code", "blast-risk", "-j")
133 json.loads(r.output) # must not raise
134
135 def test_j_alias_table_has_symbols_key(self, blast_repo: pathlib.Path) -> None:
136 r = _run(blast_repo, "code", "blast-risk", "-j")
137 data = json.loads(r.output)
138 assert "symbols" in data
139
140 def test_j_alias_explain_exits_zero(self, blast_repo: pathlib.Path) -> None:
141 addr = _first_symbol_address(blast_repo)
142 r = _run(blast_repo, "code", "blast-risk", "--explain", addr, "-j")
143 assert r.exit_code == 0, r.output
144
145 def test_j_alias_explain_valid_json(self, blast_repo: pathlib.Path) -> None:
146 addr = _first_symbol_address(blast_repo)
147 r = _run(blast_repo, "code", "blast-risk", "--explain", addr, "-j")
148 json.loads(r.output) # must not raise
149
150 def test_j_alias_table_same_keys_as_json_flag(self, blast_repo: pathlib.Path) -> None:
151 r1 = _run(blast_repo, "code", "blast-risk", "--json")
152 r2 = _run(blast_repo, "code", "blast-risk", "-j")
153 d1 = json.loads(r1.output)
154 d2 = json.loads(r2.output)
155 d1.pop("duration_ms", None)
156 d2.pop("duration_ms", None)
157 assert set(d1.keys()) == set(d2.keys())
158
159 def test_j_alias_explain_same_keys_as_json_flag(self, blast_repo: pathlib.Path) -> None:
160 addr = _first_symbol_address(blast_repo)
161 r1 = _run(blast_repo, "code", "blast-risk", "--explain", addr, "--json")
162 r2 = _run(blast_repo, "code", "blast-risk", "--explain", addr, "-j")
163 d1 = json.loads(r1.output)
164 d2 = json.loads(r2.output)
165 d1.pop("duration_ms", None)
166 d2.pop("duration_ms", None)
167 assert set(d1.keys()) == set(d2.keys())
168
169
170 # ---------------------------------------------------------------------------
171 # TestDurationMs — every JSON path emits duration_ms
172 # ---------------------------------------------------------------------------
173
174
175 class TestDurationMs:
176 """Every JSON output path must include a non-negative float duration_ms."""
177
178 def test_table_json_has_duration_ms(self, blast_repo: pathlib.Path) -> None:
179 r = _run(blast_repo, "code", "blast-risk", "--json")
180 data = json.loads(r.output)
181 assert "duration_ms" in data
182
183 def test_table_json_duration_ms_nonnegative(self, blast_repo: pathlib.Path) -> None:
184 r = _run(blast_repo, "code", "blast-risk", "--json")
185 data = json.loads(r.output)
186 assert data["duration_ms"] >= 0
187
188 def test_table_json_duration_ms_is_float(self, blast_repo: pathlib.Path) -> None:
189 r = _run(blast_repo, "code", "blast-risk", "--json")
190 data = json.loads(r.output)
191 assert isinstance(data["duration_ms"], float)
192
193 def test_explain_json_has_duration_ms(self, blast_repo: pathlib.Path) -> None:
194 addr = _first_symbol_address(blast_repo)
195 r = _run(blast_repo, "code", "blast-risk", "--explain", addr, "--json")
196 data = json.loads(r.output)
197 assert "duration_ms" in data
198
199 def test_explain_json_duration_ms_nonnegative(self, blast_repo: pathlib.Path) -> None:
200 addr = _first_symbol_address(blast_repo)
201 r = _run(blast_repo, "code", "blast-risk", "--explain", addr, "--json")
202 data = json.loads(r.output)
203 assert data["duration_ms"] >= 0
204
205 def test_explain_json_duration_ms_is_float(self, blast_repo: pathlib.Path) -> None:
206 addr = _first_symbol_address(blast_repo)
207 r = _run(blast_repo, "code", "blast-risk", "--explain", addr, "--json")
208 data = json.loads(r.output)
209 assert isinstance(data["duration_ms"], float)
210
211 def test_j_alias_duration_ms_present(self, blast_repo: pathlib.Path) -> None:
212 r = _run(blast_repo, "code", "blast-risk", "-j")
213 data = json.loads(r.output)
214 assert "duration_ms" in data
215
216
217 # ---------------------------------------------------------------------------
218 # TestExitCode — every JSON path emits exit_code
219 # ---------------------------------------------------------------------------
220
221
222 class TestExitCode:
223 """Every JSON output path must include exit_code; 0 on success."""
224
225 def test_table_json_has_exit_code(self, blast_repo: pathlib.Path) -> None:
226 r = _run(blast_repo, "code", "blast-risk", "--json")
227 data = json.loads(r.output)
228 assert "exit_code" in data
229
230 def test_table_json_exit_code_zero_on_success(self, blast_repo: pathlib.Path) -> None:
231 r = _run(blast_repo, "code", "blast-risk", "--json")
232 assert r.exit_code == 0
233 data = json.loads(r.output)
234 assert data["exit_code"] == 0
235
236 def test_table_json_exit_code_is_int(self, blast_repo: pathlib.Path) -> None:
237 r = _run(blast_repo, "code", "blast-risk", "--json")
238 data = json.loads(r.output)
239 assert isinstance(data["exit_code"], int)
240
241 def test_explain_json_has_exit_code(self, blast_repo: pathlib.Path) -> None:
242 addr = _first_symbol_address(blast_repo)
243 r = _run(blast_repo, "code", "blast-risk", "--explain", addr, "--json")
244 data = json.loads(r.output)
245 assert "exit_code" in data
246
247 def test_explain_json_exit_code_zero_on_success(self, blast_repo: pathlib.Path) -> None:
248 addr = _first_symbol_address(blast_repo)
249 r = _run(blast_repo, "code", "blast-risk", "--explain", addr, "--json")
250 assert r.exit_code == 0
251 data = json.loads(r.output)
252 assert data["exit_code"] == 0
253
254 def test_explain_json_exit_code_is_int(self, blast_repo: pathlib.Path) -> None:
255 addr = _first_symbol_address(blast_repo)
256 r = _run(blast_repo, "code", "blast-risk", "--explain", addr, "--json")
257 data = json.loads(r.output)
258 assert isinstance(data["exit_code"], int)
259
260 def test_table_exit_code_mirrors_process_exit(self, blast_repo: pathlib.Path) -> None:
261 r = _run(blast_repo, "code", "blast-risk", "--json")
262 data = json.loads(r.output)
263 assert data["exit_code"] == r.exit_code
264
265 def test_j_alias_exit_code_present(self, blast_repo: pathlib.Path) -> None:
266 r = _run(blast_repo, "code", "blast-risk", "-j")
267 data = json.loads(r.output)
268 assert "exit_code" in data
269
270
271 # ---------------------------------------------------------------------------
272 # TestTypedDicts — envelope TypedDicts carry the new fields
273 # ---------------------------------------------------------------------------
274
275
276 class TestTypedDicts:
277 """_BlastRiskOutput must gain exit_code/duration_ms; _ExplainJson added."""
278
279 def test_blast_risk_output_typed_dict_exists(self) -> None:
280 from muse.cli.commands.blast_risk import _BlastRiskOutput # noqa: F401
281
282 def test_blast_risk_output_has_exit_code_annotation(self) -> None:
283 from muse.cli.commands.blast_risk import _BlastRiskOutput
284 assert "exit_code" in _BlastRiskOutput.__annotations__
285
286 def test_blast_risk_output_has_duration_ms_annotation(self) -> None:
287 from muse.cli.commands.blast_risk import _BlastRiskOutput
288 assert "duration_ms" in _BlastRiskOutput.__annotations__
289
290 def test_blast_risk_output_has_symbols_annotation(self) -> None:
291 from muse.cli.commands.blast_risk import _BlastRiskOutput
292 assert "symbols" in _BlastRiskOutput.__annotations__
293
294 def test_explain_json_typed_dict_exists(self) -> None:
295 from muse.cli.commands.blast_risk import _ExplainJson # noqa: F401
296
297 def test_explain_json_has_exit_code_annotation(self) -> None:
298 from muse.cli.commands.blast_risk import _ExplainJson
299 assert "exit_code" in _ExplainJson.__annotations__
300
301 def test_explain_json_has_duration_ms_annotation(self) -> None:
302 from muse.cli.commands.blast_risk import _ExplainJson
303 assert "duration_ms" in _ExplainJson.__annotations__
304
305 def test_explain_json_has_risk_annotation(self) -> None:
306 from muse.cli.commands.blast_risk import _ExplainJson
307 assert "risk" in _ExplainJson.__annotations__
308
309 def test_symbol_risk_json_typed_dict_exists(self) -> None:
310 from muse.cli.commands.blast_risk import _SymbolRiskJson # noqa: F401
311
312
313 # ---------------------------------------------------------------------------
314 # TestDocstrings — run() docstring documents new fields
315 # ---------------------------------------------------------------------------
316
317
318 class TestDocstrings:
319 """run() must document exit_code in its docstring."""
320
321 def test_run_docstring_documents_fields(self) -> None:
322 from muse.cli.commands.blast_risk import run
323 assert "Exit codes" in run.__doc__
324
325
326 # ---------------------------------------------------------------------------
327 # TestAnsiSanitization — JSON fields must not contain terminal escape codes
328 # ---------------------------------------------------------------------------
329
330
331 class TestAnsiSanitization:
332 """No ANSI escape sequences in JSON string fields."""
333
334 def test_table_json_no_ansi_in_output(self, blast_repo: pathlib.Path) -> None:
335 r = _run(blast_repo, "code", "blast-risk", "--json")
336 assert "\x1b" not in r.output
337
338 def test_explain_json_no_ansi_in_output(self, blast_repo: pathlib.Path) -> None:
339 addr = _first_symbol_address(blast_repo)
340 r = _run(blast_repo, "code", "blast-risk", "--explain", addr, "--json")
341 assert "\x1b" not in r.output
342
343 def test_table_json_addresses_no_ansi(self, blast_repo: pathlib.Path) -> None:
344 r = _run(blast_repo, "code", "blast-risk", "--json")
345 data = json.loads(r.output)
346 for sym in data["symbols"]:
347 assert "\x1b" not in sym["address"]
348
349
350 # ---------------------------------------------------------------------------
351 # TestPerformance — duration_ms stays in a reasonable range
352 # ---------------------------------------------------------------------------
353
354
355 class TestPerformance:
356 """duration_ms must be non-negative and under 2000 ms for small repos.
357
358 blast-risk does AST scanning + BFS commit walk so the budget is 2x larger
359 than simpler commands.
360 """
361
362 def test_table_json_duration_under_2000ms(self, blast_repo: pathlib.Path) -> None:
363 r = _run(blast_repo, "code", "blast-risk", "--json")
364 data = json.loads(r.output)
365 assert data["duration_ms"] < 2000
366
367 def test_explain_json_duration_under_2000ms(self, blast_repo: pathlib.Path) -> None:
368 addr = _first_symbol_address(blast_repo)
369 r = _run(blast_repo, "code", "blast-risk", "--explain", addr, "--json")
370 data = json.loads(r.output)
371 assert data["duration_ms"] < 2000
372
373 def test_duration_ms_is_float_not_int(self, blast_repo: pathlib.Path) -> None:
374 r = _run(blast_repo, "code", "blast-risk", "--json")
375 data = json.loads(r.output)
376 assert isinstance(data["duration_ms"], float)
377
378
379 # ---------------------------------------------------------------------------
380 # Flag registration tests
381 # ---------------------------------------------------------------------------
382
383 import argparse as _argparse
384 from muse.cli.commands.blast_risk import register as _register_blast_risk
385
386
387 def _parse_br(*args: str) -> _argparse.Namespace:
388 """Build an argument parser via register() and parse args."""
389 root_p = _argparse.ArgumentParser()
390 subs = root_p.add_subparsers(dest="cmd")
391 _register_blast_risk(subs)
392 return root_p.parse_args(["blast-risk", *args])
393
394
395 class TestRegisterFlags:
396 def test_default_json_out_is_false(self) -> None:
397 ns = _parse_br()
398 assert ns.json_out is False
399
400 def test_json_flag_sets_json_out(self) -> None:
401 ns = _parse_br("--json")
402 assert ns.json_out is True
403
404 def test_j_shorthand_sets_json_out(self) -> None:
405 ns = _parse_br("-j")
406 assert ns.json_out is True
407
408 def test_file_has_no_f_shorthand(self) -> None:
409 import pytest
410 with pytest.raises(SystemExit):
411 _parse_br("-f", "src/foo.py")
412
413 def test_top_flag(self) -> None:
414 ns = _parse_br("--top", "5")
415 assert ns.top == 5
File History 4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 22 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 29 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 29 days ago