gabriel / muse public
test_api_surface_supercharge.py python
442 lines 17.8 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
1 """Supercharge tests for ``muse code api-surface`` — agent-usability gaps.
2
3 Coverage matrix
4 ---------------
5 - --json / -j: -j alias works identically to --json for list and diff 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: _ListJson, _DiffJson annotations exist with required fields
9 - Docstrings: run() docstring mentions exit_code and duration_ms
10 - ANSI: address / string fields in JSON never contain escape sequences
11 - Performance: duration_ms stays < 1000 ms for small repos
12 - Schema: semver_impact valid values, stability_pct in 0-100, breaking_count int
13 """
14
15 from __future__ import annotations
16
17 import json
18 import pathlib
19 import textwrap
20
21 import pytest
22
23 from tests.cli_test_helper import CliRunner
24
25 runner = CliRunner()
26
27
28 # ---------------------------------------------------------------------------
29 # Helpers
30 # ---------------------------------------------------------------------------
31
32
33 def _env(root: pathlib.Path) -> dict[str, str]:
34 return {"MUSE_REPO_ROOT": str(root)}
35
36
37 def _run(root: pathlib.Path, *args: str): # type: ignore[return]
38 return runner.invoke(None, list(args), env=_env(root))
39
40
41 def _commit_ids(root: pathlib.Path) -> list[str]:
42 """Return all commit IDs newest-first via muse log --json."""
43 r = _run(root, "log", "--json")
44 assert r.exit_code == 0, r.output
45 data = json.loads(r.output)
46 return [c["commit_id"] for c in data["commits"]]
47
48
49 # ---------------------------------------------------------------------------
50 # Fixture — repo with two commits giving api-surface meaningful diff data
51 # ---------------------------------------------------------------------------
52
53
54 @pytest.fixture()
55 def api_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
56 """Code-domain repo with two commits that change the public API surface.
57
58 Commit 1: Invoice class with compute_total + apply_discount + process_order
59 Commit 2: rename compute_total → compute_invoice_total, add send_email
60 (one removal = MAJOR semver impact)
61 """
62 monkeypatch.chdir(tmp_path)
63
64 r = _run(tmp_path, "init", "--domain", "code")
65 assert r.exit_code == 0, r.output
66
67 # Commit 1
68 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
69 class Invoice:
70 def compute_total(self, items):
71 return sum(items)
72
73 def apply_discount(self, total, pct):
74 return total * (1 - pct)
75
76 def process_order(invoice, items):
77 return invoice.compute_total(items)
78 """))
79 r1 = _run(tmp_path, "code", "add", "billing.py")
80 assert r1.exit_code == 0, r1.output
81 r2 = _run(tmp_path, "commit", "-m", "initial billing module")
82 assert r2.exit_code == 0, r2.output
83
84 # Commit 2 — rename compute_total (breaking), add send_email (MINOR)
85 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
86 class Invoice:
87 def compute_invoice_total(self, items):
88 return sum(items)
89
90 def apply_discount(self, total, pct):
91 return total * (1 - pct)
92
93 def generate_pdf(self):
94 return b"pdf"
95
96 def process_order(invoice, items):
97 return invoice.compute_invoice_total(items)
98
99 def send_email(address):
100 pass
101 """))
102 r3 = _run(tmp_path, "code", "add", "billing.py")
103 assert r3.exit_code == 0, r3.output
104 r4 = _run(tmp_path, "commit", "-m", "rename compute_total, add generate_pdf + send_email")
105 assert r4.exit_code == 0, r4.output
106
107 return tmp_path
108
109
110 # ---------------------------------------------------------------------------
111 # TestJsonAlias — -j works identically to --json
112 # ---------------------------------------------------------------------------
113
114
115 class TestJsonAlias:
116 """The -j shorthand must behave identically to --json."""
117
118 def test_j_alias_list_mode_exits_zero(self, api_repo: pathlib.Path) -> None:
119 r = _run(api_repo, "code", "api-surface", "-j")
120 assert r.exit_code == 0, r.output
121
122 def test_j_alias_list_mode_valid_json(self, api_repo: pathlib.Path) -> None:
123 r = _run(api_repo, "code", "api-surface", "-j")
124 assert r.exit_code == 0, r.output
125 json.loads(r.output) # must not raise
126
127 def test_j_alias_list_mode_has_results_key(self, api_repo: pathlib.Path) -> None:
128 r = _run(api_repo, "code", "api-surface", "-j")
129 data = json.loads(r.output)
130 assert "results" in data
131
132 def test_j_alias_diff_mode_exits_zero(self, api_repo: pathlib.Path) -> None:
133 ids = _commit_ids(api_repo)
134 assert len(ids) >= 2
135 r = _run(api_repo, "code", "api-surface", "-j", "--diff", ids[-1])
136 assert r.exit_code == 0, r.output
137
138 def test_j_alias_diff_mode_valid_json(self, api_repo: pathlib.Path) -> None:
139 ids = _commit_ids(api_repo)
140 r = _run(api_repo, "code", "api-surface", "-j", "--diff", ids[-1])
141 json.loads(r.output) # must not raise
142
143 def test_j_alias_list_same_keys_as_json_flag(self, api_repo: pathlib.Path) -> None:
144 r1 = _run(api_repo, "code", "api-surface", "--json")
145 r2 = _run(api_repo, "code", "api-surface", "-j")
146 d1 = json.loads(r1.output)
147 d2 = json.loads(r2.output)
148 d1.pop("duration_ms", None)
149 d2.pop("duration_ms", None)
150 assert set(d1.keys()) == set(d2.keys())
151
152 def test_j_alias_diff_same_keys_as_json_flag(self, api_repo: pathlib.Path) -> None:
153 ids = _commit_ids(api_repo)
154 r1 = _run(api_repo, "code", "api-surface", "--json", "--diff", ids[-1])
155 r2 = _run(api_repo, "code", "api-surface", "-j", "--diff", ids[-1])
156 d1 = json.loads(r1.output)
157 d2 = json.loads(r2.output)
158 d1.pop("duration_ms", None)
159 d2.pop("duration_ms", None)
160 assert set(d1.keys()) == set(d2.keys())
161
162
163 # ---------------------------------------------------------------------------
164 # TestDurationMs — every JSON path emits duration_ms
165 # ---------------------------------------------------------------------------
166
167
168 class TestDurationMs:
169 """Every JSON output path must include a non-negative float duration_ms."""
170
171 def test_list_json_has_duration_ms(self, api_repo: pathlib.Path) -> None:
172 r = _run(api_repo, "code", "api-surface", "--json")
173 data = json.loads(r.output)
174 assert "duration_ms" in data
175
176 def test_list_json_duration_ms_nonnegative(self, api_repo: pathlib.Path) -> None:
177 r = _run(api_repo, "code", "api-surface", "--json")
178 data = json.loads(r.output)
179 assert data["duration_ms"] >= 0
180
181 def test_list_json_duration_ms_is_float(self, api_repo: pathlib.Path) -> None:
182 r = _run(api_repo, "code", "api-surface", "--json")
183 data = json.loads(r.output)
184 assert isinstance(data["duration_ms"], float)
185
186 def test_diff_json_has_duration_ms(self, api_repo: pathlib.Path) -> None:
187 ids = _commit_ids(api_repo)
188 r = _run(api_repo, "code", "api-surface", "--json", "--diff", ids[-1])
189 data = json.loads(r.output)
190 assert "duration_ms" in data
191
192 def test_diff_json_duration_ms_nonnegative(self, api_repo: pathlib.Path) -> None:
193 ids = _commit_ids(api_repo)
194 r = _run(api_repo, "code", "api-surface", "--json", "--diff", ids[-1])
195 data = json.loads(r.output)
196 assert data["duration_ms"] >= 0
197
198 def test_diff_json_duration_ms_is_float(self, api_repo: pathlib.Path) -> None:
199 ids = _commit_ids(api_repo)
200 r = _run(api_repo, "code", "api-surface", "--json", "--diff", ids[-1])
201 data = json.loads(r.output)
202 assert isinstance(data["duration_ms"], float)
203
204 def test_j_alias_duration_ms_present(self, api_repo: pathlib.Path) -> None:
205 r = _run(api_repo, "code", "api-surface", "-j")
206 data = json.loads(r.output)
207 assert "duration_ms" in data
208
209
210 # ---------------------------------------------------------------------------
211 # TestExitCode — every JSON path emits exit_code
212 # ---------------------------------------------------------------------------
213
214
215 class TestExitCode:
216 """Every JSON output path must include exit_code; 0 on success."""
217
218 def test_list_json_has_exit_code(self, api_repo: pathlib.Path) -> None:
219 r = _run(api_repo, "code", "api-surface", "--json")
220 data = json.loads(r.output)
221 assert "exit_code" in data
222
223 def test_list_json_exit_code_zero_on_success(self, api_repo: pathlib.Path) -> None:
224 r = _run(api_repo, "code", "api-surface", "--json")
225 assert r.exit_code == 0
226 data = json.loads(r.output)
227 assert data["exit_code"] == 0
228
229 def test_list_json_exit_code_is_int(self, api_repo: pathlib.Path) -> None:
230 r = _run(api_repo, "code", "api-surface", "--json")
231 data = json.loads(r.output)
232 assert isinstance(data["exit_code"], int)
233
234 def test_diff_json_has_exit_code(self, api_repo: pathlib.Path) -> None:
235 ids = _commit_ids(api_repo)
236 r = _run(api_repo, "code", "api-surface", "--json", "--diff", ids[-1])
237 data = json.loads(r.output)
238 assert "exit_code" in data
239
240 def test_diff_json_exit_code_zero_on_success(self, api_repo: pathlib.Path) -> None:
241 ids = _commit_ids(api_repo)
242 r = _run(api_repo, "code", "api-surface", "--json", "--diff", ids[-1])
243 # exit_code in JSON is 0 even when there are breaking changes
244 # (breaking changes cause a non-zero process exit only with --breaking flag)
245 data = json.loads(r.output)
246 assert data["exit_code"] == 0
247
248 def test_diff_json_exit_code_is_int(self, api_repo: pathlib.Path) -> None:
249 ids = _commit_ids(api_repo)
250 r = _run(api_repo, "code", "api-surface", "--json", "--diff", ids[-1])
251 data = json.loads(r.output)
252 assert isinstance(data["exit_code"], int)
253
254 def test_list_exit_code_mirrors_process_exit(self, api_repo: pathlib.Path) -> None:
255 r = _run(api_repo, "code", "api-surface", "--json")
256 data = json.loads(r.output)
257 assert data["exit_code"] == r.exit_code
258
259 def test_j_alias_exit_code_present(self, api_repo: pathlib.Path) -> None:
260 r = _run(api_repo, "code", "api-surface", "-j")
261 data = json.loads(r.output)
262 assert "exit_code" in data
263
264
265 # ---------------------------------------------------------------------------
266 # TestTypedDicts — envelope TypedDicts exist with the required fields
267 # ---------------------------------------------------------------------------
268
269
270 class TestTypedDicts:
271 """_ListJson and _DiffJson TypedDicts must exist and carry exit_code/duration_ms."""
272
273 def test_list_json_typed_dict_exists(self) -> None:
274 from muse.cli.commands.api_surface import _ListJson # noqa: F401
275
276 def test_list_json_has_exit_code_annotation(self) -> None:
277 from muse.cli.commands.api_surface import _ListJson
278 assert "exit_code" in _ListJson.__annotations__
279
280 def test_list_json_has_duration_ms_annotation(self) -> None:
281 from muse.cli.commands.api_surface import _ListJson
282 assert "duration_ms" in _ListJson.__annotations__
283
284 def test_list_json_has_results_annotation(self) -> None:
285 from muse.cli.commands.api_surface import _ListJson
286 assert "results" in _ListJson.__annotations__
287
288 def test_diff_json_typed_dict_exists(self) -> None:
289 from muse.cli.commands.api_surface import _DiffJson # noqa: F401
290
291 def test_diff_json_has_exit_code_annotation(self) -> None:
292 from muse.cli.commands.api_surface import _DiffJson
293 assert "exit_code" in _DiffJson.__annotations__
294
295 def test_diff_json_has_duration_ms_annotation(self) -> None:
296 from muse.cli.commands.api_surface import _DiffJson
297 assert "duration_ms" in _DiffJson.__annotations__
298
299 def test_diff_json_has_semver_impact_annotation(self) -> None:
300 from muse.cli.commands.api_surface import _DiffJson
301 assert "semver_impact" in _DiffJson.__annotations__
302
303 def test_diff_json_has_breaking_count_annotation(self) -> None:
304 from muse.cli.commands.api_surface import _DiffJson
305 assert "breaking_count" in _DiffJson.__annotations__
306
307 def test_public_symbol_dict_exists(self) -> None:
308 from muse.cli.commands.api_surface import _PublicSymbolDict # noqa: F401
309
310
311 # ---------------------------------------------------------------------------
312 # TestDocstrings — run() docstring documents new fields
313 # ---------------------------------------------------------------------------
314
315
316 class TestDocstrings:
317 """run() must document exit_code and duration_ms in its docstring."""
318
319 def test_run_docstring_mentions_exit_code(self) -> None:
320 from muse.cli.commands.api_surface import run
321 assert run.__doc__ is not None
322 assert "exit_code" in run.__doc__
323
324 def test_run_docstring_mentions_duration_ms(self) -> None:
325 from muse.cli.commands.api_surface import run
326 assert run.__doc__ is not None
327 assert "duration_ms" in run.__doc__
328
329
330 # ---------------------------------------------------------------------------
331 # TestSchema — diff JSON shape and value constraints
332 # ---------------------------------------------------------------------------
333
334
335 class TestSchema:
336 """Validate the shape and value constraints of the diff JSON output."""
337
338 def test_diff_json_semver_impact_valid(self, api_repo: pathlib.Path) -> None:
339 ids = _commit_ids(api_repo)
340 r = _run(api_repo, "code", "api-surface", "--json", "--diff", ids[-1])
341 data = json.loads(r.output)
342 assert data["semver_impact"] in ("MAJOR", "MINOR", "PATCH", "NONE")
343
344 def test_diff_json_stability_pct_in_range(self, api_repo: pathlib.Path) -> None:
345 ids = _commit_ids(api_repo)
346 r = _run(api_repo, "code", "api-surface", "--json", "--diff", ids[-1])
347 data = json.loads(r.output)
348 assert 0 <= data["stability_pct"] <= 100
349
350 def test_diff_json_breaking_count_is_int(self, api_repo: pathlib.Path) -> None:
351 ids = _commit_ids(api_repo)
352 r = _run(api_repo, "code", "api-surface", "--json", "--diff", ids[-1])
353 data = json.loads(r.output)
354 assert isinstance(data["breaking_count"], int)
355 assert data["breaking_count"] >= 0
356
357 def test_diff_json_added_is_list(self, api_repo: pathlib.Path) -> None:
358 ids = _commit_ids(api_repo)
359 r = _run(api_repo, "code", "api-surface", "--json", "--diff", ids[-1])
360 data = json.loads(r.output)
361 assert isinstance(data["added"], list)
362
363 def test_diff_json_removed_is_list(self, api_repo: pathlib.Path) -> None:
364 ids = _commit_ids(api_repo)
365 r = _run(api_repo, "code", "api-surface", "--json", "--diff", ids[-1])
366 data = json.loads(r.output)
367 assert isinstance(data["removed"], list)
368
369 def test_diff_json_changed_is_list(self, api_repo: pathlib.Path) -> None:
370 ids = _commit_ids(api_repo)
371 r = _run(api_repo, "code", "api-surface", "--json", "--diff", ids[-1])
372 data = json.loads(r.output)
373 assert isinstance(data["changed"], list)
374
375 def test_diff_json_changed_entries_have_breaking_flag(self, api_repo: pathlib.Path) -> None:
376 ids = _commit_ids(api_repo)
377 r = _run(api_repo, "code", "api-surface", "--json", "--diff", ids[-1])
378 data = json.loads(r.output)
379 for entry in data["changed"]:
380 assert "breaking" in entry
381 assert isinstance(entry["breaking"], bool)
382
383 def test_list_json_total_matches_results_len(self, api_repo: pathlib.Path) -> None:
384 r = _run(api_repo, "code", "api-surface", "--json")
385 data = json.loads(r.output)
386 assert data["total"] == len(data["results"])
387
388 def test_list_json_results_have_address_and_kind(self, api_repo: pathlib.Path) -> None:
389 r = _run(api_repo, "code", "api-surface", "--json")
390 data = json.loads(r.output)
391 for entry in data["results"]:
392 assert "address" in entry
393 assert "kind" in entry
394
395
396 # ---------------------------------------------------------------------------
397 # TestAnsiSanitization — JSON fields must not contain terminal escape codes
398 # ---------------------------------------------------------------------------
399
400
401 class TestAnsiSanitization:
402 """No ANSI escape sequences in JSON string fields."""
403
404 def test_list_json_no_ansi_in_output(self, api_repo: pathlib.Path) -> None:
405 r = _run(api_repo, "code", "api-surface", "--json")
406 assert "\x1b" not in r.output
407
408 def test_diff_json_no_ansi_in_output(self, api_repo: pathlib.Path) -> None:
409 ids = _commit_ids(api_repo)
410 r = _run(api_repo, "code", "api-surface", "--json", "--diff", ids[-1])
411 assert "\x1b" not in r.output
412
413 def test_list_json_addresses_no_ansi(self, api_repo: pathlib.Path) -> None:
414 r = _run(api_repo, "code", "api-surface", "--json")
415 data = json.loads(r.output)
416 for entry in data["results"]:
417 assert "\x1b" not in entry["address"]
418
419
420 # ---------------------------------------------------------------------------
421 # TestPerformance — duration_ms stays in a reasonable range
422 # ---------------------------------------------------------------------------
423
424
425 class TestPerformance:
426 """duration_ms must be non-negative and under 1000 ms for small repos."""
427
428 def test_list_json_duration_under_1000ms(self, api_repo: pathlib.Path) -> None:
429 r = _run(api_repo, "code", "api-surface", "--json")
430 data = json.loads(r.output)
431 assert data["duration_ms"] < 1000
432
433 def test_diff_json_duration_under_1000ms(self, api_repo: pathlib.Path) -> None:
434 ids = _commit_ids(api_repo)
435 r = _run(api_repo, "code", "api-surface", "--json", "--diff", ids[-1])
436 data = json.loads(r.output)
437 assert data["duration_ms"] < 1000
438
439 def test_duration_ms_is_float_not_int(self, api_repo: pathlib.Path) -> None:
440 r = _run(api_repo, "code", "api-surface", "--json")
441 data = json.loads(r.output)
442 assert isinstance(data["duration_ms"], float)
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago