gabriel / muse public
test_cmd_invariants.py python
883 lines 31.6 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 125 days ago
1 """Comprehensive tests for ``muse code invariants``.
2
3 Coverage:
4 I. Unit — check_forbidden_dependency (new rule in plugin engine)
5 II. Unit — check_layer_boundary (new rule in plugin engine)
6 III. Integration — CLI run() with a real repo: all 6 rule types
7 IV. Integration — --commit, --rule, --strict, --json flags
8 V. Integration — exit-code contract
9 VI. Integration — JSON schema validation
10 VII. Integration — no rules file → built-in defaults
11 VIII. Regression — bugs fixed in this review
12 IX. Stress — 100-file repo with deliberate violations
13 """
14
15 from __future__ import annotations
16 from muse.core.paths import muse_dir
17
18 import json
19 import pathlib
20
21 import pytest
22
23 from typing import TypedDict
24
25 from tests.cli_test_helper import CliRunner, InvokeResult
26 from muse.core.invariants import BaseViolation
27 from muse.core.types import Manifest, blob_id
28 from muse.core.object_store import object_path
29 from muse.plugins.code._invariants import (
30 check_forbidden_dependency,
31 check_layer_boundary,
32 load_invariant_rules,
33 run_invariants,
34 )
35
36
37 class _InvariantsCliJson(TypedDict, total=False):
38 """Shape of the JSON output from ``muse code invariants --json``."""
39
40 commit_id: str
41 domain: str
42 branch: str
43 ref: str
44 using_defaults: bool
45 rule_filter: str | None
46 strict: bool
47 rules_checked: int
48 violations_total: int
49 errors: int
50 warnings: int
51 violations: list[BaseViolation]
52
53
54 cli = None
55 runner = CliRunner()
56
57 type _FilesMap = dict[str, bytes]
58
59 # ---------------------------------------------------------------------------
60 # Shared fixtures and helpers
61 # ---------------------------------------------------------------------------
62
63
64 @pytest.fixture
65 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
66 """Fresh Muse repo in tmp_path."""
67 monkeypatch.chdir(tmp_path)
68 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
69 result = runner.invoke(cli, ["init"])
70 assert result.exit_code == 0, result.output
71 return tmp_path
72
73
74 def _write(repo: pathlib.Path, rel: str, content: str) -> None:
75 p = repo / rel
76 p.parent.mkdir(parents=True, exist_ok=True)
77 p.write_text(content)
78
79
80 def _commit(msg: str = "snapshot") -> None:
81 r = runner.invoke(cli, ["code", "add", "."])
82 assert r.exit_code == 0, r.output
83 r = runner.invoke(cli, ["commit", "-m", msg])
84 assert r.exit_code == 0, r.output
85
86
87 def _inv(args: list[str] | None = None) -> InvokeResult:
88 return runner.invoke(cli, ["code", "invariants"] + (args or []))
89
90
91 def _write_object(root: pathlib.Path, content: bytes) -> str:
92 oid = blob_id(content)
93 p = object_path(root, oid)
94 p.parent.mkdir(parents=True, exist_ok=True)
95 p.write_bytes(content)
96 return oid
97
98
99 def _make_bare_repo(tmp_path: pathlib.Path) -> pathlib.Path:
100 muse = muse_dir(tmp_path)
101 muse.mkdir()
102 (muse / "repo.json").write_text('{"repo_id":"test"}')
103 (muse / "HEAD").write_text("ref: refs/heads/main")
104 (muse / "commits").mkdir()
105 (muse / "snapshots").mkdir()
106 (muse / "refs" / "heads").mkdir(parents=True)
107 (muse / "objects").mkdir()
108 return tmp_path
109
110
111 # ---------------------------------------------------------------------------
112 # Section I — Unit: check_forbidden_dependency
113 # ---------------------------------------------------------------------------
114
115
116 class TestCheckForbiddenDependency:
117 def _manifest(
118 self, root: pathlib.Path, files: _FilesMap
119 ) -> Manifest:
120 manifest: Manifest = {}
121 for fp, src in files.items():
122 h = _write_object(root, src)
123 manifest[fp] = h
124 return manifest
125
126 def test_violation_detected(self, tmp_path: pathlib.Path) -> None:
127 root = _make_bare_repo(tmp_path)
128 src = {
129 "core/engine.py": b"from cli import app\n",
130 "cli/app.py": b"def run(): pass\n",
131 }
132 manifest = self._manifest(root, src)
133 violations = check_forbidden_dependency(
134 manifest, root, "core→cli", "error",
135 source_pattern="core/", forbidden_pattern="cli/",
136 )
137 assert len(violations) == 1
138 assert "core/engine.py" in violations[0]["address"]
139 assert violations[0]["severity"] == "error"
140
141 def test_no_violation_when_no_match(self, tmp_path: pathlib.Path) -> None:
142 root = _make_bare_repo(tmp_path)
143 src = {
144 "core/engine.py": b"def process(): pass\n",
145 "cli/app.py": b"from core.engine import process\n",
146 }
147 manifest = self._manifest(root, src)
148 violations = check_forbidden_dependency(
149 manifest, root, "no-op", "error",
150 source_pattern="core/", forbidden_pattern="cli/",
151 )
152 # cli imports from core, not the other way around
153 assert violations == []
154
155 def test_empty_pattern_skips_with_no_violations(
156 self, tmp_path: pathlib.Path
157 ) -> None:
158 root = _make_bare_repo(tmp_path)
159 manifest = self._manifest(root, {"a.py": b"import b\n", "b.py": b""})
160 # Missing forbidden_pattern → should log warning and return []
161 violations = check_forbidden_dependency(
162 manifest, root, "bad-rule", "error",
163 source_pattern="a", forbidden_pattern="",
164 )
165 assert violations == []
166
167 def test_multiple_violations(self, tmp_path: pathlib.Path) -> None:
168 root = _make_bare_repo(tmp_path)
169 src = {
170 "core/a.py": b"from cli import x\n",
171 "core/b.py": b"from cli import y\n",
172 "cli/x.py": b"def x(): pass\n",
173 "cli/y.py": b"def y(): pass\n",
174 }
175 manifest = self._manifest(root, src)
176 violations = check_forbidden_dependency(
177 manifest, root, "core→cli", "error",
178 source_pattern="core/", forbidden_pattern="cli/",
179 )
180 assert len(violations) == 2
181
182 def test_warning_severity_respected(self, tmp_path: pathlib.Path) -> None:
183 root = _make_bare_repo(tmp_path)
184 src = {
185 "core/engine.py": b"from cli import app\n",
186 "cli/app.py": b"def run(): pass\n",
187 }
188 manifest = self._manifest(root, src)
189 violations = check_forbidden_dependency(
190 manifest, root, "soft-rule", "warning",
191 source_pattern="core/", forbidden_pattern="cli/",
192 )
193 assert all(v["severity"] == "warning" for v in violations)
194
195
196 # ---------------------------------------------------------------------------
197 # Section II — Unit: check_layer_boundary
198 # ---------------------------------------------------------------------------
199
200
201 class TestCheckLayerBoundary:
202 def _manifest(
203 self, root: pathlib.Path, files: _FilesMap
204 ) -> Manifest:
205 manifest: Manifest = {}
206 for fp, src in files.items():
207 h = _write_object(root, src)
208 manifest[fp] = h
209 return manifest
210
211 def test_lower_imports_upper_is_violation(
212 self, tmp_path: pathlib.Path
213 ) -> None:
214 root = _make_bare_repo(tmp_path)
215 src = {
216 "core/engine.py": b"from cli import app\n",
217 "cli/app.py": b"def run(): pass\n",
218 }
219 manifest = self._manifest(root, src)
220 violations = check_layer_boundary(
221 manifest, root, "layer", "error",
222 lower="core/", upper="cli/",
223 )
224 assert len(violations) == 1
225 assert violations[0]["severity"] == "error"
226 assert "core/engine.py" in violations[0]["address"]
227
228 def test_upper_importing_lower_is_allowed(
229 self, tmp_path: pathlib.Path
230 ) -> None:
231 root = _make_bare_repo(tmp_path)
232 src = {
233 "core/engine.py": b"def process(): pass\n",
234 "cli/app.py": b"from core import engine\n",
235 }
236 manifest = self._manifest(root, src)
237 # cli (upper) importing core (lower) should NOT be a violation
238 violations = check_layer_boundary(
239 manifest, root, "layer", "error",
240 lower="core/", upper="cli/",
241 )
242 assert violations == []
243
244 def test_empty_params_skip_with_no_violations(
245 self, tmp_path: pathlib.Path
246 ) -> None:
247 root = _make_bare_repo(tmp_path)
248 manifest = self._manifest(root, {"a.py": b"import b\n", "b.py": b""})
249 violations = check_layer_boundary(
250 manifest, root, "bad-rule", "error", lower="", upper=""
251 )
252 assert violations == []
253
254
255 # ---------------------------------------------------------------------------
256 # Section III — Integration: all 6 rule types via CLI
257 # ---------------------------------------------------------------------------
258
259 _SIMPLE_MODULE = """\
260 def compute(x: int) -> int:
261 return x * 2
262 """
263
264 _COMPLEX_MODULE = """\
265 def very_complex(x: int) -> int:
266 if x > 0:
267 if x > 10:
268 if x > 100:
269 if x > 1000:
270 if x > 10000:
271 if x > 100000:
272 if x > 1000000:
273 if x > 10000000:
274 if x > 100000000:
275 if x > 1000000000:
276 return x
277 return 0
278 """
279
280 _RULES_MAX_COMPLEXITY = """\
281 [[rule]]
282 name = "complexity gate"
283 severity = "warning"
284 scope = "function"
285 rule_type = "max_complexity"
286 [rule.params]
287 threshold = 5
288 """
289
290 _RULES_NO_CYCLES = """\
291 [[rule]]
292 name = "no cycles"
293 severity = "error"
294 scope = "file"
295 rule_type = "no_circular_imports"
296 """
297
298 _RULES_FORBIDDEN = """\
299 [[rule]]
300 name = "core must not import cli"
301 severity = "error"
302 scope = "file"
303 rule_type = "forbidden_dependency"
304 [rule.params]
305 source_pattern = "src/core/"
306 forbidden_pattern = "src/cli/"
307 """
308
309 _RULES_LAYER = """\
310 [[rule]]
311 name = "layer boundary"
312 severity = "error"
313 scope = "file"
314 rule_type = "layer_boundary"
315 [rule.params]
316 lower = "src/core/"
317 upper = "src/cli/"
318 """
319
320 _RULES_TEST_COVERAGE = """\
321 [[rule]]
322 name = "test coverage floor"
323 severity = "warning"
324 scope = "repo"
325 rule_type = "test_coverage_floor"
326 [rule.params]
327 min_ratio = 0.99
328 """
329
330 _RULES_DEAD_EXPORTS = """\
331 [[rule]]
332 name = "no dead exports"
333 severity = "warning"
334 scope = "file"
335 rule_type = "no_dead_exports"
336 """
337
338
339 class TestIntegrationRuleTypes:
340 def _rules(self, repo: pathlib.Path, content: str) -> None:
341 _write(repo, ".muse/code_invariants.toml", content)
342
343 def test_max_complexity_violation(self, repo: pathlib.Path) -> None:
344 self._rules(repo, _RULES_MAX_COMPLEXITY)
345 _write(repo, "src/complex.py", _COMPLEX_MODULE)
346 _commit("add complex")
347 result = _inv()
348 assert "complexity" in result.output.lower()
349 # warnings don't exit 1 without --strict
350 assert result.exit_code == 0
351
352 def test_max_complexity_pass(self, repo: pathlib.Path) -> None:
353 self._rules(repo, _RULES_MAX_COMPLEXITY)
354 _write(repo, "src/simple.py", _SIMPLE_MODULE)
355 _commit("add simple")
356 result = _inv()
357 assert result.exit_code == 0
358 assert "✅" in result.output
359
360 def test_no_circular_imports_cycle_detected(
361 self, repo: pathlib.Path
362 ) -> None:
363 self._rules(repo, _RULES_NO_CYCLES)
364 _write(repo, "src/a.py", "from src import b\n")
365 _write(repo, "src/b.py", "from src import a\n")
366 _commit("cycle")
367 result = _inv()
368 assert "cycle" in result.output.lower() or "circular" in result.output.lower()
369 assert result.exit_code == 1 # error severity
370
371 def test_no_circular_imports_clean(self, repo: pathlib.Path) -> None:
372 self._rules(repo, _RULES_NO_CYCLES)
373 _write(repo, "src/a.py", "def foo(): pass\n")
374 _write(repo, "src/b.py", "from src import a\n")
375 _commit("no cycle")
376 result = _inv()
377 assert result.exit_code == 0
378
379 def test_forbidden_dependency_violation(
380 self, repo: pathlib.Path
381 ) -> None:
382 self._rules(repo, _RULES_FORBIDDEN)
383 _write(repo, "src/core/engine.py", "from src.cli import app\n")
384 _write(repo, "src/cli/app.py", "def run(): pass\n")
385 _commit("forbidden import")
386 result = _inv()
387 assert "core" in result.output or "forbidden" in result.output.lower()
388 assert result.exit_code == 1
389
390 def test_forbidden_dependency_clean(self, repo: pathlib.Path) -> None:
391 self._rules(repo, _RULES_FORBIDDEN)
392 _write(repo, "src/core/engine.py", "def process(): pass\n")
393 _write(repo, "src/cli/app.py", "from src.core import engine\n")
394 _commit("allowed import direction")
395 result = _inv()
396 assert result.exit_code == 0
397
398 def test_layer_boundary_violation(self, repo: pathlib.Path) -> None:
399 self._rules(repo, _RULES_LAYER)
400 _write(repo, "src/core/engine.py", "from src.cli import app\n")
401 _write(repo, "src/cli/app.py", "def run(): pass\n")
402 _commit("layer violation")
403 result = _inv()
404 assert result.exit_code == 1
405
406 def test_test_coverage_floor_violation(self, repo: pathlib.Path) -> None:
407 self._rules(repo, _RULES_TEST_COVERAGE)
408 _write(repo, "src/billing.py", "def pay(): pass\ndef refund(): pass\n")
409 # No test file → coverage is 0% < 99%
410 _commit("no tests")
411 result = _inv()
412 assert "coverage" in result.output.lower()
413 assert result.exit_code == 0 # warning severity
414
415 def test_test_coverage_floor_pass(self, repo: pathlib.Path) -> None:
416 self._rules(repo, _RULES_TEST_COVERAGE.replace("0.99", "0.0"))
417 _write(repo, "src/billing.py", "def pay(): pass\n")
418 _write(repo, "tests/test_billing.py", "def test_pay(): pass\n")
419 _commit("with tests, floor=0")
420 result = _inv()
421 assert result.exit_code == 0
422
423 def test_no_dead_exports_flags_unreferenced(
424 self, repo: pathlib.Path
425 ) -> None:
426 self._rules(repo, _RULES_DEAD_EXPORTS)
427 _write(repo, "src/utils.py", "def orphan(): pass\n")
428 _commit("orphan function")
429 result = _inv()
430 # Dead exports are warnings — no exit 1 without --strict
431 assert result.exit_code == 0
432
433
434 # ---------------------------------------------------------------------------
435 # Section IV — Integration: flags
436 # ---------------------------------------------------------------------------
437
438
439 class TestFlags:
440 def _rules(self, repo: pathlib.Path, content: str) -> None:
441 _write(repo, ".muse/code_invariants.toml", content)
442
443 def test_commit_flag_resolves_branch_name(
444 self, repo: pathlib.Path
445 ) -> None:
446 self._rules(repo, _RULES_NO_CYCLES)
447 _write(repo, "src/a.py", _SIMPLE_MODULE)
448 _commit("v1")
449 result = _inv(["--commit", "main"])
450 assert result.exit_code == 0
451
452 def test_commit_flag_invalid_ref_exits_nonzero(
453 self, repo: pathlib.Path
454 ) -> None:
455 self._rules(repo, _RULES_NO_CYCLES)
456 _write(repo, "src/a.py", _SIMPLE_MODULE)
457 _commit("v1")
458 result = _inv(["--commit", "no-such-ref-xyz"])
459 assert result.exit_code != 0
460
461 def test_rule_filter_restricts_to_matching_rules(
462 self, repo: pathlib.Path
463 ) -> None:
464 combined = f"{_RULES_MAX_COMPLEXITY}\n{_RULES_NO_CYCLES}"
465 self._rules(repo, combined)
466 _write(repo, "src/a.py", _SIMPLE_MODULE)
467 _commit("simple")
468 result = _inv(["--rule", "no_circular_imports"])
469 # Should only run the no_circular_imports rule, not complexity gate
470 assert "complexity" not in result.output.lower()
471 assert result.exit_code == 0
472
473 def test_rule_filter_by_name(self, repo: pathlib.Path) -> None:
474 self._rules(repo, f"{_RULES_MAX_COMPLEXITY}\n{_RULES_NO_CYCLES}")
475 _write(repo, "src/a.py", _SIMPLE_MODULE)
476 _commit("simple")
477 result = _inv(["--rule", "no cycles"])
478 assert result.exit_code == 0
479
480 def test_rule_filter_no_match_exits_zero(
481 self, repo: pathlib.Path
482 ) -> None:
483 self._rules(repo, _RULES_NO_CYCLES)
484 _write(repo, "src/a.py", _SIMPLE_MODULE)
485 _commit("simple")
486 result = _inv(["--rule", "nonexistent_rule_type_xyz"])
487 assert result.exit_code == 0
488
489 def test_strict_makes_warnings_exit_one(self, repo: pathlib.Path) -> None:
490 self._rules(repo, _RULES_MAX_COMPLEXITY)
491 _write(repo, "src/complex.py", _COMPLEX_MODULE)
492 _commit("complex")
493 # Without --strict: warning, exit 0
494 assert _inv().exit_code == 0
495 # With --strict: warning → exit 1
496 assert _inv(["--strict"]).exit_code == 1
497
498 def test_strict_no_violations_exits_zero(
499 self, repo: pathlib.Path
500 ) -> None:
501 self._rules(repo, _RULES_NO_CYCLES)
502 _write(repo, "src/a.py", _SIMPLE_MODULE)
503 _commit("clean")
504 assert _inv(["--strict"]).exit_code == 0
505
506 def test_no_rules_file_uses_defaults(self, repo: pathlib.Path) -> None:
507 # Ensure no code_invariants.toml exists
508 (muse_dir(repo) / "code_invariants.toml").unlink(missing_ok=True)
509 _write(repo, "src/a.py", _SIMPLE_MODULE)
510 _commit("no rules file")
511 result = _inv()
512 # Built-in defaults should still produce output
513 assert "built-in default" in result.output
514
515
516 # ---------------------------------------------------------------------------
517 # Section V — Exit-code contract
518 # ---------------------------------------------------------------------------
519
520
521 class TestExitCode:
522 def _rules(self, repo: pathlib.Path, content: str) -> None:
523 _write(repo, ".muse/code_invariants.toml", content)
524
525 def test_all_pass_exits_zero(self, repo: pathlib.Path) -> None:
526 self._rules(repo, _RULES_NO_CYCLES)
527 _write(repo, "src/a.py", _SIMPLE_MODULE)
528 _commit("clean")
529 assert _inv().exit_code == 0
530
531 def test_error_violations_exit_one(self, repo: pathlib.Path) -> None:
532 self._rules(repo, _RULES_NO_CYCLES)
533 _write(repo, "src/a.py", "from src import b\n")
534 _write(repo, "src/b.py", "from src import a\n")
535 _commit("cycle")
536 assert _inv().exit_code == 1
537
538 def test_warning_violations_exit_zero_without_strict(
539 self, repo: pathlib.Path
540 ) -> None:
541 self._rules(repo, _RULES_MAX_COMPLEXITY)
542 _write(repo, "src/c.py", _COMPLEX_MODULE)
543 _commit("complex")
544 assert _inv().exit_code == 0
545
546 def test_warning_violations_exit_one_with_strict(
547 self, repo: pathlib.Path
548 ) -> None:
549 self._rules(repo, _RULES_MAX_COMPLEXITY)
550 _write(repo, "src/c.py", _COMPLEX_MODULE)
551 _commit("complex")
552 assert _inv(["--strict"]).exit_code == 1
553
554 def test_json_exit_code_matches_human(self, repo: pathlib.Path) -> None:
555 self._rules(repo, _RULES_NO_CYCLES)
556 _write(repo, "src/a.py", "from src import b\n")
557 _write(repo, "src/b.py", "from src import a\n")
558 _commit("cycle")
559 human_code = _inv().exit_code
560 json_code = _inv(["--json"]).exit_code
561 assert human_code == json_code == 1
562
563
564 # ---------------------------------------------------------------------------
565 # Section VI — JSON output schema
566 # ---------------------------------------------------------------------------
567
568
569 class TestJsonSchema:
570 def _j(self, args: list[str] | None = None) -> _InvariantsCliJson:
571 result = _inv((args or []) + ["--json"])
572 data: _InvariantsCliJson = json.loads(result.output)
573 return data
574
575 def _rules(self, repo: pathlib.Path, content: str) -> None:
576 _write(repo, ".muse/code_invariants.toml", content)
577
578 def test_required_keys_present(self, repo: pathlib.Path) -> None:
579 self._rules(repo, _RULES_NO_CYCLES)
580 _write(repo, "src/a.py", _SIMPLE_MODULE)
581 _commit("baseline")
582 d = self._j()
583 required = {
584 "muse_version", "commit", "branch", "ref", "using_defaults",
585 "rule_filter", "strict", "rules_checked", "violations_total",
586 "errors", "warnings_count", "violations",
587 }
588 assert required <= d.keys()
589
590 def test_zero_violations_when_clean(self, repo: pathlib.Path) -> None:
591 self._rules(repo, _RULES_NO_CYCLES)
592 _write(repo, "src/a.py", _SIMPLE_MODULE)
593 _commit("baseline")
594 d = self._j()
595 assert d["violations_total"] == 0
596 assert d["errors"] == 0
597 assert d["warnings_count"] == 0
598 assert d["violations"] == []
599
600 def test_violation_fields_present(self, repo: pathlib.Path) -> None:
601 self._rules(repo, _RULES_NO_CYCLES)
602 _write(repo, "src/a.py", "from src import b\n")
603 _write(repo, "src/b.py", "from src import a\n")
604 _commit("cycle")
605 d = self._j()
606 assert d["violations_total"] >= 1
607 for v in d["violations"]:
608 assert {"rule_name", "severity", "address", "description"} <= v.keys()
609
610 def test_errors_and_warnings_counted_correctly(
611 self, repo: pathlib.Path
612 ) -> None:
613 # Mix error + warning rules
614 self._rules(repo, f"{_RULES_NO_CYCLES}\n{_RULES_MAX_COMPLEXITY}")
615 _write(repo, "src/a.py", "from src import b\n")
616 _write(repo, "src/b.py", "from src import a\n")
617 _write(repo, "src/c.py", _COMPLEX_MODULE)
618 _commit("mixed")
619 d = self._j()
620 assert d["errors"] >= 1
621 assert d["warnings_count"] >= 1
622 assert d["errors"] + d["warnings_count"] == d["violations_total"]
623
624 def test_strict_reflected_in_json(self, repo: pathlib.Path) -> None:
625 self._rules(repo, _RULES_NO_CYCLES)
626 _write(repo, "src/a.py", _SIMPLE_MODULE)
627 _commit("baseline")
628 d = self._j(["--strict"])
629 assert d["strict"] is True
630
631 def test_rule_filter_reflected_in_json(self, repo: pathlib.Path) -> None:
632 self._rules(repo, _RULES_NO_CYCLES)
633 _write(repo, "src/a.py", _SIMPLE_MODULE)
634 _commit("baseline")
635 d = self._j(["--rule", "no_circular_imports"])
636 assert d["rule_filter"] == "no_circular_imports"
637
638 def test_using_defaults_false_when_rules_file_exists(
639 self, repo: pathlib.Path
640 ) -> None:
641 self._rules(repo, _RULES_NO_CYCLES)
642 _write(repo, "src/a.py", _SIMPLE_MODULE)
643 _commit("baseline")
644 d = self._j()
645 assert d["using_defaults"] is False
646
647 def test_using_defaults_true_when_no_rules_file(
648 self, repo: pathlib.Path
649 ) -> None:
650 (muse_dir(repo) / "code_invariants.toml").unlink(missing_ok=True)
651 _write(repo, "src/a.py", _SIMPLE_MODULE)
652 _commit("no rules file")
653 d = self._j()
654 assert d["using_defaults"] is True
655
656 def test_branch_is_nonempty_string(self, repo: pathlib.Path) -> None:
657 self._rules(repo, _RULES_NO_CYCLES)
658 _write(repo, "src/a.py", _SIMPLE_MODULE)
659 _commit("baseline")
660 d = self._j()
661 assert isinstance(d["branch"], str) and d["branch"]
662
663 def test_rules_checked_matches_loaded_rules(
664 self, repo: pathlib.Path
665 ) -> None:
666 combined = f"{_RULES_NO_CYCLES}\n{_RULES_MAX_COMPLEXITY}"
667 self._rules(repo, combined)
668 _write(repo, "src/a.py", _SIMPLE_MODULE)
669 _commit("two rules")
670 d = self._j()
671 assert d["rules_checked"] == 2
672
673
674 # ---------------------------------------------------------------------------
675 # Section VII — No rules file → built-in defaults
676 # ---------------------------------------------------------------------------
677
678
679 class TestBuiltinDefaults:
680 def test_runs_without_error_when_no_file(
681 self, repo: pathlib.Path
682 ) -> None:
683 (muse_dir(repo) / "code_invariants.toml").unlink(missing_ok=True)
684 _write(repo, "src/a.py", _SIMPLE_MODULE)
685 _commit("no file")
686 result = _inv()
687 # Should succeed (may warn but not error out)
688 assert "built-in" in result.output.lower() or "default" in result.output.lower()
689
690 def test_json_using_defaults_true(self, repo: pathlib.Path) -> None:
691 (muse_dir(repo) / "code_invariants.toml").unlink(missing_ok=True)
692 _write(repo, "src/a.py", _SIMPLE_MODULE)
693 _commit("no file")
694 d = json.loads(_inv(["--json"]).output)
695 assert d["using_defaults"] is True
696
697 def test_builtin_defaults_include_complexity_and_cycles(
698 self, repo: pathlib.Path
699 ) -> None:
700 (muse_dir(repo) / "code_invariants.toml").unlink(missing_ok=True)
701 _write(repo, "src/a.py", _SIMPLE_MODULE)
702 _commit("no file")
703 d = json.loads(_inv(["--json"]).output)
704 # Built-in defaults are: complexity_gate, no_cycles, dead_exports
705 assert d["rules_checked"] == 3
706
707
708 # ---------------------------------------------------------------------------
709 # Section VIII — Regression: bugs fixed in this review
710 # ---------------------------------------------------------------------------
711
712
713 class TestRegressions:
714 def _rules(self, repo: pathlib.Path, content: str) -> None:
715 _write(repo, ".muse/code_invariants.toml", content)
716
717 def test_exit_code_1_on_error_violations(self, repo: pathlib.Path) -> None:
718 """Old CLI always exited 0 — useless as a CI gate."""
719 self._rules(repo, _RULES_NO_CYCLES)
720 _write(repo, "src/a.py", "from src import b\n")
721 _write(repo, "src/b.py", "from src import a\n")
722 _commit("cycle")
723 assert _inv().exit_code == 1
724
725 def test_invalid_ref_exits_nonzero_not_silently(
726 self, repo: pathlib.Path
727 ) -> None:
728 """Old CLI silently defaulted `manifest = None or {}` — ran with empty snapshot."""
729 self._rules(repo, _RULES_NO_CYCLES)
730 _write(repo, "src/a.py", _SIMPLE_MODULE)
731 _commit("baseline")
732 result = _inv(["--commit", "no-such-ref-8675309"])
733 assert result.exit_code != 0
734
735 def test_branch_name_resolves_for_commit_flag(
736 self, repo: pathlib.Path
737 ) -> None:
738 """Old code passed branch name to resolve_commit_ref which doesn't handle it."""
739 self._rules(repo, _RULES_NO_CYCLES)
740 _write(repo, "src/a.py", _SIMPLE_MODULE)
741 _commit("v1")
742 result = _inv(["--commit", "main"])
743 assert result.exit_code == 0, result.output
744
745 def test_json_has_branch_field(self, repo: pathlib.Path) -> None:
746 """Old JSON output was missing branch field."""
747 self._rules(repo, _RULES_NO_CYCLES)
748 _write(repo, "src/a.py", _SIMPLE_MODULE)
749 _commit("baseline")
750 d = json.loads(_inv(["--json"]).output)
751 assert "branch" in d
752 assert isinstance(d["branch"], str)
753
754 def test_json_has_errors_and_warnings_fields(
755 self, repo: pathlib.Path
756 ) -> None:
757 """Old JSON output was missing errors/warnings split."""
758 self._rules(repo, _RULES_NO_CYCLES)
759 _write(repo, "src/a.py", _SIMPLE_MODULE)
760 _commit("baseline")
761 d = json.loads(_inv(["--json"]).output)
762 assert "errors" in d
763 assert "warnings_count" in d
764
765 def test_forbidden_dependency_available(self, repo: pathlib.Path) -> None:
766 """Old CLI only had 4 rule types; forbidden_dependency was not in plugin engine."""
767 self._rules(repo, _RULES_FORBIDDEN)
768 _write(repo, "src/core/engine.py", "from src.cli import app\n")
769 _write(repo, "src/cli/app.py", "def run(): pass\n")
770 _commit("forbidden import")
771 result = _inv()
772 assert result.exit_code == 1
773
774 def test_layer_boundary_available(self, repo: pathlib.Path) -> None:
775 """Old CLI only had 4 rule types; layer_boundary was not in plugin engine."""
776 self._rules(repo, _RULES_LAYER)
777 _write(repo, "src/core/engine.py", "from src.cli import app\n")
778 _write(repo, "src/cli/app.py", "def run(): pass\n")
779 _commit("layer violation")
780 result = _inv()
781 assert result.exit_code == 1
782
783 def test_no_silent_or_fallback_on_corrupt_commit(
784 self, repo: pathlib.Path
785 ) -> None:
786 """Old code: `manifest = ... or {}` silently ran with empty snapshot."""
787 self._rules(repo, _RULES_NO_CYCLES)
788 _write(repo, "src/a.py", _SIMPLE_MODULE)
789 _commit("baseline")
790 # Corrupt the snapshot by pointing to a nonexistent ref
791 result = _inv(["--commit", "deadbeef00"])
792 # Should fail, not silently succeed with zero violations
793 assert result.exit_code != 0
794
795
796 # ---------------------------------------------------------------------------
797 # Section IX — Stress tests
798 # ---------------------------------------------------------------------------
799
800 _CLEAN_MOD = "def fn_{i}(x: int) -> int:\n return x + {i}\n"
801 _CYCLIC_MOD = "from src import mod_{j}\n"
802
803
804 class TestStress:
805 @pytest.mark.slow
806 def test_100_clean_files_no_false_positives(
807 self, repo: pathlib.Path
808 ) -> None:
809 """100 clean files with a no_circular_imports rule — zero violations."""
810 _write(repo, ".muse/code_invariants.toml", _RULES_NO_CYCLES)
811 for i in range(100):
812 _write(repo, f"src/mod_{i:03d}.py", _CLEAN_MOD.format(i=i))
813 _commit("100 clean files")
814 d = json.loads(_inv(["--json"]).output)
815 assert d["violations_total"] == 0
816
817 @pytest.mark.slow
818 def test_large_repo_with_multiple_rule_types(
819 self, repo: pathlib.Path
820 ) -> None:
821 """50 files: mix of complexity, cycle, and forbidden rules."""
822 combined = f"{_RULES_MAX_COMPLEXITY.replace('5', '20')}\n{_RULES_NO_CYCLES}" # threshold=20, most pass
823 _write(repo, ".muse/code_invariants.toml", combined)
824 for i in range(50):
825 _write(repo, f"src/mod_{i:03d}.py", _CLEAN_MOD.format(i=i))
826 # Introduce one cycle
827 _write(repo, "src/mod_000.py", "from src import mod_001\n")
828 _write(repo, "src/mod_001.py", "from src import mod_000\n")
829 _commit("50 files + cycle")
830 d = json.loads(_inv(["--json"]).output)
831 assert d["errors"] >= 1 # the cycle
832
833 @pytest.mark.slow
834 def test_rule_filter_on_large_repo(self, repo: pathlib.Path) -> None:
835 """Rule filter works correctly on a large repo — only selected rule runs."""
836 combined = f"{_RULES_MAX_COMPLEXITY}\n{_RULES_NO_CYCLES}"
837 _write(repo, ".muse/code_invariants.toml", combined)
838 for i in range(50):
839 _write(repo, f"src/mod_{i:03d}.py", _CLEAN_MOD.format(i=i))
840 _commit("50 files")
841 # With filter: only no_circular_imports — result should have 1 rule checked
842 d = json.loads(_inv(["--json", "--rule", "no_circular_imports"]).output)
843 assert d["rules_checked"] == 1
844
845 @pytest.mark.slow
846 def test_json_violation_schema_consistent_across_all_rule_types(
847 self, repo: pathlib.Path
848 ) -> None:
849 """Every violation across all 6 rule types has the required JSON fields."""
850 all_rules = "\n".join([
851 _RULES_MAX_COMPLEXITY.replace("5", "3"), # low threshold → many violations
852 _RULES_DEAD_EXPORTS,
853 _RULES_TEST_COVERAGE,
854 _RULES_FORBIDDEN,
855 _RULES_LAYER,
856 ])
857 _write(repo, ".muse/code_invariants.toml", all_rules)
858 _write(repo, "src/core/engine.py", f"from src.cli import app\n{_COMPLEX_MODULE}")
859 _write(repo, "src/cli/app.py", "def run(): pass\n")
860 _commit("all violations")
861 d = json.loads(_inv(["--json"]).output)
862 required_keys = {"rule_name", "severity", "address", "description"}
863 for v in d["violations"]:
864 missing = required_keys - v.keys()
865 assert not missing, f"Violation missing keys {missing}: {v}"
866
867 @pytest.mark.slow
868 def test_strict_on_100_file_repo_with_one_warning(
869 self, repo: pathlib.Path
870 ) -> None:
871 """--strict exits 1 with even a single warning in a 100-file clean repo."""
872 _write(
873 repo,
874 ".muse/code_invariants.toml",
875 _RULES_MAX_COMPLEXITY.replace("5", "3"), # low threshold
876 )
877 # Only one file violates
878 for i in range(99):
879 _write(repo, f"src/mod_{i:03d}.py", _CLEAN_MOD.format(i=i))
880 _write(repo, "src/mod_099.py", _COMPLEX_MODULE)
881 _commit("99 clean + 1 complex")
882 assert _inv().exit_code == 0 # warning → 0
883 assert _inv(["--strict"]).exit_code == 1 # warning + strict → 1
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 125 days ago