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