gabriel / muse public
test_invariants_supercharge.py python
351 lines 14.0 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 invariants`` — agent-usability gaps.
2
3 Existing tests (test_cmd_invariants.py) cover rule types, violation detection,
4 --strict, --rule filter, no-rules file default, JSON schema basics.
5
6 This file targets only the gaps those tests leave open:
7
8 Coverage matrix
9 ---------------
10 - --json / -j: -j alias works identically to --json
11 - exit_code: JSON output includes exit_code reflecting violation status
12 (0 = all pass / warnings only; 1 = errors or strict+warnings)
13 - duration_ms: JSON output includes non-negative float duration_ms
14 - TypedDicts: _InvariantsOutputJson carries exit_code and duration_ms
15 - Docstrings: run() docstring mentions exit_code and duration_ms
16 - ANSI: JSON output never contains terminal escape sequences
17 - Performance: duration_ms stays under 2000 ms for a small repo
18 - Early-exit paths: no-match --rule filter emits exit_code and duration_ms
19 - HEAD~N ref syntax: --commit HEAD~1 must not crash (was raising ValueError)
20 """
21
22 from __future__ import annotations
23
24 import json
25 import pathlib
26 import textwrap
27
28 import pytest
29
30 from tests.cli_test_helper import CliRunner
31
32 runner = CliRunner()
33
34
35 # ---------------------------------------------------------------------------
36 # Helpers
37 # ---------------------------------------------------------------------------
38
39
40 def _env(root: pathlib.Path) -> dict[str, str]:
41 return {"MUSE_REPO_ROOT": str(root)}
42
43
44 def _run(root: pathlib.Path, *args: str):
45 return runner.invoke(None, list(args), env=_env(root))
46
47
48 # ---------------------------------------------------------------------------
49 # Fixture — minimal Python repo (no invariants violations)
50 # ---------------------------------------------------------------------------
51
52
53 @pytest.fixture()
54 def inv_repo(
55 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
56 ) -> pathlib.Path:
57 """Minimal repo — clean Python files, no circular imports."""
58 monkeypatch.chdir(tmp_path)
59 r = _run(tmp_path, "init", "--domain", "code")
60 assert r.exit_code == 0, r.output
61
62 (tmp_path / "core.py").write_text(textwrap.dedent("""\
63 def compute(x):
64 return x * 2
65 """))
66 (tmp_path / "service.py").write_text(textwrap.dedent("""\
67 from core import compute
68
69 def process(x):
70 return compute(x)
71 """))
72 r = _run(tmp_path, "code", "add", ".")
73 assert r.exit_code == 0, r.output
74 r = _run(tmp_path, "commit", "-m", "seed invariants repo")
75 assert r.exit_code == 0, r.output
76
77 return tmp_path
78
79
80 # ---------------------------------------------------------------------------
81 # TestJsonAlias — -j works identically to --json
82 # ---------------------------------------------------------------------------
83
84
85 class TestJsonAlias:
86 """-j shorthand must behave identically to --json."""
87
88 def test_j_alias_exits_zero(self, inv_repo: pathlib.Path) -> None:
89 r = _run(inv_repo, "code", "invariants", "-j")
90 assert r.exit_code == 0, r.output
91
92 def test_j_alias_valid_json(self, inv_repo: pathlib.Path) -> None:
93 r = _run(inv_repo, "code", "invariants", "-j")
94 json.loads(r.output) # must not raise
95
96 def test_j_alias_has_violations_key(self, inv_repo: pathlib.Path) -> None:
97 r = _run(inv_repo, "code", "invariants", "-j")
98 assert "violations" in json.loads(r.output)
99
100 def test_j_alias_has_errors_key(self, inv_repo: pathlib.Path) -> None:
101 r = _run(inv_repo, "code", "invariants", "-j")
102 assert "errors" in json.loads(r.output)
103
104 def test_j_alias_same_top_level_keys_as_json_flag(
105 self, inv_repo: pathlib.Path
106 ) -> None:
107 r1 = _run(inv_repo, "code", "invariants", "--json")
108 r2 = _run(inv_repo, "code", "invariants", "-j")
109 d1 = json.loads(r1.output)
110 d2 = json.loads(r2.output)
111 d1.pop("duration_ms", None)
112 d2.pop("duration_ms", None)
113 assert set(d1.keys()) == set(d2.keys())
114
115 def test_j_alias_violations_is_list(self, inv_repo: pathlib.Path) -> None:
116 r = _run(inv_repo, "code", "invariants", "-j")
117 data = json.loads(r.output)
118 assert isinstance(data["violations"], list)
119
120 def test_j_alias_rules_checked_positive(self, inv_repo: pathlib.Path) -> None:
121 r = _run(inv_repo, "code", "invariants", "-j")
122 data = json.loads(r.output)
123 assert data["rules_checked"] >= 0
124
125
126 # ---------------------------------------------------------------------------
127 # TestDurationMs — JSON output must include duration_ms
128 # ---------------------------------------------------------------------------
129
130
131 class TestDurationMs:
132 """JSON output must include a non-negative float duration_ms."""
133
134 def test_json_has_duration_ms(self, inv_repo: pathlib.Path) -> None:
135 r = _run(inv_repo, "code", "invariants", "--json")
136 assert "duration_ms" in json.loads(r.output)
137
138 def test_json_duration_ms_nonnegative(self, inv_repo: pathlib.Path) -> None:
139 r = _run(inv_repo, "code", "invariants", "--json")
140 assert json.loads(r.output)["duration_ms"] >= 0
141
142 def test_json_duration_ms_is_float(self, inv_repo: pathlib.Path) -> None:
143 r = _run(inv_repo, "code", "invariants", "--json")
144 assert isinstance(json.loads(r.output)["duration_ms"], float)
145
146 def test_j_alias_duration_ms_present(self, inv_repo: pathlib.Path) -> None:
147 r = _run(inv_repo, "code", "invariants", "-j")
148 assert "duration_ms" in json.loads(r.output)
149
150 def test_duration_ms_under_2000ms(self, inv_repo: pathlib.Path) -> None:
151 r = _run(inv_repo, "code", "invariants", "--json")
152 assert json.loads(r.output)["duration_ms"] < 2000
153
154
155 # ---------------------------------------------------------------------------
156 # TestExitCode — JSON includes exit_code reflecting violation status
157 # ---------------------------------------------------------------------------
158
159
160 class TestExitCode:
161 """JSON exit_code must mirror the process exit code."""
162
163 def test_json_has_exit_code(self, inv_repo: pathlib.Path) -> None:
164 r = _run(inv_repo, "code", "invariants", "--json")
165 assert "exit_code" in json.loads(r.output)
166
167 def test_json_exit_code_zero_clean_repo(self, inv_repo: pathlib.Path) -> None:
168 r = _run(inv_repo, "code", "invariants", "--json")
169 assert r.exit_code == 0
170 assert json.loads(r.output)["exit_code"] == 0
171
172 def test_json_exit_code_is_int(self, inv_repo: pathlib.Path) -> None:
173 r = _run(inv_repo, "code", "invariants", "--json")
174 assert isinstance(json.loads(r.output)["exit_code"], int)
175
176 def test_j_alias_exit_code_present(self, inv_repo: pathlib.Path) -> None:
177 r = _run(inv_repo, "code", "invariants", "-j")
178 assert "exit_code" in json.loads(r.output)
179
180 def test_exit_code_mirrors_process_exit(self, inv_repo: pathlib.Path) -> None:
181 r = _run(inv_repo, "code", "invariants", "--json")
182 assert json.loads(r.output)["exit_code"] == r.exit_code
183
184
185 # ---------------------------------------------------------------------------
186 # TestTypedDicts — _InvariantsOutputJson carries exit_code and duration_ms
187 # ---------------------------------------------------------------------------
188
189
190 class TestTypedDicts:
191 """_InvariantsOutputJson must carry exit_code and duration_ms annotations."""
192
193 def test_invariants_output_json_typeddict_exists(self) -> None:
194 from muse.cli.commands.invariants import _InvariantsOutputJson # noqa: F401
195
196 def test_has_exit_code_annotation(self) -> None:
197 from muse.cli.commands.invariants import _InvariantsOutputJson
198 assert "exit_code" in _InvariantsOutputJson.__annotations__
199
200 def test_has_duration_ms_annotation(self) -> None:
201 from muse.cli.commands.invariants import _InvariantsOutputJson
202 assert "duration_ms" in _InvariantsOutputJson.__annotations__
203
204 def test_has_violations_annotation(self) -> None:
205 from muse.cli.commands.invariants import _InvariantsOutputJson
206 assert "violations" in _InvariantsOutputJson.__annotations__
207
208 def test_has_errors_annotation(self) -> None:
209 from muse.cli.commands.invariants import _InvariantsOutputJson
210 assert "errors" in _InvariantsOutputJson.__annotations__
211
212 def test_has_warnings_annotation(self) -> None:
213 from muse.cli.commands.invariants import _InvariantsOutputJson
214 assert "warnings" in _InvariantsOutputJson.__annotations__
215
216
217 # ---------------------------------------------------------------------------
218 # TestDocstrings — run() docstring documents exit_code and duration_ms
219 # ---------------------------------------------------------------------------
220
221
222 class TestDocstrings:
223 """run() must document exit_code and duration_ms."""
224
225 def test_run_docstring_mentions_exit_code(self) -> None:
226 from muse.cli.commands.invariants import run
227 assert run.__doc__ is not None
228 assert "exit_code" in run.__doc__
229
230 def test_run_docstring_mentions_duration_ms(self) -> None:
231 from muse.cli.commands.invariants import run
232 assert run.__doc__ is not None
233 assert "duration_ms" in run.__doc__
234
235
236 # ---------------------------------------------------------------------------
237 # TestAnsiSanitization — no escape codes in JSON output
238 # ---------------------------------------------------------------------------
239
240
241 class TestAnsiSanitization:
242 """No ANSI escape sequences anywhere in the JSON output."""
243
244 def test_json_output_no_ansi(self, inv_repo: pathlib.Path) -> None:
245 r = _run(inv_repo, "code", "invariants", "--json")
246 assert "\x1b" not in r.output
247
248 def test_j_alias_output_no_ansi(self, inv_repo: pathlib.Path) -> None:
249 r = _run(inv_repo, "code", "invariants", "-j")
250 assert "\x1b" not in r.output
251
252
253 # ---------------------------------------------------------------------------
254 # TestPerformance — duration_ms under 2000 ms for small repo
255 # ---------------------------------------------------------------------------
256
257
258 class TestPerformance:
259 """duration_ms must stay under 2000 ms for small repos."""
260
261 def test_json_duration_under_2000ms(self, inv_repo: pathlib.Path) -> None:
262 r = _run(inv_repo, "code", "invariants", "--json")
263 assert json.loads(r.output)["duration_ms"] < 2000
264
265 def test_duration_ms_is_float_not_int(self, inv_repo: pathlib.Path) -> None:
266 r = _run(inv_repo, "code", "invariants", "--json")
267 assert isinstance(json.loads(r.output)["duration_ms"], float)
268
269
270 # ---------------------------------------------------------------------------
271 # TestEarlyExitPaths — no-match and no-rules paths must include envelope fields
272 # ---------------------------------------------------------------------------
273
274
275 class TestEarlyExitPaths:
276 """Every JSON-emitting code path must include exit_code and duration_ms."""
277
278 def test_no_match_rule_filter_has_exit_code(self, inv_repo: pathlib.Path) -> None:
279 r = _run(inv_repo, "code", "invariants", "--rule", "zzz_no_such_rule", "-j")
280 assert r.exit_code == 0, r.output
281 data = json.loads(r.output)
282 assert "exit_code" in data
283 assert data["exit_code"] == 0
284
285 def test_no_match_rule_filter_has_duration_ms(self, inv_repo: pathlib.Path) -> None:
286 r = _run(inv_repo, "code", "invariants", "--rule", "zzz_no_such_rule", "-j")
287 data = json.loads(r.output)
288 assert "duration_ms" in data
289 assert isinstance(data["duration_ms"], float)
290 assert data["duration_ms"] >= 0
291
292 def test_no_match_rule_filter_has_error_field(self, inv_repo: pathlib.Path) -> None:
293 r = _run(inv_repo, "code", "invariants", "--rule", "zzz_no_such_rule", "-j")
294 data = json.loads(r.output)
295 assert data.get("error") == "no_matching_rules"
296
297 def test_no_match_rule_filter_no_ansi(self, inv_repo: pathlib.Path) -> None:
298 r = _run(inv_repo, "code", "invariants", "--rule", "zzz_no_such_rule", "-j")
299 assert "\x1b" not in r.output
300
301
302 # ---------------------------------------------------------------------------
303 # TestRelativeRefSyntax — HEAD~N must not crash
304 # ---------------------------------------------------------------------------
305
306
307 class TestRelativeRefSyntax:
308 """--commit HEAD~N and similar relative refs must not raise ValueError."""
309
310 @pytest.fixture()
311 def two_commit_repo(
312 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
313 ) -> pathlib.Path:
314 """Repo with two commits so HEAD~1 resolves to a real commit."""
315 monkeypatch.chdir(tmp_path)
316 r = _run(tmp_path, "init", "--domain", "code")
317 assert r.exit_code == 0, r.output
318
319 (tmp_path / "core.py").write_text("def compute(x):\n return x * 2\n")
320 r = _run(tmp_path, "code", "add", ".")
321 assert r.exit_code == 0, r.output
322 r = _run(tmp_path, "commit", "-m", "first")
323 assert r.exit_code == 0, r.output
324
325 (tmp_path / "service.py").write_text("def process(x):\n return x\n")
326 r = _run(tmp_path, "code", "add", ".")
327 assert r.exit_code == 0, r.output
328 r = _run(tmp_path, "commit", "-m", "second")
329 assert r.exit_code == 0, r.output
330
331 return tmp_path
332
333 def test_head_tilde_1_does_not_crash(self, two_commit_repo: pathlib.Path) -> None:
334 r = _run(two_commit_repo, "code", "invariants", "--commit", "HEAD~1", "-j")
335 # Must not crash with ValueError — exit 0 or 1 (depending on violations)
336 assert r.exit_code in (0, 1), r.output
337
338 def test_head_tilde_1_emits_valid_json(self, two_commit_repo: pathlib.Path) -> None:
339 r = _run(two_commit_repo, "code", "invariants", "--commit", "HEAD~1", "-j")
340 assert r.exit_code in (0, 1), r.output
341 json.loads(r.output) # must not raise
342
343 def test_head_tilde_1_has_exit_code(self, two_commit_repo: pathlib.Path) -> None:
344 r = _run(two_commit_repo, "code", "invariants", "--commit", "HEAD~1", "-j")
345 assert "exit_code" in json.loads(r.output)
346
347 def test_head_tilde_1_has_duration_ms(self, two_commit_repo: pathlib.Path) -> None:
348 r = _run(two_commit_repo, "code", "invariants", "--commit", "HEAD~1", "-j")
349 data = json.loads(r.output)
350 assert "duration_ms" in data
351 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