gabriel / muse public
test_gravity_supercharge.py python
470 lines 19.3 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 148 days ago
1 """Supercharge tests for ``muse code gravity`` — agent-usability gaps.
2
3 There are NO existing gravity tests (confirmed: test_cmd_gravity.py is empty,
4 no other gravity test files exist).
5
6 This file targets both correctness gaps and agent-usability gaps:
7
8 Coverage matrix
9 ---------------
10 - --json / -j: -j alias works identically to --json (both modes)
11 - exit_code: JSON output includes exit_code = 0 on success (both modes)
12 - duration_ms: JSON output includes non-negative float duration_ms (both)
13 - TypedDicts: _JsonOut and _GravityExplainJson carry exit_code/duration_ms
14 - Docstrings: run() docstring mentions exit_code and duration_ms
15 - ANSI: JSON output never contains terminal escape sequences
16 - Performance: duration_ms stays under 5000 ms for a small repo
17 - Schema: leaderboard JSON has required top-level keys
18 - Explain schema: --explain JSON has required fields
19 - args.as_json: --json flag uses dest="as_json" (idiomatic)
20
21 Two JSON modes exercised
22 ------------------------
23 1. Leaderboard mode: --json / -j → _JsonOut envelope
24 2. Explain mode: --explain ADDR --json → _GravityExplainJson envelope
25 """
26
27 from __future__ import annotations
28
29 import json
30 import pathlib
31 import textwrap
32
33 import pytest
34
35 from tests.cli_test_helper import CliRunner
36
37 runner = CliRunner()
38
39
40 # ---------------------------------------------------------------------------
41 # Helpers
42 # ---------------------------------------------------------------------------
43
44
45 def _env(root: pathlib.Path) -> dict[str, str]:
46 return {"MUSE_REPO_ROOT": str(root)}
47
48
49 def _run(root: pathlib.Path, *args: str):
50 return runner.invoke(None, list(args), env=_env(root))
51
52
53 # ---------------------------------------------------------------------------
54 # Fixture — small Python repo with call-graph structure
55 # ---------------------------------------------------------------------------
56
57
58 @pytest.fixture()
59 def gravity_repo(
60 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
61 ) -> pathlib.Path:
62 """Repo with a simple Python call graph for gravity analysis.
63
64 core.py:
65 def read_object() ← foundation, called by everything
66 def validate(x) ← helper, called by process
67
68 service.py:
69 def process(x) ← calls validate, read_object
70 def publish(x) ← calls process
71
72 api.py:
73 def handle(req) ← calls publish, read_object
74
75 This gives:
76 read_object: high gravity (called by process, publish, handle)
77 validate: medium gravity (called by process → publish → handle)
78 process: medium gravity (called by publish → handle)
79 publish: lower gravity (called by handle)
80 handle: zero gravity (nobody calls it)
81 """
82 monkeypatch.chdir(tmp_path)
83 r = _run(tmp_path, "init", "--domain", "code")
84 assert r.exit_code == 0, r.output
85
86 (tmp_path / "core.py").write_text(textwrap.dedent("""\
87 def read_object(obj_id):
88 \"\"\"Load an object by ID.\"\"\"
89 return {"id": obj_id}
90
91 def validate(x):
92 \"\"\"Validate input.\"\"\"
93 if x is None:
94 raise ValueError("x must not be None")
95 return x
96 """))
97 (tmp_path / "service.py").write_text(textwrap.dedent("""\
98 from core import read_object, validate
99
100 def process(x):
101 \"\"\"Process with validation.\"\"\"
102 v = validate(x)
103 obj = read_object(v)
104 return obj
105
106 def publish(x):
107 \"\"\"Publish a processed result.\"\"\"
108 return process(x)
109 """))
110 (tmp_path / "api.py").write_text(textwrap.dedent("""\
111 from service import publish
112 from core import read_object
113
114 def handle(req):
115 \"\"\"Handle an incoming request.\"\"\"
116 read_object(req)
117 return publish(req)
118 """))
119 r = _run(tmp_path, "code", "add", ".")
120 assert r.exit_code == 0, r.output
121 r = _run(tmp_path, "commit", "-m", "seed gravity repo")
122 assert r.exit_code == 0, r.output
123
124 return tmp_path
125
126
127 # ---------------------------------------------------------------------------
128 # TestJsonAlias — -j works identically to --json (leaderboard mode)
129 # ---------------------------------------------------------------------------
130
131
132 class TestJsonAlias:
133 """-j shorthand must behave identically to --json in leaderboard mode."""
134
135 def test_j_alias_exits_zero(self, gravity_repo: pathlib.Path) -> None:
136 r = _run(gravity_repo, "code", "gravity", "-j")
137 assert r.exit_code == 0, r.output
138
139 def test_j_alias_valid_json(self, gravity_repo: pathlib.Path) -> None:
140 r = _run(gravity_repo, "code", "gravity", "-j")
141 json.loads(r.output) # must not raise
142
143 def test_j_alias_has_symbols_key(self, gravity_repo: pathlib.Path) -> None:
144 r = _run(gravity_repo, "code", "gravity", "-j")
145 assert "symbols" in json.loads(r.output)
146
147 def test_j_alias_has_ref_key(self, gravity_repo: pathlib.Path) -> None:
148 r = _run(gravity_repo, "code", "gravity", "-j")
149 assert "ref" in json.loads(r.output)
150
151 def test_j_alias_has_filters_key(self, gravity_repo: pathlib.Path) -> None:
152 r = _run(gravity_repo, "code", "gravity", "-j")
153 assert "filters" in json.loads(r.output)
154
155 def test_j_alias_same_top_level_keys_as_json_flag(
156 self, gravity_repo: pathlib.Path
157 ) -> None:
158 r1 = _run(gravity_repo, "code", "gravity", "--json")
159 r2 = _run(gravity_repo, "code", "gravity", "-j")
160 d1 = json.loads(r1.output)
161 d2 = json.loads(r2.output)
162 d1.pop("duration_ms", None)
163 d2.pop("duration_ms", None)
164 assert set(d1.keys()) == set(d2.keys())
165
166 def test_j_alias_symbol_count_matches_json_flag(
167 self, gravity_repo: pathlib.Path
168 ) -> None:
169 r1 = _run(gravity_repo, "code", "gravity", "--json")
170 r2 = _run(gravity_repo, "code", "gravity", "-j")
171 assert len(json.loads(r1.output)["symbols"]) == len(
172 json.loads(r2.output)["symbols"]
173 )
174
175 def test_j_alias_with_top_filter(self, gravity_repo: pathlib.Path) -> None:
176 r = _run(gravity_repo, "code", "gravity", "-j", "--top", "2")
177 assert r.exit_code == 0, r.output
178 assert len(json.loads(r.output)["symbols"]) <= 2
179
180 def test_j_alias_with_min_gravity(self, gravity_repo: pathlib.Path) -> None:
181 r = _run(gravity_repo, "code", "gravity", "-j", "--min-gravity", "0")
182 assert r.exit_code == 0, r.output
183 data = json.loads(r.output)
184 assert "symbols" in data
185
186
187 # ---------------------------------------------------------------------------
188 # TestDurationMs — JSON output must include duration_ms in both modes
189 # ---------------------------------------------------------------------------
190
191
192 class TestDurationMs:
193 """Every JSON path must include a non-negative float duration_ms."""
194
195 def test_json_has_duration_ms_leaderboard(self, gravity_repo: pathlib.Path) -> None:
196 r = _run(gravity_repo, "code", "gravity", "--json")
197 assert "duration_ms" in json.loads(r.output)
198
199 def test_json_duration_ms_nonnegative(self, gravity_repo: pathlib.Path) -> None:
200 r = _run(gravity_repo, "code", "gravity", "--json")
201 assert json.loads(r.output)["duration_ms"] >= 0
202
203 def test_json_duration_ms_is_float(self, gravity_repo: pathlib.Path) -> None:
204 r = _run(gravity_repo, "code", "gravity", "--json")
205 assert isinstance(json.loads(r.output)["duration_ms"], float)
206
207 def test_j_alias_duration_ms_present(self, gravity_repo: pathlib.Path) -> None:
208 r = _run(gravity_repo, "code", "gravity", "-j")
209 assert "duration_ms" in json.loads(r.output)
210
211 def test_duration_ms_with_top_filter(self, gravity_repo: pathlib.Path) -> None:
212 r = _run(gravity_repo, "code", "gravity", "--json", "--top", "2")
213 data = json.loads(r.output)
214 assert "duration_ms" in data
215 assert data["duration_ms"] >= 0
216
217 def test_duration_ms_explain_mode(self, gravity_repo: pathlib.Path) -> None:
218 r = _run(gravity_repo, "code", "gravity", "--json", "--explain", "core.py::read_object")
219 assert r.exit_code == 0, r.output
220 data = json.loads(r.output)
221 assert "duration_ms" in data
222 assert isinstance(data["duration_ms"], float)
223 assert data["duration_ms"] >= 0
224
225 def test_j_alias_duration_ms_explain(self, gravity_repo: pathlib.Path) -> None:
226 r = _run(gravity_repo, "code", "gravity", "-j", "--explain", "core.py::read_object")
227 assert r.exit_code == 0, r.output
228 assert "duration_ms" in json.loads(r.output)
229
230
231 # ---------------------------------------------------------------------------
232 # TestExitCode — JSON includes exit_code = 0 on success (both modes)
233 # ---------------------------------------------------------------------------
234
235
236 class TestExitCode:
237 """JSON exit_code must be 0 on success in both leaderboard and explain modes."""
238
239 def test_json_has_exit_code_leaderboard(self, gravity_repo: pathlib.Path) -> None:
240 r = _run(gravity_repo, "code", "gravity", "--json")
241 assert "exit_code" in json.loads(r.output)
242
243 def test_json_exit_code_zero_leaderboard(self, gravity_repo: pathlib.Path) -> None:
244 r = _run(gravity_repo, "code", "gravity", "--json")
245 assert r.exit_code == 0
246 assert json.loads(r.output)["exit_code"] == 0
247
248 def test_json_exit_code_is_int_leaderboard(self, gravity_repo: pathlib.Path) -> None:
249 r = _run(gravity_repo, "code", "gravity", "--json")
250 assert isinstance(json.loads(r.output)["exit_code"], int)
251
252 def test_j_alias_exit_code_present(self, gravity_repo: pathlib.Path) -> None:
253 r = _run(gravity_repo, "code", "gravity", "-j")
254 assert "exit_code" in json.loads(r.output)
255
256 def test_exit_code_mirrors_process_exit(self, gravity_repo: pathlib.Path) -> None:
257 r = _run(gravity_repo, "code", "gravity", "--json")
258 assert json.loads(r.output)["exit_code"] == r.exit_code
259
260 def test_json_has_exit_code_explain(self, gravity_repo: pathlib.Path) -> None:
261 r = _run(gravity_repo, "code", "gravity", "--json", "--explain", "core.py::read_object")
262 assert r.exit_code == 0, r.output
263 assert "exit_code" in json.loads(r.output)
264
265 def test_json_exit_code_zero_explain(self, gravity_repo: pathlib.Path) -> None:
266 r = _run(gravity_repo, "code", "gravity", "--json", "--explain", "core.py::read_object")
267 assert r.exit_code == 0
268 assert json.loads(r.output)["exit_code"] == 0
269
270 def test_exit_code_is_int_explain(self, gravity_repo: pathlib.Path) -> None:
271 r = _run(gravity_repo, "code", "gravity", "--json", "--explain", "core.py::read_object")
272 assert isinstance(json.loads(r.output)["exit_code"], int)
273
274 def test_exit_code_mirrors_process_exit_explain(
275 self, gravity_repo: pathlib.Path
276 ) -> None:
277 r = _run(gravity_repo, "code", "gravity", "--json", "--explain", "core.py::read_object")
278 assert json.loads(r.output)["exit_code"] == r.exit_code
279
280
281 # ---------------------------------------------------------------------------
282 # TestTypedDicts — TypedDicts carry exit_code and duration_ms
283 # ---------------------------------------------------------------------------
284
285
286 class TestTypedDicts:
287 """_JsonOut and _GravityExplainJson must carry exit_code and duration_ms."""
288
289 def test_json_out_typeddict_exists(self) -> None:
290 from muse.cli.commands.gravity import _JsonOut # noqa: F401
291
292 def test_json_out_has_exit_code_annotation(self) -> None:
293 from muse.cli.commands.gravity import _JsonOut
294 assert "exit_code" in _JsonOut.__annotations__
295
296 def test_json_out_has_duration_ms_annotation(self) -> None:
297 from muse.cli.commands.gravity import _JsonOut
298 assert "duration_ms" in _JsonOut.__annotations__
299
300 def test_json_out_retains_symbols_annotation(self) -> None:
301 from muse.cli.commands.gravity import _JsonOut
302 assert "symbols" in _JsonOut.__annotations__
303
304 def test_json_out_retains_filters_annotation(self) -> None:
305 from muse.cli.commands.gravity import _JsonOut
306 assert "filters" in _JsonOut.__annotations__
307
308 def test_gravity_explain_json_exists(self) -> None:
309 from muse.cli.commands.gravity import _GravityExplainJson # noqa: F401
310
311 def test_gravity_explain_json_has_exit_code(self) -> None:
312 from muse.cli.commands.gravity import _GravityExplainJson
313 assert "exit_code" in _GravityExplainJson.__annotations__
314
315 def test_gravity_explain_json_has_duration_ms(self) -> None:
316 from muse.cli.commands.gravity import _GravityExplainJson
317 assert "duration_ms" in _GravityExplainJson.__annotations__
318
319 def test_gravity_explain_json_has_address(self) -> None:
320 from muse.cli.commands.gravity import _GravityExplainJson
321 assert "address" in _GravityExplainJson.__annotations__
322
323 def test_gravity_explain_json_has_gravity_pct(self) -> None:
324 from muse.cli.commands.gravity import _GravityExplainJson
325 assert "gravity_pct" in _GravityExplainJson.__annotations__
326
327
328 # ---------------------------------------------------------------------------
329 # TestDocstrings — run() docstring documents exit_code and duration_ms
330 # ---------------------------------------------------------------------------
331
332
333 class TestDocstrings:
334 """run() must document exit_code and duration_ms."""
335
336 def test_run_docstring_mentions_exit_code(self) -> None:
337 from muse.cli.commands.gravity import run
338 assert run.__doc__ is not None
339 assert "exit_code" in run.__doc__
340
341 def test_run_docstring_mentions_duration_ms(self) -> None:
342 from muse.cli.commands.gravity import run
343 assert run.__doc__ is not None
344 assert "duration_ms" in run.__doc__
345
346
347 # ---------------------------------------------------------------------------
348 # TestAnsiSanitization — no escape codes in JSON output
349 # ---------------------------------------------------------------------------
350
351
352 class TestAnsiSanitization:
353 """No ANSI escape sequences anywhere in the JSON output."""
354
355 def test_json_output_no_ansi_leaderboard(self, gravity_repo: pathlib.Path) -> None:
356 r = _run(gravity_repo, "code", "gravity", "--json")
357 assert "\x1b" not in r.output
358
359 def test_j_alias_output_no_ansi(self, gravity_repo: pathlib.Path) -> None:
360 r = _run(gravity_repo, "code", "gravity", "-j")
361 assert "\x1b" not in r.output
362
363 def test_json_output_no_ansi_explain(self, gravity_repo: pathlib.Path) -> None:
364 r = _run(gravity_repo, "code", "gravity", "--json", "--explain", "core.py::read_object")
365 assert "\x1b" not in r.output
366
367
368 # ---------------------------------------------------------------------------
369 # TestLeaderboardSchema — JSON shape for leaderboard mode
370 # ---------------------------------------------------------------------------
371
372
373 class TestLeaderboardSchema:
374 """Leaderboard JSON must carry the documented top-level keys."""
375
376 def test_has_ref_key(self, gravity_repo: pathlib.Path) -> None:
377 r = _run(gravity_repo, "code", "gravity", "--json")
378 assert "ref" in json.loads(r.output)
379
380 def test_has_snapshot_id_key(self, gravity_repo: pathlib.Path) -> None:
381 r = _run(gravity_repo, "code", "gravity", "--json")
382 assert "snapshot_id" in json.loads(r.output)
383
384 def test_has_total_production_symbols_key(self, gravity_repo: pathlib.Path) -> None:
385 r = _run(gravity_repo, "code", "gravity", "--json")
386 assert "total_production_symbols" in json.loads(r.output)
387
388 def test_has_include_tests_key(self, gravity_repo: pathlib.Path) -> None:
389 r = _run(gravity_repo, "code", "gravity", "--json")
390 assert "include_tests" in json.loads(r.output)
391
392 def test_include_tests_is_false_by_default(self, gravity_repo: pathlib.Path) -> None:
393 r = _run(gravity_repo, "code", "gravity", "--json")
394 assert json.loads(r.output)["include_tests"] is False
395
396 def test_symbols_is_list(self, gravity_repo: pathlib.Path) -> None:
397 r = _run(gravity_repo, "code", "gravity", "--json")
398 assert isinstance(json.loads(r.output)["symbols"], list)
399
400 def test_symbol_entries_have_gravity_pct(self, gravity_repo: pathlib.Path) -> None:
401 r = _run(gravity_repo, "code", "gravity", "--json")
402 data = json.loads(r.output)
403 for sym in data["symbols"]:
404 assert "gravity_pct" in sym
405
406 def test_symbol_entries_have_address(self, gravity_repo: pathlib.Path) -> None:
407 r = _run(gravity_repo, "code", "gravity", "--json")
408 data = json.loads(r.output)
409 for sym in data["symbols"]:
410 assert "address" in sym
411
412 def test_top_filter_bounds_symbols(self, gravity_repo: pathlib.Path) -> None:
413 r = _run(gravity_repo, "code", "gravity", "--json", "--top", "2")
414 data = json.loads(r.output)
415 assert len(data["symbols"]) <= 2
416
417
418 # ---------------------------------------------------------------------------
419 # TestExplainSchema — JSON shape for --explain mode
420 # ---------------------------------------------------------------------------
421
422
423 class TestExplainSchema:
424 """Explain JSON must carry the documented fields."""
425
426 def test_explain_has_address(self, gravity_repo: pathlib.Path) -> None:
427 r = _run(gravity_repo, "code", "gravity", "--json", "--explain", "core.py::read_object")
428 assert r.exit_code == 0, r.output
429 assert "address" in json.loads(r.output)
430
431 def test_explain_has_gravity_pct(self, gravity_repo: pathlib.Path) -> None:
432 r = _run(gravity_repo, "code", "gravity", "--json", "--explain", "core.py::read_object")
433 data = json.loads(r.output)
434 assert "gravity_pct" in data
435 assert isinstance(data["gravity_pct"], float)
436
437 def test_explain_has_direct_dependents(self, gravity_repo: pathlib.Path) -> None:
438 r = _run(gravity_repo, "code", "gravity", "--json", "--explain", "core.py::read_object")
439 assert "direct_dependents" in json.loads(r.output)
440
441 def test_explain_has_depth_distribution(self, gravity_repo: pathlib.Path) -> None:
442 r = _run(gravity_repo, "code", "gravity", "--json", "--explain", "core.py::read_object")
443 assert "depth_distribution" in json.loads(r.output)
444
445 def test_explain_address_matches_flag(self, gravity_repo: pathlib.Path) -> None:
446 r = _run(gravity_repo, "code", "gravity", "--json", "--explain", "core.py::validate")
447 assert r.exit_code == 0, r.output
448 data = json.loads(r.output)
449 assert data["address"] == "core.py::validate"
450
451
452 # ---------------------------------------------------------------------------
453 # TestPerformance — duration_ms under 5000 ms for a small repo
454 # ---------------------------------------------------------------------------
455
456
457 class TestPerformance:
458 """duration_ms must stay under 5000 ms for small repos (AST parse overhead)."""
459
460 def test_leaderboard_duration_under_5000ms(self, gravity_repo: pathlib.Path) -> None:
461 r = _run(gravity_repo, "code", "gravity", "--json")
462 assert json.loads(r.output)["duration_ms"] < 5000
463
464 def test_explain_duration_under_5000ms(self, gravity_repo: pathlib.Path) -> None:
465 r = _run(gravity_repo, "code", "gravity", "--json", "--explain", "core.py::read_object")
466 assert json.loads(r.output)["duration_ms"] < 5000
467
468 def test_duration_ms_is_float_not_int(self, gravity_repo: pathlib.Path) -> None:
469 r = _run(gravity_repo, "code", "gravity", "--json")
470 assert isinstance(json.loads(r.output)["duration_ms"], float)
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 148 days ago