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