gabriel / muse public
test_code_check_supercharge.py python
357 lines 14.1 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
1 """Supercharge tests for ``muse code code-check`` — agent-usability gaps.
2
3 The existing test_cmd_code_check.py covers correctness, JSON schema, filters,
4 --diff, --rules, security, edge cases, and stress. This file targets only
5 the gaps those tests leave open:
6
7 Coverage matrix
8 ---------------
9 - --json / -j: -j alias works identically to --json
10 - exit_code: JSON output includes exit_code mirroring process exit
11 (0 normally; 1 when --strict and has_errors)
12 - duration_ms: JSON output includes non-negative float duration_ms
13 - TypedDicts: _CodeCheckOutputJson gains exit_code/duration_ms annotations
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 2000 ms for a small repo
17
18 Critical distinction: exit_code is NOT hardcoded to 0. When --strict is
19 active and violations with severity=error are found, both the process and
20 the JSON exit_code equal 1.
21 """
22
23 from __future__ import annotations
24
25 import json
26 import pathlib
27 import textwrap
28
29 import pytest
30
31 from tests.cli_test_helper import CliRunner
32
33 runner = CliRunner()
34
35
36 # ---------------------------------------------------------------------------
37 # Helpers
38 # ---------------------------------------------------------------------------
39
40
41 def _env(root: pathlib.Path) -> dict[str, str]:
42 return {"MUSE_REPO_ROOT": str(root)}
43
44
45 def _run(root: pathlib.Path, *args: str):
46 return runner.invoke(None, list(args), env=_env(root))
47
48
49 def _stage_commit(root: pathlib.Path, msg: str = "commit") -> None:
50 r = _run(root, "code", "add", ".")
51 assert r.exit_code == 0, r.output
52 r2 = _run(root, "commit", "-m", msg)
53 assert r2.exit_code == 0, r2.output
54
55
56 # ---------------------------------------------------------------------------
57 # Fixtures
58 # ---------------------------------------------------------------------------
59
60
61 @pytest.fixture()
62 def clean_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
63 """Code-domain repo with one clean Python file — no violations."""
64 monkeypatch.chdir(tmp_path)
65 r = _run(tmp_path, "init", "--domain", "code")
66 assert r.exit_code == 0, r.output
67 (tmp_path / "clean.py").write_text("def hello():\n return 'hello'\n")
68 _stage_commit(tmp_path, "clean")
69 return tmp_path
70
71
72 def _complex_func(n_branches: int = 12) -> str:
73 """Python source with cyclomatic complexity > 10 (triggers max_complexity rule)."""
74 lines = ["def heavy(x: int) -> int:", " if x == 1:", " return 1"]
75 for i in range(2, n_branches + 1):
76 lines.append(f" elif x == {i}:")
77 lines.append(f" return {i}")
78 lines.append(" return 0")
79 return "\n".join(lines) + "\n"
80
81
82 @pytest.fixture()
83 def violation_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
84 """Repo whose HEAD commit has a high-complexity function → error violation.
85
86 Writes a custom code_invariants.toml that raises max_complexity to severity=error
87 so that --strict exits 1 and has_errors is True.
88 """
89 monkeypatch.chdir(tmp_path)
90 r = _run(tmp_path, "init", "--domain", "code")
91 assert r.exit_code == 0, r.output
92 muse_dir = tmp_path / ".muse"
93 muse_dir.mkdir(exist_ok=True)
94 (muse_dir / "code_invariants.toml").write_text(
95 '[[rule]]\nname = "complexity_gate"\nseverity = "error"\n'
96 'rule_type = "max_complexity"\n[rule.params]\nthreshold = 10\n'
97 )
98 (tmp_path / "complex.py").write_text(_complex_func())
99 _stage_commit(tmp_path, "add complex function")
100 return tmp_path
101
102
103 # ---------------------------------------------------------------------------
104 # TestJsonAlias — -j works identically to --json
105 # ---------------------------------------------------------------------------
106
107
108 class TestJsonAlias:
109 """-j shorthand must behave identically to --json."""
110
111 def test_j_alias_exits_zero_clean(self, clean_repo: pathlib.Path) -> None:
112 r = _run(clean_repo, "code", "code-check", "-j")
113 assert r.exit_code == 0, r.output
114
115 def test_j_alias_valid_json(self, clean_repo: pathlib.Path) -> None:
116 r = _run(clean_repo, "code", "code-check", "-j")
117 json.loads(r.output) # must not raise
118
119 def test_j_alias_has_violations_key(self, clean_repo: pathlib.Path) -> None:
120 r = _run(clean_repo, "code", "code-check", "-j")
121 data = json.loads(r.output)
122 assert "violations" in data
123
124 def test_j_alias_has_has_errors_key(self, clean_repo: pathlib.Path) -> None:
125 r = _run(clean_repo, "code", "code-check", "-j")
126 data = json.loads(r.output)
127 assert "has_errors" in data
128
129 def test_j_alias_same_top_level_keys_as_json_flag(self, clean_repo: pathlib.Path) -> None:
130 r1 = _run(clean_repo, "code", "code-check", "--json")
131 r2 = _run(clean_repo, "code", "code-check", "-j")
132 d1 = json.loads(r1.output)
133 d2 = json.loads(r2.output)
134 d1.pop("duration_ms", None)
135 d2.pop("duration_ms", None)
136 assert set(d1.keys()) == set(d2.keys())
137
138 def test_j_alias_violation_count_matches(self, violation_repo: pathlib.Path) -> None:
139 r1 = _run(violation_repo, "code", "code-check", "--json")
140 r2 = _run(violation_repo, "code", "code-check", "-j")
141 d1 = json.loads(r1.output)
142 d2 = json.loads(r2.output)
143 assert len(d1["violations"]) == len(d2["violations"])
144
145 def test_j_alias_with_strict(self, clean_repo: pathlib.Path) -> None:
146 r = _run(clean_repo, "code", "code-check", "-j", "--strict")
147 assert r.exit_code == 0, r.output
148 json.loads(r.output)
149
150
151 # ---------------------------------------------------------------------------
152 # TestDurationMs — JSON output must include duration_ms
153 # ---------------------------------------------------------------------------
154
155
156 class TestDurationMs:
157 """JSON output must include a non-negative float duration_ms."""
158
159 def test_json_has_duration_ms(self, clean_repo: pathlib.Path) -> None:
160 r = _run(clean_repo, "code", "code-check", "--json")
161 data = json.loads(r.output)
162 assert "duration_ms" in data
163
164 def test_json_duration_ms_nonnegative(self, clean_repo: pathlib.Path) -> None:
165 r = _run(clean_repo, "code", "code-check", "--json")
166 data = json.loads(r.output)
167 assert data["duration_ms"] >= 0
168
169 def test_json_duration_ms_is_float(self, clean_repo: pathlib.Path) -> None:
170 r = _run(clean_repo, "code", "code-check", "--json")
171 data = json.loads(r.output)
172 assert isinstance(data["duration_ms"], float)
173
174 def test_j_alias_duration_ms_present(self, clean_repo: pathlib.Path) -> None:
175 r = _run(clean_repo, "code", "code-check", "-j")
176 data = json.loads(r.output)
177 assert "duration_ms" in data
178
179 def test_duration_ms_with_violations(self, violation_repo: pathlib.Path) -> None:
180 r = _run(violation_repo, "code", "code-check", "--json")
181 data = json.loads(r.output)
182 assert "duration_ms" in data
183 assert data["duration_ms"] >= 0
184
185 def test_duration_ms_with_filter(self, clean_repo: pathlib.Path) -> None:
186 r = _run(clean_repo, "code", "code-check", "--json", "--filter", "error")
187 data = json.loads(r.output)
188 assert "duration_ms" in data
189 assert data["duration_ms"] >= 0
190
191
192 # ---------------------------------------------------------------------------
193 # TestExitCode — JSON includes exit_code mirroring process exit
194 # ---------------------------------------------------------------------------
195
196
197 class TestExitCode:
198 """JSON exit_code must mirror the actual process exit code.
199
200 Without --strict: always 0, even if violations exist.
201 With --strict and error-severity violations: 1.
202 """
203
204 def test_json_has_exit_code(self, clean_repo: pathlib.Path) -> None:
205 r = _run(clean_repo, "code", "code-check", "--json")
206 data = json.loads(r.output)
207 assert "exit_code" in data
208
209 def test_json_exit_code_zero_clean_no_strict(self, clean_repo: pathlib.Path) -> None:
210 r = _run(clean_repo, "code", "code-check", "--json")
211 assert r.exit_code == 0
212 data = json.loads(r.output)
213 assert data["exit_code"] == 0
214
215 def test_json_exit_code_is_int(self, clean_repo: pathlib.Path) -> None:
216 r = _run(clean_repo, "code", "code-check", "--json")
217 data = json.loads(r.output)
218 assert isinstance(data["exit_code"], int)
219
220 def test_j_alias_exit_code_present(self, clean_repo: pathlib.Path) -> None:
221 r = _run(clean_repo, "code", "code-check", "-j")
222 data = json.loads(r.output)
223 assert "exit_code" in data
224
225 def test_exit_code_zero_with_violations_no_strict(
226 self, violation_repo: pathlib.Path
227 ) -> None:
228 """Violations without --strict → process exits 0, JSON exit_code == 0."""
229 r = _run(violation_repo, "code", "code-check", "--json")
230 assert r.exit_code == 0
231 data = json.loads(r.output)
232 assert data["exit_code"] == 0
233
234 def test_exit_code_one_strict_with_errors(self, violation_repo: pathlib.Path) -> None:
235 """--strict + error violations → process exits 1, JSON exit_code == 1."""
236 r = _run(violation_repo, "code", "code-check", "--json", "--strict")
237 assert r.exit_code == 1
238 data = json.loads(r.output)
239 assert data["exit_code"] == 1
240
241 def test_exit_code_mirrors_process_exit_clean(self, clean_repo: pathlib.Path) -> None:
242 r = _run(clean_repo, "code", "code-check", "--json")
243 data = json.loads(r.output)
244 assert data["exit_code"] == r.exit_code
245
246 def test_exit_code_mirrors_process_exit_strict_violations(
247 self, violation_repo: pathlib.Path
248 ) -> None:
249 r = _run(violation_repo, "code", "code-check", "--json", "--strict")
250 data = json.loads(r.output)
251 assert data["exit_code"] == r.exit_code
252
253 def test_exit_code_not_hardcoded_zero(self, violation_repo: pathlib.Path) -> None:
254 """Prove exit_code is computed, not a literal 0."""
255 r = _run(violation_repo, "code", "code-check", "--json", "--strict")
256 data = json.loads(r.output)
257 assert data["exit_code"] != 0
258
259
260 # ---------------------------------------------------------------------------
261 # TestTypedDicts — _CodeCheckOutputJson carries the new fields
262 # ---------------------------------------------------------------------------
263
264
265 class TestTypedDicts:
266 """_CodeCheckOutputJson must carry exit_code and duration_ms annotations."""
267
268 def test_code_check_output_json_exists(self) -> None:
269 from muse.cli.commands.code_check import _CodeCheckOutputJson # noqa: F401
270
271 def test_has_exit_code_annotation(self) -> None:
272 from muse.cli.commands.code_check import _CodeCheckOutputJson
273 assert "exit_code" in _CodeCheckOutputJson.__annotations__
274
275 def test_has_duration_ms_annotation(self) -> None:
276 from muse.cli.commands.code_check import _CodeCheckOutputJson
277 assert "duration_ms" in _CodeCheckOutputJson.__annotations__
278
279 def test_retains_violations_annotation(self) -> None:
280 from muse.cli.commands.code_check import _CodeCheckOutputJson
281 assert "violations" in _CodeCheckOutputJson.__annotations__
282
283 def test_retains_has_errors_annotation(self) -> None:
284 from muse.cli.commands.code_check import _CodeCheckOutputJson
285 assert "has_errors" in _CodeCheckOutputJson.__annotations__
286
287 def test_retains_commit_id_annotation(self) -> None:
288 from muse.cli.commands.code_check import _CodeCheckOutputJson
289 assert "commit_id" in _CodeCheckOutputJson.__annotations__
290
291 def test_retains_rules_checked_annotation(self) -> None:
292 from muse.cli.commands.code_check import _CodeCheckOutputJson
293 assert "rules_checked" in _CodeCheckOutputJson.__annotations__
294
295
296 # ---------------------------------------------------------------------------
297 # TestDocstrings — run() docstring documents new fields
298 # ---------------------------------------------------------------------------
299
300
301 class TestDocstrings:
302 """run() must document exit_code and duration_ms."""
303
304 def test_run_docstring_mentions_exit_code(self) -> None:
305 from muse.cli.commands.code_check import run
306 assert run.__doc__ is not None
307 assert "exit_code" in run.__doc__
308
309 def test_run_docstring_mentions_duration_ms(self) -> None:
310 from muse.cli.commands.code_check import run
311 assert run.__doc__ is not None
312 assert "duration_ms" in run.__doc__
313
314
315 # ---------------------------------------------------------------------------
316 # TestAnsiSanitization — no escape codes in JSON output
317 # ---------------------------------------------------------------------------
318
319
320 class TestAnsiSanitization:
321 """No ANSI escape sequences anywhere in the JSON output."""
322
323 def test_json_output_no_ansi_clean(self, clean_repo: pathlib.Path) -> None:
324 r = _run(clean_repo, "code", "code-check", "--json")
325 assert "\x1b" not in r.output
326
327 def test_j_alias_output_no_ansi(self, clean_repo: pathlib.Path) -> None:
328 r = _run(clean_repo, "code", "code-check", "-j")
329 assert "\x1b" not in r.output
330
331 def test_json_output_no_ansi_violations(self, violation_repo: pathlib.Path) -> None:
332 r = _run(violation_repo, "code", "code-check", "--json")
333 assert "\x1b" not in r.output
334
335
336 # ---------------------------------------------------------------------------
337 # TestPerformance — duration_ms under 2000 ms for a small repo
338 # ---------------------------------------------------------------------------
339
340
341 class TestPerformance:
342 """duration_ms must stay under 2000 ms for small repos."""
343
344 def test_json_duration_under_2000ms(self, clean_repo: pathlib.Path) -> None:
345 r = _run(clean_repo, "code", "code-check", "--json")
346 data = json.loads(r.output)
347 assert data["duration_ms"] < 2000
348
349 def test_j_alias_duration_under_2000ms(self, clean_repo: pathlib.Path) -> None:
350 r = _run(clean_repo, "code", "code-check", "-j")
351 data = json.loads(r.output)
352 assert data["duration_ms"] < 2000
353
354 def test_duration_ms_is_float_not_int(self, clean_repo: pathlib.Path) -> None:
355 r = _run(clean_repo, "code", "code-check", "--json")
356 data = json.loads(r.output)
357 assert isinstance(data["duration_ms"], float)
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago