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