gabriel / muse public
test_cmd_check.py python
947 lines 38.2 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Comprehensive tests for ``muse check`` — generic domain invariant enforcement.
2
3 Coverage dimensions
4 -------------------
5
6 Unit
7 ~~~~
8 - ``_get_checker``: returns CodeChecker for code, MidiChecker for midi, None
9 for unknown
10 - ``_resolve_ref``: HEAD, short SHA, HEAD~N, explicit branch, non-existent ref
11 - ``_filter_report``: filter by severity, rule name, path glob, combined
12 - ``_CheckJson`` TypedDict shape has all required fields
13 - ``format_report`` integration: zero violations, mixed violations
14
15 Integration (run / CLI)
16 ~~~~~~~~~~~~~~~~~~~~~~~
17 - Default invocation (HEAD, code domain) → exit 0
18 - ``--json`` output has all required keys with correct types
19 - ``--json`` duration_ms > 0
20 - ``--json`` error_count / warning_count / info_count are integers
21 - ``--json`` base_commit_id is None without --base
22 - ``--strict`` exits 1 when errors present
23 - ``--strict`` exits 0 when no errors
24 - ``--warn`` exits 2 when warnings present
25 - ``--warn`` exits 0 when no warnings
26 - ``--strict`` and ``--warn`` combined
27 - ``--base HEAD~1`` diff mode: no new violations on identical snapshots
28 - ``--base`` diff mode: JSON has base_commit_id set
29 - ``--base`` with bad ref exits non-zero with error
30 - ``--branch`` checks tip of another branch
31 - ``--filter-severity error`` narrows violations
32 - ``--filter-severity warning`` narrows violations
33 - ``--filter-rule`` keeps only matching rule
34 - ``--filter-path`` keeps only matching addresses
35 - ``--summary`` prints one-line pass/fail
36 - ``--summary --strict`` propagates exit code
37 - ``--rules`` custom TOML file used
38 - ``--rules`` path outside repo rejected (security)
39 - ``--rules`` absolute path outside repo rejected (security)
40 - ``--json --summary`` → json wins (--summary only affects text mode)
41
42 Commit resolution
43 ~~~~~~~~~~~~~~~~~
44 - Full 64-char SHA resolved correctly
45 - Short SHA prefix resolved correctly (HEAD is short prefix)
46 - HEAD~1 walks one parent
47 - HEAD~0 same as HEAD
48 - Non-existent ref exits 1 with error message
49 - Branch name resolves tip of that branch
50 - Empty repo (no commits) exits with error
51
52 Security
53 ~~~~~~~~
54 - ANSI escape in commit_arg stripped from display
55 - ANSI escape in domain name stripped from display
56 - ``--rules`` with ``../../../etc/passwd`` rejected
57 - ``--rules`` with absolute path outside repo rejected
58 - ``--filter-rule`` with ANSI escape doesn't crash
59 - ``--filter-path`` with ``/etc/*`` doesn't crash
60
61 Edge cases
62 ~~~~~~~~~~
63 - No commits on current branch → error message
64 - Unknown domain (not code/midi) → warning, exit 0
65 - Rules file that is a symlink outside repo → rejected
66 - ``--base`` same as HEAD → zero new violations
67 - ``--filter-severity`` with no matching violations → empty report, exit 0
68 - ``--json`` on fresh empty repo → error JSON, non-zero exit
69
70 Stress
71 ~~~~~~
72 - 200-violation report filtered correctly
73 - check with large TOML rules file (50 rules) doesn't crash
74 """
75
76 from __future__ import annotations
77
78 import datetime
79 import json
80 import pathlib
81
82 import msgpack
83 import pytest
84
85 from muse.core.invariants import BaseReport, BaseViolation, make_report
86 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
87 from muse.core.store import CommitRecord, SnapshotRecord, commit_path, write_commit, write_snapshot
88 from muse.core._types import Manifest, long_id, short_id, fake_id, blob_id
89 from muse.core.object_store import object_path
90 from tests.cli_test_helper import CliRunner
91
92 runner = CliRunner()
93 cli = None
94
95 _EPOCH = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
96
97
98 # ---------------------------------------------------------------------------
99 # Repo helpers
100 # ---------------------------------------------------------------------------
101
102
103 def _make_repo(tmp_path: pathlib.Path, domain: str = "code") -> pathlib.Path:
104 muse = tmp_path / ".muse"
105 for sub in ("objects", "commits", "snapshots", "refs/heads"):
106 (muse / sub).mkdir(parents=True, exist_ok=True)
107 (muse / "repo.json").write_text(
108 json.dumps({
109 "repo_id": fake_id("repo"),
110 "domain": domain,
111 "default_branch": "main",
112 "created_at": "2026-01-01T00:00:00+00:00",
113 }),
114 encoding="utf-8",
115 )
116 (muse / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
117 return tmp_path
118
119
120 def _write_commit_chain(
121 root: pathlib.Path,
122 n: int = 1,
123 branch: str = "main",
124 file_content: bytes = b"pass",
125 ) -> list[str]:
126 """Write *n* commits on *branch*, returning the list of commit IDs (oldest first)."""
127 commit_ids: list[str] = []
128 parent: str | None = None
129
130 for i in range(n):
131 content = file_content + f"\n# {i}".encode()
132 oid = blob_id(content)
133 p = object_path(root, oid)
134 p.parent.mkdir(parents=True, exist_ok=True)
135 p.write_bytes(content)
136
137 manifest = {"main.py": oid}
138 snap_id = compute_snapshot_id(manifest)
139 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
140
141 ts = (_EPOCH + datetime.timedelta(seconds=i)).isoformat()
142 commit_id = compute_commit_id(
143 repo_id="test",
144 parent_ids=[p for p in [parent] if p],
145 snapshot_id=snap_id,
146 message=f"commit {i}",
147 committed_at_iso=ts,
148 author="test",
149 )
150 data = {
151 "commit_id": commit_id,
152 "repo_id": "test",
153 "created_on_branch": branch,
154 "snapshot_id": snap_id,
155 "message": f"commit {i}",
156 "committed_at": ts,
157 "parent_commit_id": parent,
158 "parent2_commit_id": None,
159 "author": "test",
160 }
161 cp = commit_path(root, commit_id)
162 cp.parent.mkdir(parents=True, exist_ok=True)
163 cp.write_bytes(msgpack.packb(data, use_bin_type=True))
164 ref_path = root / ".muse" / "refs" / "heads" / branch
165 ref_path.parent.mkdir(parents=True, exist_ok=True)
166 ref_path.write_text(commit_id, encoding="utf-8")
167 commit_ids.append(commit_id)
168 parent = commit_id
169
170 return commit_ids
171
172
173 def _env(root: pathlib.Path) -> Manifest:
174 return {"MUSE_REPO_ROOT": str(root)}
175
176
177 def _invoke(root: pathlib.Path, *args: str) -> tuple[int, str]:
178 r = runner.invoke(cli, list(args), env=_env(root), catch_exceptions=False)
179 return r.exit_code, r.output
180
181
182 def _invoke_unchecked(root: pathlib.Path, *args: str) -> tuple[int, str]:
183 r = runner.invoke(cli, list(args), env=_env(root))
184 return r.exit_code, r.output
185
186
187 # ---------------------------------------------------------------------------
188 # Unit — _get_checker
189 # ---------------------------------------------------------------------------
190
191
192 class TestGetChecker:
193 def test_code_returns_code_checker(self) -> None:
194 from muse.cli.commands.check import _get_checker
195 from muse.plugins.code._invariants import CodeChecker
196 assert isinstance(_get_checker("code"), CodeChecker)
197
198 def test_midi_returns_midi_checker(self) -> None:
199 from muse.cli.commands.check import _get_checker
200 from muse.plugins.midi._invariants import MidiChecker
201 assert isinstance(_get_checker("midi"), MidiChecker)
202
203 def test_unknown_domain_returns_none(self) -> None:
204 from muse.cli.commands.check import _get_checker
205 assert _get_checker("genomics") is None
206 assert _get_checker("") is None
207 assert _get_checker("CODE") is None # case-sensitive
208
209
210 # ---------------------------------------------------------------------------
211 # Unit — _filter_report
212 # ---------------------------------------------------------------------------
213
214
215 class TestFilterReport:
216 def _make_report_with_violations(self) -> BaseReport:
217 violations: list[BaseViolation] = [
218 BaseViolation(rule_name="max_complexity", severity="error",
219 address="src/a.py::foo", description="too complex"),
220 BaseViolation(rule_name="max_complexity", severity="warning",
221 address="src/b.py::bar", description="complex"),
222 BaseViolation(rule_name="no_cycles", severity="error",
223 address="src/c.py", description="cycle"),
224 BaseViolation(rule_name="coverage", severity="info",
225 address="src/d.py", description="low coverage"),
226 ]
227 return make_report("a" * 64, "code", violations, 3)
228
229 def test_filter_by_severity_error(self) -> None:
230 from muse.cli.commands.check import _filter_report
231 report = self._make_report_with_violations()
232 filtered = _filter_report(report, filter_severity="error",
233 filter_rule=None, filter_path=None)
234 assert all(v["severity"] == "error" for v in filtered["violations"])
235 assert len(filtered["violations"]) == 2
236
237 def test_filter_by_severity_warning(self) -> None:
238 from muse.cli.commands.check import _filter_report
239 report = self._make_report_with_violations()
240 filtered = _filter_report(report, filter_severity="warning",
241 filter_rule=None, filter_path=None)
242 assert len(filtered["violations"]) == 1
243 assert filtered["violations"][0]["rule_name"] == "max_complexity"
244
245 def test_filter_by_severity_info(self) -> None:
246 from muse.cli.commands.check import _filter_report
247 report = self._make_report_with_violations()
248 filtered = _filter_report(report, filter_severity="info",
249 filter_rule=None, filter_path=None)
250 assert len(filtered["violations"]) == 1
251 assert filtered["violations"][0]["rule_name"] == "coverage"
252
253 def test_filter_by_rule_name(self) -> None:
254 from muse.cli.commands.check import _filter_report
255 report = self._make_report_with_violations()
256 filtered = _filter_report(report, filter_severity=None,
257 filter_rule="no_cycles", filter_path=None)
258 assert all(v["rule_name"] == "no_cycles" for v in filtered["violations"])
259 assert len(filtered["violations"]) == 1
260
261 def test_filter_by_path_glob(self) -> None:
262 from muse.cli.commands.check import _filter_report
263 report = self._make_report_with_violations()
264 filtered = _filter_report(report, filter_severity=None,
265 filter_rule=None, filter_path="src/a.py::*")
266 assert len(filtered["violations"]) == 1
267 assert filtered["violations"][0]["address"] == "src/a.py::foo"
268
269 def test_combined_filters(self) -> None:
270 from muse.cli.commands.check import _filter_report
271 report = self._make_report_with_violations()
272 filtered = _filter_report(report, filter_severity="error",
273 filter_rule="max_complexity", filter_path=None)
274 assert len(filtered["violations"]) == 1
275 assert filtered["violations"][0]["address"] == "src/a.py::foo"
276
277 def test_no_filters_returns_all(self) -> None:
278 from muse.cli.commands.check import _filter_report
279 report = self._make_report_with_violations()
280 filtered = _filter_report(report, filter_severity=None,
281 filter_rule=None, filter_path=None)
282 assert len(filtered["violations"]) == len(report["violations"])
283
284 def test_filter_no_match_returns_empty(self) -> None:
285 from muse.cli.commands.check import _filter_report
286 report = self._make_report_with_violations()
287 filtered = _filter_report(report, filter_severity="error",
288 filter_rule="nonexistent_rule", filter_path=None)
289 assert filtered["violations"] == []
290
291 def test_rules_checked_preserved_through_filter(self) -> None:
292 from muse.cli.commands.check import _filter_report
293 report = self._make_report_with_violations()
294 filtered = _filter_report(report, filter_severity="error",
295 filter_rule=None, filter_path=None)
296 assert filtered["rules_checked"] == report["rules_checked"]
297
298
299 # ---------------------------------------------------------------------------
300 # Unit — _CheckJson shape
301 # ---------------------------------------------------------------------------
302
303
304 class TestCheckJsonShape:
305 def test_required_keys_present(self, tmp_path: pathlib.Path) -> None:
306 root = _make_repo(tmp_path)
307 _write_commit_chain(root)
308 code, out = _invoke(root, "check", "--json")
309 assert code == 0
310 data = json.loads(out.strip())
311 required = {
312 "commit_id", "domain", "rules_checked", "has_errors",
313 "has_warnings", "error_count", "warning_count", "info_count",
314 "total_violations", "violations", "base_commit_id", "duration_ms",
315 "exit_code",
316 }
317 assert required <= set(data.keys())
318
319 def test_field_types(self, tmp_path: pathlib.Path) -> None:
320 root = _make_repo(tmp_path)
321 _write_commit_chain(root)
322 _, out = _invoke(root, "check", "--json")
323 d = json.loads(out.strip())
324 assert isinstance(d["commit_id"], str)
325 assert isinstance(d["domain"], str)
326 assert isinstance(d["rules_checked"], int)
327 assert isinstance(d["has_errors"], bool)
328 assert isinstance(d["has_warnings"], bool)
329 assert isinstance(d["error_count"], int)
330 assert isinstance(d["warning_count"], int)
331 assert isinstance(d["info_count"], int)
332 assert isinstance(d["total_violations"], int)
333 assert isinstance(d["violations"], list)
334 assert isinstance(d["duration_ms"], float)
335
336 def test_duration_ms_positive(self, tmp_path: pathlib.Path) -> None:
337 root = _make_repo(tmp_path)
338 _write_commit_chain(root)
339 _, out = _invoke(root, "check", "--json")
340 d = json.loads(out.strip())
341 assert d["duration_ms"] > 0.0
342
343 def test_base_commit_id_none_without_base_flag(self, tmp_path: pathlib.Path) -> None:
344 root = _make_repo(tmp_path)
345 _write_commit_chain(root)
346 _, out = _invoke(root, "check", "--json")
347 d = json.loads(out.strip())
348 assert d["base_commit_id"] is None
349
350 def test_counts_consistent_with_violations(self, tmp_path: pathlib.Path) -> None:
351 root = _make_repo(tmp_path)
352 _write_commit_chain(root)
353 _, out = _invoke(root, "check", "--json")
354 d = json.loads(out.strip())
355 total = d["error_count"] + d["warning_count"] + d["info_count"]
356 assert total == d["total_violations"]
357 assert len(d["violations"]) == d["total_violations"]
358
359
360 # ---------------------------------------------------------------------------
361 # Integration — basic invocation
362 # ---------------------------------------------------------------------------
363
364
365 class TestBasicInvocation:
366 def test_default_head_exits_zero(self, tmp_path: pathlib.Path) -> None:
367 root = _make_repo(tmp_path)
368 _write_commit_chain(root)
369 code, _ = _invoke(root, "check")
370 assert code == 0
371
372 def test_text_output_contains_domain(self, tmp_path: pathlib.Path) -> None:
373 root = _make_repo(tmp_path)
374 _write_commit_chain(root)
375 _, out = _invoke(root, "check")
376 assert "code" in out
377
378 def test_text_output_contains_rules_checked(self, tmp_path: pathlib.Path) -> None:
379 root = _make_repo(tmp_path)
380 _write_commit_chain(root)
381 _, out = _invoke(root, "check")
382 assert "rules" in out
383
384 def test_text_output_contains_commit_prefix(self, tmp_path: pathlib.Path) -> None:
385 root = _make_repo(tmp_path)
386 cids = _write_commit_chain(root)
387 _, out = _invoke(root, "check")
388 # check.py displays short_id(commit_id) — bare 12-char hex.
389 assert short_id(cids[-1], strip=True) in out
390
391 def test_text_output_has_elapsed_time(self, tmp_path: pathlib.Path) -> None:
392 root = _make_repo(tmp_path)
393 _write_commit_chain(root)
394 _, out = _invoke(root, "check")
395 assert "s)" in out # e.g. "(0.123s)"
396
397 def test_full_sha_argument(self, tmp_path: pathlib.Path) -> None:
398 root = _make_repo(tmp_path)
399 cids = _write_commit_chain(root)
400 code, _ = _invoke(root, "check", cids[-1])
401 assert code == 0
402
403 def test_short_sha_argument(self, tmp_path: pathlib.Path) -> None:
404 root = _make_repo(tmp_path)
405 cids = _write_commit_chain(root)
406 short = short_id(cids[-1], strip=True)
407 code, _ = _invoke(root, "check", short)
408 assert code == 0
409
410 def test_head_tilde_1(self, tmp_path: pathlib.Path) -> None:
411 root = _make_repo(tmp_path)
412 _write_commit_chain(root, n=3)
413 code, _ = _invoke(root, "check", "HEAD~1")
414 assert code == 0
415
416 def test_head_tilde_0(self, tmp_path: pathlib.Path) -> None:
417 root = _make_repo(tmp_path)
418 _write_commit_chain(root, n=2)
419 code, _ = _invoke(root, "check", "HEAD~0")
420 assert code == 0
421
422
423 # ---------------------------------------------------------------------------
424 # Integration — --strict and --warn
425 # ---------------------------------------------------------------------------
426
427
428 class TestStrictAndWarn:
429 def _make_clean_report_repo(self, tmp_path: pathlib.Path) -> pathlib.Path:
430 """Repo with a simple clean Python file — no violations expected."""
431 root = _make_repo(tmp_path)
432 _write_commit_chain(root, file_content=b"x = 1\n")
433 return root
434
435 def test_strict_exits_0_when_no_errors(self, tmp_path: pathlib.Path) -> None:
436 root = self._make_clean_report_repo(tmp_path)
437 code, _ = _invoke(root, "check", "--strict")
438 # Code domain with a clean file may still have warnings — strict only cares about errors.
439 assert code in (0, 1) # 0 if no errors, 1 if errors
440
441 def test_warn_flag_in_json(self, tmp_path: pathlib.Path) -> None:
442 root = _make_repo(tmp_path)
443 _write_commit_chain(root)
444 code, out = _invoke(root, "check", "--json")
445 d = json.loads(out.strip())
446 # JSON always has warning_count regardless of --warn flag.
447 assert "warning_count" in d
448
449 def test_strict_json_exit_code_consistent(self, tmp_path: pathlib.Path) -> None:
450 root = _make_repo(tmp_path)
451 _write_commit_chain(root)
452 code, out = _invoke(root, "check", "--strict", "--json")
453 d = json.loads(out.strip())
454 if d["has_errors"]:
455 assert code == 1
456 else:
457 assert code == 0
458
459
460 # ---------------------------------------------------------------------------
461 # Integration — --base diff mode
462 # ---------------------------------------------------------------------------
463
464
465 class TestBaseMode:
466 def test_same_commit_as_base_zero_new_violations(self, tmp_path: pathlib.Path) -> None:
467 root = _make_repo(tmp_path)
468 cids = _write_commit_chain(root, n=1)
469 # Base is same as HEAD → no new violations.
470 code, _ = _invoke(root, "check", "--base", cids[0])
471 assert code == 0
472
473 def test_base_head_tilde_1_on_identical_snapshots(self, tmp_path: pathlib.Path) -> None:
474 root = _make_repo(tmp_path)
475 _write_commit_chain(root, n=3)
476 # HEAD~1 and HEAD have the same file content → diff is zero violations.
477 code, out = _invoke(root, "check", "--base", "HEAD~1")
478 assert code == 0
479
480 def test_base_sets_base_commit_id_in_json(self, tmp_path: pathlib.Path) -> None:
481 root = _make_repo(tmp_path)
482 cids = _write_commit_chain(root, n=2)
483 _, out = _invoke(root, "check", "--base", "HEAD~1", "--json")
484 d = json.loads(out.strip())
485 assert d["base_commit_id"] == cids[0] # HEAD~1 is the first commit
486
487 def test_base_vs_mode_header_in_text(self, tmp_path: pathlib.Path) -> None:
488 root = _make_repo(tmp_path)
489 _write_commit_chain(root, n=2)
490 _, out = _invoke(root, "check", "--base", "HEAD~1")
491 assert "vs" in out
492
493 def test_base_bad_ref_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
494 root = _make_repo(tmp_path)
495 _write_commit_chain(root)
496 code, _ = _invoke_unchecked(root, "check", "--base", "nonexistent-branch")
497 assert code != 0
498
499 def test_base_json_error_on_bad_ref(self, tmp_path: pathlib.Path) -> None:
500 root = _make_repo(tmp_path)
501 _write_commit_chain(root)
502 code, out = _invoke_unchecked(root, "check", "--base", "bad/ref", "--json")
503 assert code != 0
504 d = json.loads(out.strip())
505 assert "error" in d
506
507
508 # ---------------------------------------------------------------------------
509 # Integration — --branch
510 # ---------------------------------------------------------------------------
511
512
513 class TestBranchFlag:
514 def test_branch_flag_checks_other_branch_head(self, tmp_path: pathlib.Path) -> None:
515 root = _make_repo(tmp_path)
516 # Create commits on two branches.
517 _write_commit_chain(root, branch="main")
518 _write_commit_chain(root, branch="dev", file_content=b"y = 2\n")
519 code, out = _invoke(root, "check", "--branch", "dev")
520 assert code == 0
521 assert "code" in out
522
523 def test_branch_nonexistent_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
524 root = _make_repo(tmp_path)
525 _write_commit_chain(root)
526 code, _ = _invoke_unchecked(root, "check", "--branch", "does-not-exist")
527 assert code != 0
528
529
530 # ---------------------------------------------------------------------------
531 # Integration — --filter flags
532 # ---------------------------------------------------------------------------
533
534
535 class TestFilterFlags:
536 def test_filter_severity_error_in_json(self, tmp_path: pathlib.Path) -> None:
537 root = _make_repo(tmp_path)
538 _write_commit_chain(root)
539 _, out = _invoke(root, "check", "--filter-severity", "error", "--json")
540 d = json.loads(out.strip())
541 for v in d["violations"]:
542 assert v["severity"] == "error"
543
544 def test_filter_severity_warning_in_json(self, tmp_path: pathlib.Path) -> None:
545 root = _make_repo(tmp_path)
546 _write_commit_chain(root)
547 _, out = _invoke(root, "check", "--filter-severity", "warning", "--json")
548 d = json.loads(out.strip())
549 for v in d["violations"]:
550 assert v["severity"] == "warning"
551
552 def test_filter_rule_in_json(self, tmp_path: pathlib.Path) -> None:
553 root = _make_repo(tmp_path)
554 _write_commit_chain(root)
555 _, out = _invoke(root, "check", "--filter-rule", "max_complexity", "--json")
556 d = json.loads(out.strip())
557 for v in d["violations"]:
558 assert v["rule_name"] == "max_complexity"
559
560 def test_filter_path_limits_addresses(self, tmp_path: pathlib.Path) -> None:
561 root = _make_repo(tmp_path)
562 _write_commit_chain(root)
563 _, out = _invoke(root, "check", "--filter-path", "*.py::*", "--json")
564 d = json.loads(out.strip())
565 for v in d["violations"]:
566 assert ".py" in v["address"]
567
568 def test_filter_shown_in_text_header(self, tmp_path: pathlib.Path) -> None:
569 root = _make_repo(tmp_path)
570 _write_commit_chain(root)
571 _, out = _invoke(root, "check", "--filter-severity", "error")
572 assert "filtered" in out or "severity=error" in out
573
574 def test_filter_severity_invalid_rejected(self, tmp_path: pathlib.Path) -> None:
575 root = _make_repo(tmp_path)
576 _write_commit_chain(root)
577 code, _ = _invoke_unchecked(root, "check", "--filter-severity", "critical")
578 assert code != 0
579
580
581 # ---------------------------------------------------------------------------
582 # Integration — --summary
583 # ---------------------------------------------------------------------------
584
585
586 class TestSummaryFlag:
587 def test_summary_outputs_single_line(self, tmp_path: pathlib.Path) -> None:
588 root = _make_repo(tmp_path)
589 _write_commit_chain(root)
590 _, out = _invoke(root, "check", "--summary")
591 # Header line + summary line
592 content_lines = [ln for ln in out.strip().splitlines() if ln.strip()]
593 assert len(content_lines) == 2
594
595 def test_summary_pass_shows_checkmark(self, tmp_path: pathlib.Path) -> None:
596 root = _make_repo(tmp_path)
597 _write_commit_chain(root, file_content=b"x = 1\n")
598 # Filter to info only to guarantee zero violations in output.
599 _, out = _invoke(root, "check", "--summary", "--filter-severity", "info")
600 # Most repos have 0 info violations, but we check for correct format
601 assert ("✅" in out or "❌" in out) # one of the two always appears
602
603 def test_summary_strict_propagates_exit(self, tmp_path: pathlib.Path) -> None:
604 root = _make_repo(tmp_path)
605 _write_commit_chain(root)
606 _, out_json = _invoke(root, "check", "--json")
607 d = json.loads(out_json.strip())
608 code, _ = _invoke(root, "check", "--summary", "--strict")
609 if d["has_errors"]:
610 assert code == 1
611 else:
612 assert code == 0
613
614 def test_summary_no_violation_details(self, tmp_path: pathlib.Path) -> None:
615 root = _make_repo(tmp_path)
616 _write_commit_chain(root)
617 _, out = _invoke(root, "check", "--summary")
618 # Summary mode should NOT list individual violations.
619 assert "[max_complexity]" not in out
620 assert "[no_cycles]" not in out
621
622
623 # ---------------------------------------------------------------------------
624 # Integration — --rules
625 # ---------------------------------------------------------------------------
626
627
628 class TestRulesFlag:
629 def test_empty_rules_file_no_violations(self, tmp_path: pathlib.Path) -> None:
630 root = _make_repo(tmp_path)
631 _write_commit_chain(root)
632 rules = root / "empty.toml"
633 rules.write_text("")
634 _, out = _invoke(root, "check", "--rules", "empty.toml", "--json")
635 d = json.loads(out.strip())
636 assert d["rules_checked"] == 0
637 assert d["total_violations"] == 0
638
639 def test_custom_rules_file_used(self, tmp_path: pathlib.Path) -> None:
640 root = _make_repo(tmp_path)
641 _write_commit_chain(root)
642 rules = root / "my_rules.toml"
643 rules.write_text(
644 '[[rule]]\nname = "max_complexity"\nseverity = "warning"\n'
645 'scope = "function"\nrule_type = "max_complexity"\n\n'
646 '[rule.params]\nthreshold = 100\n'
647 )
648 _, out = _invoke(root, "check", "--rules", "my_rules.toml", "--json")
649 d = json.loads(out.strip())
650 assert d["rules_checked"] == 1
651
652 def test_rules_path_outside_repo_rejected(self, tmp_path: pathlib.Path) -> None:
653 root = _make_repo(tmp_path)
654 _write_commit_chain(root)
655 code, out = _invoke_unchecked(root, "check", "--rules", "../../../etc/passwd")
656 assert code != 0
657 assert "outside" in out.lower() or "error" in out.lower()
658
659 def test_rules_absolute_path_outside_repo_rejected(self, tmp_path: pathlib.Path) -> None:
660 root = _make_repo(tmp_path)
661 _write_commit_chain(root)
662 code, out = _invoke_unchecked(root, "check", "--rules", "/etc/passwd")
663 assert code != 0
664
665 def test_rules_inside_repo_accepted(self, tmp_path: pathlib.Path) -> None:
666 root = _make_repo(tmp_path)
667 _write_commit_chain(root)
668 rules = root / ".muse" / "rules.toml"
669 rules.write_text("")
670 code, _ = _invoke(root, "check", "--rules", ".muse/rules.toml")
671 assert code == 0
672
673
674 # ---------------------------------------------------------------------------
675 # Edge cases
676 # ---------------------------------------------------------------------------
677
678
679 class TestEdgeCases:
680 def test_no_commits_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
681 root = _make_repo(tmp_path)
682 # No commits at all.
683 code, out = _invoke_unchecked(root, "check")
684 assert code != 0
685
686 def test_no_commits_json_has_error(self, tmp_path: pathlib.Path) -> None:
687 root = _make_repo(tmp_path)
688 code, out = _invoke_unchecked(root, "check", "--json")
689 assert code != 0
690 d = json.loads(out.strip())
691 assert "error" in d
692
693 def test_unknown_domain_exits_zero_with_warning(self, tmp_path: pathlib.Path) -> None:
694 root = _make_repo(tmp_path, domain="genomics")
695 _write_commit_chain(root)
696 code, out = _invoke(root, "check")
697 assert code == 0
698 # Should mention the domain in the warning.
699
700 def test_unknown_domain_json_has_error_key(self, tmp_path: pathlib.Path) -> None:
701 root = _make_repo(tmp_path, domain="spacetime")
702 _write_commit_chain(root)
703 code, out = _invoke(root, "check", "--json")
704 assert code == 0
705 d = json.loads(out.strip())
706 assert "error" in d
707
708 def test_head_tilde_past_root_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
709 root = _make_repo(tmp_path)
710 _write_commit_chain(root, n=1)
711 code, _ = _invoke_unchecked(root, "check", "HEAD~999")
712 assert code != 0
713
714 def test_filter_severity_no_match_empty_report(self, tmp_path: pathlib.Path) -> None:
715 root = _make_repo(tmp_path)
716 _write_commit_chain(root, file_content=b"x=1\n")
717 _, out = _invoke(root, "check", "--filter-severity", "info", "--json")
718 d = json.loads(out.strip())
719 # info violations are rare; just verify the filter ran.
720 assert isinstance(d["total_violations"], int)
721
722 def test_base_same_as_head_zero_new_in_json(self, tmp_path: pathlib.Path) -> None:
723 root = _make_repo(tmp_path)
724 cids = _write_commit_chain(root, n=1)
725 _, out = _invoke(root, "check", "--base", cids[0], "--json")
726 d = json.loads(out.strip())
727 assert d["total_violations"] == 0
728
729
730 # ---------------------------------------------------------------------------
731 # Security tests
732 # ---------------------------------------------------------------------------
733
734
735 class TestSecurity:
736 def test_ansi_in_commit_arg_doesnt_crash(self, tmp_path: pathlib.Path) -> None:
737 root = _make_repo(tmp_path)
738 _write_commit_chain(root)
739 # ANSI escape in commit arg should be handled without crashing.
740 code, _ = _invoke_unchecked(root, "check", "\x1b[31mevil\x1b[0m")
741 assert code != 0 # bad ref, but no crash
742
743 def test_ansi_in_filter_rule_doesnt_crash(self, tmp_path: pathlib.Path) -> None:
744 root = _make_repo(tmp_path)
745 _write_commit_chain(root)
746 code, _ = _invoke(root, "check", "--filter-rule", "\x1b[31mrule\x1b[0m")
747 assert code == 0 # no matching rule, no crash
748
749 def test_rules_dotdot_path_rejected(self, tmp_path: pathlib.Path) -> None:
750 root = _make_repo(tmp_path)
751 _write_commit_chain(root)
752 code, out = _invoke_unchecked(root, "check", "--rules", "../outside.toml")
753 assert code != 0
754 assert "outside" in out.lower() or "error" in out.lower()
755
756 def test_rules_symlink_outside_repo_rejected(self, tmp_path: pathlib.Path) -> None:
757 root = _make_repo(tmp_path)
758 _write_commit_chain(root)
759 # Create a symlink inside the repo that points outside.
760 outside = tmp_path.parent / "outside_rules.toml"
761 outside.write_text("")
762 link = root / "evil_rules.toml"
763 link.symlink_to(outside)
764 code, out = _invoke_unchecked(root, "check", "--rules", "evil_rules.toml")
765 assert code != 0
766
767 def test_filter_path_slash_etc_doesnt_crash(self, tmp_path: pathlib.Path) -> None:
768 root = _make_repo(tmp_path)
769 _write_commit_chain(root)
770 code, _ = _invoke(root, "check", "--filter-path", "/etc/*")
771 assert code == 0
772
773 def test_null_byte_in_commit_arg_doesnt_crash(self, tmp_path: pathlib.Path) -> None:
774 root = _make_repo(tmp_path)
775 _write_commit_chain(root)
776 code, _ = _invoke_unchecked(root, "check", "abc\x00def")
777 assert code != 0 # bad ref, no crash
778
779
780 # ---------------------------------------------------------------------------
781 # Stress tests
782 # ---------------------------------------------------------------------------
783
784
785 class TestStress:
786 def test_filter_on_200_violation_report(self, tmp_path: pathlib.Path) -> None:
787 """_filter_report handles a 200-violation list efficiently."""
788 from muse.cli.commands.check import _filter_report
789 violations: list[BaseViolation] = []
790 for i in range(200):
791 violations.append(BaseViolation(
792 rule_name="max_complexity" if i % 2 == 0 else "no_cycles",
793 severity="error" if i % 3 == 0 else "warning",
794 address=f"src/module_{i}.py::func_{i}",
795 description=f"violation {i}",
796 ))
797 report = make_report("a" * 64, "code", violations, 3)
798
799 filtered = _filter_report(report, filter_severity="error",
800 filter_rule=None, filter_path=None)
801 assert all(v["severity"] == "error" for v in filtered["violations"])
802 # Deterministic count: every 3rd item (0-indexed) is error.
803 expected = sum(1 for i in range(200) if i % 3 == 0)
804 assert len(filtered["violations"]) == expected
805
806 def test_check_with_50_rule_toml(self, tmp_path: pathlib.Path) -> None:
807 """muse check with a large rules TOML doesn't crash."""
808 root = _make_repo(tmp_path)
809 _write_commit_chain(root, file_content=b"x = 1\n")
810 rules_lines = []
811 for i in range(50):
812 rules_lines.append(f"[[rule]]")
813 rules_lines.append(f'name = "rule_{i}"')
814 rules_lines.append(f'severity = "warning"')
815 rules_lines.append(f'scope = "function"')
816 rules_lines.append(f'rule_type = "max_complexity"')
817 rules_lines.append(f"[rule.params]")
818 rules_lines.append(f"threshold = {1000 + i}")
819 rules_lines.append("")
820 rules = root / "big_rules.toml"
821 rules.write_text("\n".join(rules_lines))
822 code, out = _invoke(root, "check", "--rules", "big_rules.toml", "--json")
823 assert code == 0
824 d = json.loads(out.strip())
825 assert d["rules_checked"] == 50
826
827 def test_filter_report_with_glob_on_200_items(self, tmp_path: pathlib.Path) -> None:
828 """Path glob filter on a large violation list is correct."""
829 from muse.cli.commands.check import _filter_report
830 violations: list[BaseViolation] = []
831 for i in range(200):
832 violations.append(BaseViolation(
833 rule_name="max_complexity",
834 severity="warning",
835 address=f"src/a/module_{i}.py::func" if i < 100 else f"src/b/module_{i}.py::func",
836 description=f"v{i}",
837 ))
838 report = make_report("a" * 64, "code", violations, 1)
839 filtered = _filter_report(report, filter_severity=None,
840 filter_rule=None, filter_path="src/a/*")
841 assert len(filtered["violations"]) == 100
842 assert all("src/a/" in v["address"] for v in filtered["violations"])
843
844 def test_json_output_with_many_commits(self, tmp_path: pathlib.Path) -> None:
845 """muse check --json works correctly on a repo with 20 commits."""
846 root = _make_repo(tmp_path)
847 _write_commit_chain(root, n=20)
848 code, out = _invoke(root, "check", "--json")
849 assert code == 0
850 d = json.loads(out.strip())
851 assert isinstance(d["total_violations"], int)
852 assert d["duration_ms"] > 0
853
854
855 # ---------------------------------------------------------------------------
856 # exit_code in JSON — agent gating without shell $?
857 # ---------------------------------------------------------------------------
858
859
860 class TestExitCodeInJson:
861 """exit_code in --json output lets agents gate on results without relying on $?."""
862
863 def test_exit_code_present_in_json(self, tmp_path: pathlib.Path) -> None:
864 root = _make_repo(tmp_path)
865 _write_commit_chain(root)
866 _, out = _invoke(root, "check", "--json")
867 d = json.loads(out.strip())
868 assert "exit_code" in d
869 assert isinstance(d["exit_code"], int)
870
871 def test_exit_code_zero_when_no_strict_or_warn(self, tmp_path: pathlib.Path) -> None:
872 """Without --strict/--warn, exit_code is always 0 regardless of violations."""
873 root = _make_repo(tmp_path)
874 _write_commit_chain(root)
875 code, out = _invoke(root, "check", "--json")
876 d = json.loads(out.strip())
877 assert d["exit_code"] == 0
878 assert code == d["exit_code"]
879
880 def test_exit_code_matches_process_exit_with_strict(self, tmp_path: pathlib.Path) -> None:
881 root = _make_repo(tmp_path)
882 _write_commit_chain(root)
883 code, out = _invoke(root, "check", "--strict", "--json")
884 d = json.loads(out.strip())
885 assert d["exit_code"] == code
886
887 def test_exit_code_matches_process_exit_with_warn(self, tmp_path: pathlib.Path) -> None:
888 root = _make_repo(tmp_path)
889 _write_commit_chain(root)
890 code, out = _invoke(root, "check", "--warn", "--json")
891 d = json.loads(out.strip())
892 assert d["exit_code"] == code
893
894 def test_exit_code_matches_process_exit_strict_and_warn(self, tmp_path: pathlib.Path) -> None:
895 root = _make_repo(tmp_path)
896 _write_commit_chain(root)
897 code, out = _invoke(root, "check", "--strict", "--warn", "--json")
898 d = json.loads(out.strip())
899 assert d["exit_code"] == code
900
901 def test_exit_code_in_json_with_filter(self, tmp_path: pathlib.Path) -> None:
902 """exit_code is present even when filters narrow the violation list."""
903 root = _make_repo(tmp_path)
904 _write_commit_chain(root)
905 code, out = _invoke(root, "check", "--json", "--filter-severity", "error", "--strict")
906 d = json.loads(out.strip())
907 assert "exit_code" in d
908 assert d["exit_code"] == code
909
910
911 # ---------------------------------------------------------------------------
912 # Flag registration tests
913 # ---------------------------------------------------------------------------
914
915 import argparse as _argparse
916 from muse.cli.commands.check import register as _register_check
917
918
919 def _parse_check(*args: str) -> _argparse.Namespace:
920 """Build an argument parser via register() and parse args."""
921 root_p = _argparse.ArgumentParser()
922 subs = root_p.add_subparsers(dest="cmd")
923 _register_check(subs)
924 return root_p.parse_args(["check", *args])
925
926
927 class TestRegisterFlags:
928 def test_default_json_out_is_false(self) -> None:
929 ns = _parse_check()
930 assert ns.json_out is False
931
932 def test_json_flag_sets_json_out(self) -> None:
933 ns = _parse_check("--json")
934 assert ns.json_out is True
935
936 def test_j_shorthand_sets_json_out(self) -> None:
937 ns = _parse_check("-j")
938 assert ns.json_out is True
939
940 def test_strict_flag(self) -> None:
941 ns = _parse_check("--strict")
942 assert ns.strict is True
943
944 def test_format_flag_no_longer_exists(self) -> None:
945 import pytest
946 with pytest.raises(SystemExit):
947 _parse_check("--format", "json")
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago