gabriel / muse public
test_cmd_check.py python
845 lines 34.4 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 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`` elapsed_seconds > 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 import uuid
82
83 import msgpack
84 import pytest
85
86 from muse.core.invariants import BaseReport, BaseViolation, make_report
87 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
88 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
89 from muse.core._types import Manifest
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": str(uuid.uuid4()),
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 import hashlib
128
129 commit_ids: list[str] = []
130 parent: str | None = None
131
132 for i in range(n):
133 content = file_content + f"\n# {i}".encode()
134 sha = hashlib.sha256(content).hexdigest()
135 obj_dir = root / ".muse" / "objects" / sha[:2]
136 obj_dir.mkdir(parents=True, exist_ok=True)
137 (obj_dir / sha[2:]).write_bytes(content)
138
139 manifest = {"main.py": sha}
140 snap_id = compute_snapshot_id(manifest)
141 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
142
143 ts = (_EPOCH + datetime.timedelta(seconds=i)).isoformat()
144 commit_id = compute_commit_id([p for p in [parent] if p], snap_id, f"commit {i}", ts)
145 data = {
146 "commit_id": commit_id,
147 "repo_id": "test",
148 "branch": branch,
149 "snapshot_id": snap_id,
150 "message": f"commit {i}",
151 "committed_at": ts,
152 "parent_commit_id": parent,
153 "parent2_commit_id": None,
154 "author": "test",
155 "format_version": 1,
156 }
157 (root / ".muse" / "commits" / f"{commit_id}.msgpack").write_bytes(
158 msgpack.packb(data, use_bin_type=True)
159 )
160 ref_path = root / ".muse" / "refs" / "heads" / branch
161 ref_path.parent.mkdir(parents=True, exist_ok=True)
162 ref_path.write_text(commit_id, encoding="utf-8")
163 commit_ids.append(commit_id)
164 parent = commit_id
165
166 return commit_ids
167
168
169 def _env(root: pathlib.Path) -> Manifest:
170 return {"MUSE_REPO_ROOT": str(root)}
171
172
173 def _invoke(root: pathlib.Path, *args: str) -> tuple[int, str]:
174 r = runner.invoke(cli, list(args), env=_env(root), catch_exceptions=False)
175 return r.exit_code, r.output
176
177
178 def _invoke_unchecked(root: pathlib.Path, *args: str) -> tuple[int, str]:
179 r = runner.invoke(cli, list(args), env=_env(root))
180 return r.exit_code, r.output
181
182
183 # ---------------------------------------------------------------------------
184 # Unit — _get_checker
185 # ---------------------------------------------------------------------------
186
187
188 class TestGetChecker:
189 def test_code_returns_code_checker(self) -> None:
190 from muse.cli.commands.check import _get_checker
191 from muse.plugins.code._invariants import CodeChecker
192 assert isinstance(_get_checker("code"), CodeChecker)
193
194 def test_midi_returns_midi_checker(self) -> None:
195 from muse.cli.commands.check import _get_checker
196 from muse.plugins.midi._invariants import MidiChecker
197 assert isinstance(_get_checker("midi"), MidiChecker)
198
199 def test_unknown_domain_returns_none(self) -> None:
200 from muse.cli.commands.check import _get_checker
201 assert _get_checker("genomics") is None
202 assert _get_checker("") is None
203 assert _get_checker("CODE") is None # case-sensitive
204
205
206 # ---------------------------------------------------------------------------
207 # Unit — _filter_report
208 # ---------------------------------------------------------------------------
209
210
211 class TestFilterReport:
212 def _make_report_with_violations(self) -> BaseReport:
213 violations: list[BaseViolation] = [
214 BaseViolation(rule_name="max_complexity", severity="error",
215 address="src/a.py::foo", description="too complex"),
216 BaseViolation(rule_name="max_complexity", severity="warning",
217 address="src/b.py::bar", description="complex"),
218 BaseViolation(rule_name="no_cycles", severity="error",
219 address="src/c.py", description="cycle"),
220 BaseViolation(rule_name="coverage", severity="info",
221 address="src/d.py", description="low coverage"),
222 ]
223 return make_report("a" * 64, "code", violations, 3)
224
225 def test_filter_by_severity_error(self) -> None:
226 from muse.cli.commands.check import _filter_report
227 report = self._make_report_with_violations()
228 filtered = _filter_report(report, filter_severity="error",
229 filter_rule=None, filter_path=None)
230 assert all(v["severity"] == "error" for v in filtered["violations"])
231 assert len(filtered["violations"]) == 2
232
233 def test_filter_by_severity_warning(self) -> None:
234 from muse.cli.commands.check import _filter_report
235 report = self._make_report_with_violations()
236 filtered = _filter_report(report, filter_severity="warning",
237 filter_rule=None, filter_path=None)
238 assert len(filtered["violations"]) == 1
239 assert filtered["violations"][0]["rule_name"] == "max_complexity"
240
241 def test_filter_by_severity_info(self) -> None:
242 from muse.cli.commands.check import _filter_report
243 report = self._make_report_with_violations()
244 filtered = _filter_report(report, filter_severity="info",
245 filter_rule=None, filter_path=None)
246 assert len(filtered["violations"]) == 1
247 assert filtered["violations"][0]["rule_name"] == "coverage"
248
249 def test_filter_by_rule_name(self) -> None:
250 from muse.cli.commands.check import _filter_report
251 report = self._make_report_with_violations()
252 filtered = _filter_report(report, filter_severity=None,
253 filter_rule="no_cycles", filter_path=None)
254 assert all(v["rule_name"] == "no_cycles" for v in filtered["violations"])
255 assert len(filtered["violations"]) == 1
256
257 def test_filter_by_path_glob(self) -> None:
258 from muse.cli.commands.check import _filter_report
259 report = self._make_report_with_violations()
260 filtered = _filter_report(report, filter_severity=None,
261 filter_rule=None, filter_path="src/a.py::*")
262 assert len(filtered["violations"]) == 1
263 assert filtered["violations"][0]["address"] == "src/a.py::foo"
264
265 def test_combined_filters(self) -> None:
266 from muse.cli.commands.check import _filter_report
267 report = self._make_report_with_violations()
268 filtered = _filter_report(report, filter_severity="error",
269 filter_rule="max_complexity", filter_path=None)
270 assert len(filtered["violations"]) == 1
271 assert filtered["violations"][0]["address"] == "src/a.py::foo"
272
273 def test_no_filters_returns_all(self) -> None:
274 from muse.cli.commands.check import _filter_report
275 report = self._make_report_with_violations()
276 filtered = _filter_report(report, filter_severity=None,
277 filter_rule=None, filter_path=None)
278 assert len(filtered["violations"]) == len(report["violations"])
279
280 def test_filter_no_match_returns_empty(self) -> None:
281 from muse.cli.commands.check import _filter_report
282 report = self._make_report_with_violations()
283 filtered = _filter_report(report, filter_severity="error",
284 filter_rule="nonexistent_rule", filter_path=None)
285 assert filtered["violations"] == []
286
287 def test_rules_checked_preserved_through_filter(self) -> None:
288 from muse.cli.commands.check import _filter_report
289 report = self._make_report_with_violations()
290 filtered = _filter_report(report, filter_severity="error",
291 filter_rule=None, filter_path=None)
292 assert filtered["rules_checked"] == report["rules_checked"]
293
294
295 # ---------------------------------------------------------------------------
296 # Unit — _CheckJson shape
297 # ---------------------------------------------------------------------------
298
299
300 class TestCheckJsonShape:
301 def test_required_keys_present(self, tmp_path: pathlib.Path) -> None:
302 root = _make_repo(tmp_path)
303 _write_commit_chain(root)
304 code, out = _invoke(root, "check", "--json")
305 assert code == 0
306 data = json.loads(out.strip())
307 required = {
308 "commit_id", "domain", "rules_checked", "has_errors",
309 "has_warnings", "error_count", "warning_count", "info_count",
310 "total_violations", "violations", "base_commit_id", "elapsed_seconds",
311 }
312 assert required <= set(data.keys())
313
314 def test_field_types(self, tmp_path: pathlib.Path) -> None:
315 root = _make_repo(tmp_path)
316 _write_commit_chain(root)
317 _, out = _invoke(root, "check", "--json")
318 d = json.loads(out.strip())
319 assert isinstance(d["commit_id"], str)
320 assert isinstance(d["domain"], str)
321 assert isinstance(d["rules_checked"], int)
322 assert isinstance(d["has_errors"], bool)
323 assert isinstance(d["has_warnings"], bool)
324 assert isinstance(d["error_count"], int)
325 assert isinstance(d["warning_count"], int)
326 assert isinstance(d["info_count"], int)
327 assert isinstance(d["total_violations"], int)
328 assert isinstance(d["violations"], list)
329 assert isinstance(d["elapsed_seconds"], float)
330
331 def test_elapsed_seconds_positive(self, tmp_path: pathlib.Path) -> None:
332 root = _make_repo(tmp_path)
333 _write_commit_chain(root)
334 _, out = _invoke(root, "check", "--json")
335 d = json.loads(out.strip())
336 assert d["elapsed_seconds"] > 0.0
337
338 def test_base_commit_id_none_without_base_flag(self, tmp_path: pathlib.Path) -> None:
339 root = _make_repo(tmp_path)
340 _write_commit_chain(root)
341 _, out = _invoke(root, "check", "--json")
342 d = json.loads(out.strip())
343 assert d["base_commit_id"] is None
344
345 def test_counts_consistent_with_violations(self, tmp_path: pathlib.Path) -> None:
346 root = _make_repo(tmp_path)
347 _write_commit_chain(root)
348 _, out = _invoke(root, "check", "--json")
349 d = json.loads(out.strip())
350 total = d["error_count"] + d["warning_count"] + d["info_count"]
351 assert total == d["total_violations"]
352 assert len(d["violations"]) == d["total_violations"]
353
354
355 # ---------------------------------------------------------------------------
356 # Integration — basic invocation
357 # ---------------------------------------------------------------------------
358
359
360 class TestBasicInvocation:
361 def test_default_head_exits_zero(self, tmp_path: pathlib.Path) -> None:
362 root = _make_repo(tmp_path)
363 _write_commit_chain(root)
364 code, _ = _invoke(root, "check")
365 assert code == 0
366
367 def test_text_output_contains_domain(self, tmp_path: pathlib.Path) -> None:
368 root = _make_repo(tmp_path)
369 _write_commit_chain(root)
370 _, out = _invoke(root, "check")
371 assert "code" in out
372
373 def test_text_output_contains_rules_checked(self, tmp_path: pathlib.Path) -> None:
374 root = _make_repo(tmp_path)
375 _write_commit_chain(root)
376 _, out = _invoke(root, "check")
377 assert "rules" in out
378
379 def test_text_output_contains_commit_prefix(self, tmp_path: pathlib.Path) -> None:
380 root = _make_repo(tmp_path)
381 cids = _write_commit_chain(root)
382 _, out = _invoke(root, "check")
383 assert cids[-1][:8] in out
384
385 def test_text_output_has_elapsed_time(self, tmp_path: pathlib.Path) -> None:
386 root = _make_repo(tmp_path)
387 _write_commit_chain(root)
388 _, out = _invoke(root, "check")
389 assert "s)" in out # e.g. "(0.123s)"
390
391 def test_full_sha_argument(self, tmp_path: pathlib.Path) -> None:
392 root = _make_repo(tmp_path)
393 cids = _write_commit_chain(root)
394 code, _ = _invoke(root, "check", cids[-1])
395 assert code == 0
396
397 def test_short_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][:12])
401 assert code == 0
402
403 def test_head_tilde_1(self, tmp_path: pathlib.Path) -> None:
404 root = _make_repo(tmp_path)
405 _write_commit_chain(root, n=3)
406 code, _ = _invoke(root, "check", "HEAD~1")
407 assert code == 0
408
409 def test_head_tilde_0(self, tmp_path: pathlib.Path) -> None:
410 root = _make_repo(tmp_path)
411 _write_commit_chain(root, n=2)
412 code, _ = _invoke(root, "check", "HEAD~0")
413 assert code == 0
414
415
416 # ---------------------------------------------------------------------------
417 # Integration — --strict and --warn
418 # ---------------------------------------------------------------------------
419
420
421 class TestStrictAndWarn:
422 def _make_clean_report_repo(self, tmp_path: pathlib.Path) -> pathlib.Path:
423 """Repo with a simple clean Python file — no violations expected."""
424 root = _make_repo(tmp_path)
425 _write_commit_chain(root, file_content=b"x = 1\n")
426 return root
427
428 def test_strict_exits_0_when_no_errors(self, tmp_path: pathlib.Path) -> None:
429 root = self._make_clean_report_repo(tmp_path)
430 code, _ = _invoke(root, "check", "--strict")
431 # Code domain with a clean file may still have warnings — strict only cares about errors.
432 assert code in (0, 1) # 0 if no errors, 1 if errors
433
434 def test_warn_flag_in_json(self, tmp_path: pathlib.Path) -> None:
435 root = _make_repo(tmp_path)
436 _write_commit_chain(root)
437 code, out = _invoke(root, "check", "--json")
438 d = json.loads(out.strip())
439 # JSON always has warning_count regardless of --warn flag.
440 assert "warning_count" in d
441
442 def test_strict_json_exit_code_consistent(self, tmp_path: pathlib.Path) -> None:
443 root = _make_repo(tmp_path)
444 _write_commit_chain(root)
445 code, out = _invoke(root, "check", "--strict", "--json")
446 d = json.loads(out.strip())
447 if d["has_errors"]:
448 assert code == 1
449 else:
450 assert code == 0
451
452
453 # ---------------------------------------------------------------------------
454 # Integration — --base diff mode
455 # ---------------------------------------------------------------------------
456
457
458 class TestBaseMode:
459 def test_same_commit_as_base_zero_new_violations(self, tmp_path: pathlib.Path) -> None:
460 root = _make_repo(tmp_path)
461 cids = _write_commit_chain(root, n=1)
462 # Base is same as HEAD → no new violations.
463 code, _ = _invoke(root, "check", "--base", cids[0])
464 assert code == 0
465
466 def test_base_head_tilde_1_on_identical_snapshots(self, tmp_path: pathlib.Path) -> None:
467 root = _make_repo(tmp_path)
468 _write_commit_chain(root, n=3)
469 # HEAD~1 and HEAD have the same file content → diff is zero violations.
470 code, out = _invoke(root, "check", "--base", "HEAD~1")
471 assert code == 0
472
473 def test_base_sets_base_commit_id_in_json(self, tmp_path: pathlib.Path) -> None:
474 root = _make_repo(tmp_path)
475 cids = _write_commit_chain(root, n=2)
476 _, out = _invoke(root, "check", "--base", "HEAD~1", "--json")
477 d = json.loads(out.strip())
478 assert d["base_commit_id"] == cids[0] # HEAD~1 is the first commit
479
480 def test_base_vs_mode_header_in_text(self, tmp_path: pathlib.Path) -> None:
481 root = _make_repo(tmp_path)
482 _write_commit_chain(root, n=2)
483 _, out = _invoke(root, "check", "--base", "HEAD~1")
484 assert "vs" in out
485
486 def test_base_bad_ref_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
487 root = _make_repo(tmp_path)
488 _write_commit_chain(root)
489 code, _ = _invoke_unchecked(root, "check", "--base", "nonexistent-branch")
490 assert code != 0
491
492 def test_base_json_error_on_bad_ref(self, tmp_path: pathlib.Path) -> None:
493 root = _make_repo(tmp_path)
494 _write_commit_chain(root)
495 code, out = _invoke_unchecked(root, "check", "--base", "bad/ref", "--json")
496 assert code != 0
497 d = json.loads(out.strip())
498 assert "error" in d
499
500
501 # ---------------------------------------------------------------------------
502 # Integration — --branch
503 # ---------------------------------------------------------------------------
504
505
506 class TestBranchFlag:
507 def test_branch_flag_checks_other_branch_head(self, tmp_path: pathlib.Path) -> None:
508 root = _make_repo(tmp_path)
509 # Create commits on two branches.
510 _write_commit_chain(root, branch="main")
511 _write_commit_chain(root, branch="dev", file_content=b"y = 2\n")
512 code, out = _invoke(root, "check", "--branch", "dev")
513 assert code == 0
514 assert "code" in out
515
516 def test_branch_nonexistent_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
517 root = _make_repo(tmp_path)
518 _write_commit_chain(root)
519 code, _ = _invoke_unchecked(root, "check", "--branch", "does-not-exist")
520 assert code != 0
521
522
523 # ---------------------------------------------------------------------------
524 # Integration — --filter flags
525 # ---------------------------------------------------------------------------
526
527
528 class TestFilterFlags:
529 def test_filter_severity_error_in_json(self, tmp_path: pathlib.Path) -> None:
530 root = _make_repo(tmp_path)
531 _write_commit_chain(root)
532 _, out = _invoke(root, "check", "--filter-severity", "error", "--json")
533 d = json.loads(out.strip())
534 for v in d["violations"]:
535 assert v["severity"] == "error"
536
537 def test_filter_severity_warning_in_json(self, tmp_path: pathlib.Path) -> None:
538 root = _make_repo(tmp_path)
539 _write_commit_chain(root)
540 _, out = _invoke(root, "check", "--filter-severity", "warning", "--json")
541 d = json.loads(out.strip())
542 for v in d["violations"]:
543 assert v["severity"] == "warning"
544
545 def test_filter_rule_in_json(self, tmp_path: pathlib.Path) -> None:
546 root = _make_repo(tmp_path)
547 _write_commit_chain(root)
548 _, out = _invoke(root, "check", "--filter-rule", "max_complexity", "--json")
549 d = json.loads(out.strip())
550 for v in d["violations"]:
551 assert v["rule_name"] == "max_complexity"
552
553 def test_filter_path_limits_addresses(self, tmp_path: pathlib.Path) -> None:
554 root = _make_repo(tmp_path)
555 _write_commit_chain(root)
556 _, out = _invoke(root, "check", "--filter-path", "*.py::*", "--json")
557 d = json.loads(out.strip())
558 for v in d["violations"]:
559 assert ".py" in v["address"]
560
561 def test_filter_shown_in_text_header(self, tmp_path: pathlib.Path) -> None:
562 root = _make_repo(tmp_path)
563 _write_commit_chain(root)
564 _, out = _invoke(root, "check", "--filter-severity", "error")
565 assert "filtered" in out or "severity=error" in out
566
567 def test_filter_severity_invalid_rejected(self, tmp_path: pathlib.Path) -> None:
568 root = _make_repo(tmp_path)
569 _write_commit_chain(root)
570 code, _ = _invoke_unchecked(root, "check", "--filter-severity", "critical")
571 assert code != 0
572
573
574 # ---------------------------------------------------------------------------
575 # Integration — --summary
576 # ---------------------------------------------------------------------------
577
578
579 class TestSummaryFlag:
580 def test_summary_outputs_single_line(self, tmp_path: pathlib.Path) -> None:
581 root = _make_repo(tmp_path)
582 _write_commit_chain(root)
583 _, out = _invoke(root, "check", "--summary")
584 # Header line + summary line
585 content_lines = [ln for ln in out.strip().splitlines() if ln.strip()]
586 assert len(content_lines) == 2
587
588 def test_summary_pass_shows_checkmark(self, tmp_path: pathlib.Path) -> None:
589 root = _make_repo(tmp_path)
590 _write_commit_chain(root, file_content=b"x = 1\n")
591 # Filter to info only to guarantee zero violations in output.
592 _, out = _invoke(root, "check", "--summary", "--filter-severity", "info")
593 # Most repos have 0 info violations, but we check for correct format
594 assert ("✅" in out or "❌" in out) # one of the two always appears
595
596 def test_summary_strict_propagates_exit(self, tmp_path: pathlib.Path) -> None:
597 root = _make_repo(tmp_path)
598 _write_commit_chain(root)
599 _, out_json = _invoke(root, "check", "--json")
600 d = json.loads(out_json.strip())
601 code, _ = _invoke(root, "check", "--summary", "--strict")
602 if d["has_errors"]:
603 assert code == 1
604 else:
605 assert code == 0
606
607 def test_summary_no_violation_details(self, tmp_path: pathlib.Path) -> None:
608 root = _make_repo(tmp_path)
609 _write_commit_chain(root)
610 _, out = _invoke(root, "check", "--summary")
611 # Summary mode should NOT list individual violations.
612 assert "[max_complexity]" not in out
613 assert "[no_cycles]" not in out
614
615
616 # ---------------------------------------------------------------------------
617 # Integration — --rules
618 # ---------------------------------------------------------------------------
619
620
621 class TestRulesFlag:
622 def test_empty_rules_file_no_violations(self, tmp_path: pathlib.Path) -> None:
623 root = _make_repo(tmp_path)
624 _write_commit_chain(root)
625 rules = root / "empty.toml"
626 rules.write_text("")
627 _, out = _invoke(root, "check", "--rules", "empty.toml", "--json")
628 d = json.loads(out.strip())
629 assert d["rules_checked"] == 0
630 assert d["total_violations"] == 0
631
632 def test_custom_rules_file_used(self, tmp_path: pathlib.Path) -> None:
633 root = _make_repo(tmp_path)
634 _write_commit_chain(root)
635 rules = root / "my_rules.toml"
636 rules.write_text(
637 '[[rule]]\nname = "max_complexity"\nseverity = "warning"\n'
638 'scope = "function"\nrule_type = "max_complexity"\n\n'
639 '[rule.params]\nthreshold = 100\n'
640 )
641 _, out = _invoke(root, "check", "--rules", "my_rules.toml", "--json")
642 d = json.loads(out.strip())
643 assert d["rules_checked"] == 1
644
645 def test_rules_path_outside_repo_rejected(self, tmp_path: pathlib.Path) -> None:
646 root = _make_repo(tmp_path)
647 _write_commit_chain(root)
648 code, out = _invoke_unchecked(root, "check", "--rules", "../../../etc/passwd")
649 assert code != 0
650 assert "outside" in out.lower() or "error" in out.lower()
651
652 def test_rules_absolute_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
658 def test_rules_inside_repo_accepted(self, tmp_path: pathlib.Path) -> None:
659 root = _make_repo(tmp_path)
660 _write_commit_chain(root)
661 rules = root / ".muse" / "rules.toml"
662 rules.write_text("")
663 code, _ = _invoke(root, "check", "--rules", ".muse/rules.toml")
664 assert code == 0
665
666
667 # ---------------------------------------------------------------------------
668 # Edge cases
669 # ---------------------------------------------------------------------------
670
671
672 class TestEdgeCases:
673 def test_no_commits_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
674 root = _make_repo(tmp_path)
675 # No commits at all.
676 code, out = _invoke_unchecked(root, "check")
677 assert code != 0
678
679 def test_no_commits_json_has_error(self, tmp_path: pathlib.Path) -> None:
680 root = _make_repo(tmp_path)
681 code, out = _invoke_unchecked(root, "check", "--json")
682 assert code != 0
683 d = json.loads(out.strip())
684 assert "error" in d
685
686 def test_unknown_domain_exits_zero_with_warning(self, tmp_path: pathlib.Path) -> None:
687 root = _make_repo(tmp_path, domain="genomics")
688 _write_commit_chain(root)
689 code, out = _invoke(root, "check")
690 assert code == 0
691 # Should mention the domain in the warning.
692
693 def test_unknown_domain_json_has_error_key(self, tmp_path: pathlib.Path) -> None:
694 root = _make_repo(tmp_path, domain="spacetime")
695 _write_commit_chain(root)
696 code, out = _invoke(root, "check", "--json")
697 assert code == 0
698 d = json.loads(out.strip())
699 assert "error" in d
700
701 def test_head_tilde_past_root_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
702 root = _make_repo(tmp_path)
703 _write_commit_chain(root, n=1)
704 code, _ = _invoke_unchecked(root, "check", "HEAD~999")
705 assert code != 0
706
707 def test_filter_severity_no_match_empty_report(self, tmp_path: pathlib.Path) -> None:
708 root = _make_repo(tmp_path)
709 _write_commit_chain(root, file_content=b"x=1\n")
710 _, out = _invoke(root, "check", "--filter-severity", "info", "--json")
711 d = json.loads(out.strip())
712 # info violations are rare; just verify the filter ran.
713 assert isinstance(d["total_violations"], int)
714
715 def test_base_same_as_head_zero_new_in_json(self, tmp_path: pathlib.Path) -> None:
716 root = _make_repo(tmp_path)
717 cids = _write_commit_chain(root, n=1)
718 _, out = _invoke(root, "check", "--base", cids[0], "--json")
719 d = json.loads(out.strip())
720 assert d["total_violations"] == 0
721
722
723 # ---------------------------------------------------------------------------
724 # Security tests
725 # ---------------------------------------------------------------------------
726
727
728 class TestSecurity:
729 def test_ansi_in_commit_arg_doesnt_crash(self, tmp_path: pathlib.Path) -> None:
730 root = _make_repo(tmp_path)
731 _write_commit_chain(root)
732 # ANSI escape in commit arg should be handled without crashing.
733 code, _ = _invoke_unchecked(root, "check", "\x1b[31mevil\x1b[0m")
734 assert code != 0 # bad ref, but no crash
735
736 def test_ansi_in_filter_rule_doesnt_crash(self, tmp_path: pathlib.Path) -> None:
737 root = _make_repo(tmp_path)
738 _write_commit_chain(root)
739 code, _ = _invoke(root, "check", "--filter-rule", "\x1b[31mrule\x1b[0m")
740 assert code == 0 # no matching rule, no crash
741
742 def test_rules_dotdot_path_rejected(self, tmp_path: pathlib.Path) -> None:
743 root = _make_repo(tmp_path)
744 _write_commit_chain(root)
745 code, out = _invoke_unchecked(root, "check", "--rules", "../outside.toml")
746 assert code != 0
747 assert "outside" in out.lower() or "error" in out.lower()
748
749 def test_rules_symlink_outside_repo_rejected(self, tmp_path: pathlib.Path) -> None:
750 root = _make_repo(tmp_path)
751 _write_commit_chain(root)
752 # Create a symlink inside the repo that points outside.
753 outside = tmp_path.parent / "outside_rules.toml"
754 outside.write_text("")
755 link = root / "evil_rules.toml"
756 link.symlink_to(outside)
757 code, out = _invoke_unchecked(root, "check", "--rules", "evil_rules.toml")
758 assert code != 0
759
760 def test_filter_path_slash_etc_doesnt_crash(self, tmp_path: pathlib.Path) -> None:
761 root = _make_repo(tmp_path)
762 _write_commit_chain(root)
763 code, _ = _invoke(root, "check", "--filter-path", "/etc/*")
764 assert code == 0
765
766 def test_null_byte_in_commit_arg_doesnt_crash(self, tmp_path: pathlib.Path) -> None:
767 root = _make_repo(tmp_path)
768 _write_commit_chain(root)
769 code, _ = _invoke_unchecked(root, "check", "abc\x00def")
770 assert code != 0 # bad ref, no crash
771
772
773 # ---------------------------------------------------------------------------
774 # Stress tests
775 # ---------------------------------------------------------------------------
776
777
778 class TestStress:
779 def test_filter_on_200_violation_report(self, tmp_path: pathlib.Path) -> None:
780 """_filter_report handles a 200-violation list efficiently."""
781 from muse.cli.commands.check import _filter_report
782 violations: list[BaseViolation] = []
783 for i in range(200):
784 violations.append(BaseViolation(
785 rule_name="max_complexity" if i % 2 == 0 else "no_cycles",
786 severity="error" if i % 3 == 0 else "warning",
787 address=f"src/module_{i}.py::func_{i}",
788 description=f"violation {i}",
789 ))
790 report = make_report("a" * 64, "code", violations, 3)
791
792 filtered = _filter_report(report, filter_severity="error",
793 filter_rule=None, filter_path=None)
794 assert all(v["severity"] == "error" for v in filtered["violations"])
795 # Deterministic count: every 3rd item (0-indexed) is error.
796 expected = sum(1 for i in range(200) if i % 3 == 0)
797 assert len(filtered["violations"]) == expected
798
799 def test_check_with_50_rule_toml(self, tmp_path: pathlib.Path) -> None:
800 """muse check with a large rules TOML doesn't crash."""
801 root = _make_repo(tmp_path)
802 _write_commit_chain(root, file_content=b"x = 1\n")
803 rules_lines = []
804 for i in range(50):
805 rules_lines.append(f"[[rule]]")
806 rules_lines.append(f'name = "rule_{i}"')
807 rules_lines.append(f'severity = "warning"')
808 rules_lines.append(f'scope = "function"')
809 rules_lines.append(f'rule_type = "max_complexity"')
810 rules_lines.append(f"[rule.params]")
811 rules_lines.append(f"threshold = {1000 + i}")
812 rules_lines.append("")
813 rules = root / "big_rules.toml"
814 rules.write_text("\n".join(rules_lines))
815 code, out = _invoke(root, "check", "--rules", "big_rules.toml", "--json")
816 assert code == 0
817 d = json.loads(out.strip())
818 assert d["rules_checked"] == 50
819
820 def test_filter_report_with_glob_on_200_items(self, tmp_path: pathlib.Path) -> None:
821 """Path glob filter on a large violation list is correct."""
822 from muse.cli.commands.check import _filter_report
823 violations: list[BaseViolation] = []
824 for i in range(200):
825 violations.append(BaseViolation(
826 rule_name="max_complexity",
827 severity="warning",
828 address=f"src/a/module_{i}.py::func" if i < 100 else f"src/b/module_{i}.py::func",
829 description=f"v{i}",
830 ))
831 report = make_report("a" * 64, "code", violations, 1)
832 filtered = _filter_report(report, filter_severity=None,
833 filter_rule=None, filter_path="src/a/*")
834 assert len(filtered["violations"]) == 100
835 assert all("src/a/" in v["address"] for v in filtered["violations"])
836
837 def test_json_output_with_many_commits(self, tmp_path: pathlib.Path) -> None:
838 """muse check --json works correctly on a repo with 20 commits."""
839 root = _make_repo(tmp_path)
840 _write_commit_chain(root, n=20)
841 code, out = _invoke(root, "check", "--json")
842 assert code == 0
843 d = json.loads(out.strip())
844 assert isinstance(d["total_violations"], int)
845 assert d["elapsed_seconds"] > 0
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 101 days ago