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