gabriel / muse public
test_sem_ver.py python
895 lines 33.1 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Tests for the semver classifier (muse.core.semver_classifier).
2
3 Coverage
4 --------
5 StabilityManifest
6 - empty() returns a manifest with all frozensets empty.
7 - load() returns empty manifest when no stability.toml exists.
8 - load() parses [stable], [unstable], [experimental], [invisible] sections.
9 - stability_for() returns "stable" / "experimental" / "unstable" correctly.
10 - stability_for() defaults to "unstable" for undeclared symbols.
11 - stability_for() matches fnmatch glob patterns.
12 - is_invisible() matches repo-specific invisible patterns.
13 - is_invisible() returns False when no patterns match.
14
15 _UNIVERSAL_INVISIBLE_PATTERNS
16 - LICENSE, *.md, *.txt match.
17 - docs/**, tests/** directories match.
18 - test_*.py, conftest.py match.
19 - *.lock, *.pyc match.
20 - Source .py files do NOT match.
21 - Binary files (*.png, *.mp3) do NOT match (not in universal patterns).
22
23 ChangeClassification
24 - All fields stored as-is (frozen dataclass).
25
26 SemVerClassification
27 - breaking_addresses returns sorted address list.
28 - all_classifications returns flat list of all four groups.
29
30 classify_delta — core bump matrix
31 - Empty delta → bump="none", confidence=1.0.
32 - Insert public unstable → bump="patch".
33 - Insert public stable → bump="minor".
34 - Delete public unstable → bump="minor" (breaking on unstable surface).
35 - Delete public stable → bump="major".
36 - Replace with implementation change → bump="patch".
37 - Replace with signature change, stable → bump="major".
38 - Replace with signature change, unstable → bump="minor".
39 - Replace with rename, stable → bump="major".
40 - Replace with unrecognised summary → bump="minor" (unstable) with confidence<1.0.
41 - Multiple ops → highest bump wins.
42
43 classify_delta — invisible gates
44 - Op on LICENSE file → invisible, bump="none".
45 - Op on *.md file → invisible, bump="none".
46 - Op on docs/** path → invisible, bump="none".
47 - Op on tests/** path → invisible, bump="none".
48 - Op on test_*.py file → invisible, bump="none".
49 - Op on *.pyc file → invisible, bump="none".
50 - PatchOp on invisible file → invisible, bump="none".
51
52 classify_delta — private symbol gate
53 - Insert on underscore-prefixed symbol → invisible, bump="none".
54 - Delete on underscore-prefixed symbol → invisible, bump="none".
55 - Replace on underscore-prefixed symbol → invisible, bump="none".
56
57 classify_delta — experimental surface
58 - Delete public experimental → bump="patch".
59 - Insert public experimental → bump="patch".
60 - Signature change experimental → bump="patch".
61
62 classify_delta — PatchOp recursion
63 - PatchOp on non-invisible file with child insert → classifies children.
64 - PatchOp on invisible file → entire op is invisible.
65 - PatchOp with no child_ops → implementation change (confidence=0.7).
66
67 classify_delta — MoveOp and MutateOp
68 - MoveOp on public exported symbol → implementation change.
69 - MutateOp on public exported symbol → implementation change.
70 - MoveOp on private symbol → invisible.
71
72 classify_delta — DirectoryRenameOp
73 - Rename in code domain → breaking (confidence=0.5).
74 - Rename in non-code domain → invisible.
75 - Rename of invisible directory → invisible.
76
77 ConflictRecord
78 - Default conflict_type is "file_level".
79 - All fields settable.
80 - addresses default factory is independent across instances.
81
82 SemVerBump literals
83 - All four valid values are plain strings.
84 """
85
86 from __future__ import annotations
87
88 import pathlib
89 import textwrap
90 from dataclasses import fields
91
92 import pytest
93
94 from muse.core.semver_classifier import (
95 ChangeClassification,
96 SemVerClassification,
97 StabilityManifest,
98 VisibilityTier,
99 StabilityTier,
100 ChangeKind,
101 classify_delta,
102 _is_universally_invisible,
103 )
104 from muse.domain import (
105 ConflictRecord,
106 DeleteOp,
107 InsertOp,
108 MoveOp,
109 MutateOp,
110 PatchOp,
111 ReplaceOp,
112 SemVerBump,
113 StructuredDelta,
114 )
115
116
117 # ---------------------------------------------------------------------------
118 # Helpers
119 # ---------------------------------------------------------------------------
120
121
122 def _delta(
123 *ops: InsertOp | DeleteOp | ReplaceOp | MoveOp | PatchOp | MutateOp,
124 domain: str = "code",
125 ) -> StructuredDelta:
126 return StructuredDelta(domain=domain, ops=list(ops), summary="test")
127
128
129 def _insert(address: str) -> InsertOp:
130 name = address.split("::")[-1] if "::" in address else address
131 return InsertOp(
132 op="insert",
133 address=address,
134 position=None,
135 content_id="cid_" + name,
136 content_summary=f"new function: {name}",
137 )
138
139
140 def _delete(address: str) -> DeleteOp:
141 return DeleteOp(
142 op="delete",
143 address=address,
144 content_id="cid_" + address,
145 content_summary=f"removed: {address}",
146 )
147
148
149 def _replace(address: str, new_summary: str, old_summary: str = "") -> ReplaceOp:
150 return ReplaceOp(
151 op="replace",
152 address=address,
153 old_content_id="old_cid",
154 new_content_id="new_cid",
155 old_summary=old_summary or new_summary,
156 new_summary=new_summary,
157 )
158
159
160 def _move(old_address: str, new_address: str) -> MoveOp:
161 return MoveOp(
162 op="move",
163 old_address=old_address,
164 new_address=new_address,
165 content_id="cid",
166 content_summary=f"moved {old_address} → {new_address}",
167 )
168
169
170 def _mutate(address: str) -> MutateOp:
171 return MutateOp(
172 op="mutate",
173 address=address,
174 field="velocity",
175 old_value=64,
176 new_value=80,
177 )
178
179
180 def _patch(address: str, *child_ops) -> PatchOp:
181 return PatchOp(
182 op="patch",
183 address=address,
184 content_id_before="old",
185 content_id_after="new",
186 child_ops=list(child_ops),
187 child_summary=f"{len(child_ops)} child ops",
188 )
189
190
191 def _manifest_with_stable(*addresses: str) -> StabilityManifest:
192 return StabilityManifest(stable=frozenset(addresses))
193
194
195 def _manifest_with_experimental(*addresses: str) -> StabilityManifest:
196 return StabilityManifest(experimental=frozenset(addresses))
197
198
199 # ---------------------------------------------------------------------------
200 # StabilityManifest
201 # ---------------------------------------------------------------------------
202
203
204 class TestStabilityManifestEmpty:
205 def test_empty_has_no_declarations(self) -> None:
206 m = StabilityManifest.empty()
207 assert len(m.stable) == 0
208 assert len(m.unstable) == 0
209 assert len(m.experimental) == 0
210 assert len(m.invisible) == 0
211
212 def test_empty_stability_for_defaults_to_unstable(self) -> None:
213 m = StabilityManifest.empty()
214 assert m.stability_for("any/file.py::AnySymbol") == "unstable"
215
216 def test_empty_is_invisible_is_always_false(self) -> None:
217 m = StabilityManifest.empty()
218 assert not m.is_invisible("any/file.py")
219
220
221 class TestStabilityManifestLoad:
222 def test_load_returns_empty_when_no_file(self, tmp_path: pathlib.Path) -> None:
223 m = StabilityManifest.load(tmp_path)
224 assert m == StabilityManifest.empty()
225
226 def test_load_parses_stable_symbols(self, tmp_path: pathlib.Path) -> None:
227 (tmp_path / ".muse").mkdir()
228 (tmp_path / ".muse" / "stability.toml").write_text(textwrap.dedent("""\
229 [stable]
230 symbols = ["muse/core/store.py::CommitRecord"]
231 """))
232 m = StabilityManifest.load(tmp_path)
233 assert "muse/core/store.py::CommitRecord" in m.stable
234
235 def test_load_parses_stable_patterns(self, tmp_path: pathlib.Path) -> None:
236 (tmp_path / ".muse").mkdir()
237 (tmp_path / ".muse" / "stability.toml").write_text(textwrap.dedent("""\
238 [stable]
239 patterns = ["muse/core/store.py::*"]
240 """))
241 m = StabilityManifest.load(tmp_path)
242 assert "muse/core/store.py::*" in m.stable
243
244 def test_load_parses_experimental(self, tmp_path: pathlib.Path) -> None:
245 (tmp_path / ".muse").mkdir()
246 (tmp_path / ".muse" / "stability.toml").write_text(textwrap.dedent("""\
247 [experimental]
248 symbols = ["muse/cli/commands/release.py::run_suggest"]
249 """))
250 m = StabilityManifest.load(tmp_path)
251 assert "muse/cli/commands/release.py::run_suggest" in m.experimental
252
253 def test_load_parses_invisible_patterns(self, tmp_path: pathlib.Path) -> None:
254 (tmp_path / ".muse").mkdir()
255 (tmp_path / ".muse" / "stability.toml").write_text(textwrap.dedent("""\
256 [invisible]
257 patterns = ["src/ts/**", "*.scss"]
258 """))
259 m = StabilityManifest.load(tmp_path)
260 assert "src/ts/**" in m.invisible
261 assert "*.scss" in m.invisible
262
263 def test_load_all_sections_together(self, tmp_path: pathlib.Path) -> None:
264 (tmp_path / ".muse").mkdir()
265 (tmp_path / ".muse" / "stability.toml").write_text(textwrap.dedent("""\
266 [stable]
267 symbols = ["a.py::Foo"]
268
269 [unstable]
270 symbols = ["a.py::Bar"]
271
272 [experimental]
273 symbols = ["a.py::Baz"]
274
275 [invisible]
276 patterns = ["generated/**"]
277 """))
278 m = StabilityManifest.load(tmp_path)
279 assert "a.py::Foo" in m.stable
280 assert "a.py::Bar" in m.unstable
281 assert "a.py::Baz" in m.experimental
282 assert "generated/**" in m.invisible
283
284
285 class TestStabilityManifestStabilityFor:
286 def test_declared_stable_exact_match(self) -> None:
287 m = StabilityManifest(stable=frozenset({"a.py::Foo"}))
288 assert m.stability_for("a.py::Foo") == "stable"
289
290 def test_declared_experimental_exact_match(self) -> None:
291 m = StabilityManifest(experimental=frozenset({"a.py::Baz"}))
292 assert m.stability_for("a.py::Baz") == "experimental"
293
294 def test_undeclared_defaults_to_unstable(self) -> None:
295 m = StabilityManifest(stable=frozenset({"a.py::Foo"}))
296 assert m.stability_for("a.py::Bar") == "unstable"
297
298 def test_stable_pattern_glob(self) -> None:
299 m = StabilityManifest(stable=frozenset({"muse/core/store.py::*"}))
300 assert m.stability_for("muse/core/store.py::CommitRecord") == "stable"
301 assert m.stability_for("muse/core/store.py::SnapshotRecord") == "stable"
302
303 def test_stable_wins_over_experimental_when_both_match(self) -> None:
304 # stable is checked first
305 m = StabilityManifest(
306 stable=frozenset({"a.py::Foo"}),
307 experimental=frozenset({"a.py::Foo"}),
308 )
309 assert m.stability_for("a.py::Foo") == "stable"
310
311 def test_empty_manifest_always_unstable(self) -> None:
312 m = StabilityManifest.empty()
313 assert m.stability_for("anything.py::anything") == "unstable"
314
315
316 class TestStabilityManifestIsInvisible:
317 def test_invisible_pattern_matches(self) -> None:
318 m = StabilityManifest(invisible=frozenset({"src/ts/**"}))
319 assert m.is_invisible("src/ts/client.ts")
320
321 def test_no_pattern_returns_false(self) -> None:
322 m = StabilityManifest.empty()
323 assert not m.is_invisible("src/main.py")
324
325 def test_unmatched_pattern_returns_false(self) -> None:
326 m = StabilityManifest(invisible=frozenset({"generated/**"}))
327 assert not m.is_invisible("src/main.py")
328
329
330 # ---------------------------------------------------------------------------
331 # _UNIVERSAL_INVISIBLE_PATTERNS
332 # ---------------------------------------------------------------------------
333
334
335 class TestUniversalInvisiblePatterns:
336 @pytest.mark.parametrize("path", [
337 "LICENSE",
338 "LICENSE.md",
339 "COPYING",
340 "NOTICE",
341 "README",
342 "README.md",
343 "CHANGELOG.md",
344 "CHANGES",
345 "HISTORY.txt",
346 "docs/index.md",
347 "docs/api/reference.rst",
348 "doc/overview.txt",
349 "tests/test_foo.py",
350 "test/unit/bar.py",
351 "spec/integration.js",
352 "test_helpers.py",
353 "conftest.py",
354 "foo_test.py",
355 "muse/core/__pycache__/store.cpython-312.pyc",
356 "foo.pyc",
357 "requirements.txt",
358 "requirements-dev.txt",
359 "poetry.lock",
360 "package-lock.json",
361 "yarn.lock",
362 "Pipfile.lock",
363 "foo.md",
364 "foo.rst",
365 "foo.txt",
366 ".gitignore",
367 ".gitattributes",
368 ".museignore",
369 ".editorconfig",
370 "Makefile",
371 ])
372 def test_invisible(self, path: str) -> None:
373 assert _is_universally_invisible(path), f"Expected {path!r} to be invisible"
374
375 @pytest.mark.parametrize("path", [
376 "muse/core/store.py",
377 "muse/domain.py",
378 "src/main.py",
379 "musehub/services/wire.py",
380 "muse/core/semver_classifier.py",
381 "setup.py",
382 "pyproject.toml",
383 ])
384 def test_not_invisible(self, path: str) -> None:
385 assert not _is_universally_invisible(path), f"Expected {path!r} to NOT be invisible"
386
387
388 # ---------------------------------------------------------------------------
389 # ChangeClassification and SemVerClassification
390 # ---------------------------------------------------------------------------
391
392
393 class TestChangeClassification:
394 def test_frozen_stores_all_fields(self) -> None:
395 cc = ChangeClassification(
396 address="a.py::Foo",
397 change_kind="breaking",
398 stability="stable",
399 visibility="exported",
400 confidence=1.0,
401 reason="deleted from stable surface",
402 )
403 assert cc.address == "a.py::Foo"
404 assert cc.change_kind == "breaking"
405 assert cc.stability == "stable"
406 assert cc.visibility == "exported"
407 assert cc.confidence == 1.0
408 assert cc.reason == "deleted from stable surface"
409
410 def test_frozen_is_immutable(self) -> None:
411 cc = ChangeClassification(
412 address="a.py::Foo",
413 change_kind="additive",
414 stability="unstable",
415 visibility="exported",
416 confidence=0.9,
417 reason="new symbol",
418 )
419 with pytest.raises((AttributeError, TypeError)):
420 cc.address = "b.py::Bar" # type: ignore[misc]
421
422
423 class TestSemVerClassification:
424 def _make_cc(self, kind: ChangeKind, address: str = "a.py::Foo") -> ChangeClassification:
425 return ChangeClassification(
426 address=address,
427 change_kind=kind,
428 stability="stable",
429 visibility="exported",
430 confidence=1.0,
431 reason="test",
432 )
433
434 def test_breaking_addresses_sorted(self) -> None:
435 svc = SemVerClassification(
436 bump="major",
437 confidence=1.0,
438 breaking=[
439 self._make_cc("breaking", "b.py::Z"),
440 self._make_cc("breaking", "a.py::A"),
441 ],
442 additive=[],
443 implementation=[],
444 invisible=[],
445 )
446 assert svc.breaking_addresses == ["a.py::A", "b.py::Z"]
447
448 def test_breaking_addresses_empty_when_no_breaking(self) -> None:
449 svc = SemVerClassification(
450 bump="patch",
451 confidence=1.0,
452 breaking=[],
453 additive=[],
454 implementation=[self._make_cc("implementation")],
455 invisible=[],
456 )
457 assert svc.breaking_addresses == []
458
459 def test_all_classifications_flat(self) -> None:
460 b = self._make_cc("breaking")
461 a = self._make_cc("additive")
462 i = self._make_cc("implementation")
463 inv = self._make_cc("invisible")
464 svc = SemVerClassification(
465 bump="major",
466 confidence=1.0,
467 breaking=[b],
468 additive=[a],
469 implementation=[i],
470 invisible=[inv],
471 )
472 all_cc = svc.all_classifications
473 assert len(all_cc) == 4
474 assert b in all_cc
475 assert a in all_cc
476 assert i in all_cc
477 assert inv in all_cc
478
479
480 # ---------------------------------------------------------------------------
481 # classify_delta — core bump matrix
482 # ---------------------------------------------------------------------------
483
484
485 class TestClassifyDeltaEmpty:
486 def test_empty_ops_is_none(self) -> None:
487 result = classify_delta(_delta())
488 assert result.bump == "none"
489 assert result.confidence == 1.0
490 assert result.breaking == []
491 assert result.additive == []
492 assert result.implementation == []
493 assert result.invisible == []
494
495
496 class TestClassifyDeltaInsert:
497 def test_insert_public_unstable_is_patch(self) -> None:
498 # Default: no manifest → unstable → additive → PATCH
499 result = classify_delta(_delta(_insert("src/a.py::compute")))
500 assert result.bump == "patch"
501 assert len(result.additive) == 1
502 assert result.additive[0].address == "src/a.py::compute"
503 assert result.additive[0].change_kind == "additive"
504
505 def test_insert_public_stable_is_minor(self) -> None:
506 manifest = _manifest_with_stable("src/a.py::compute")
507 result = classify_delta(_delta(_insert("src/a.py::compute")), manifest=manifest)
508 assert result.bump == "minor"
509
510 def test_insert_private_symbol_is_invisible(self) -> None:
511 result = classify_delta(_delta(_insert("src/a.py::_helper")))
512 assert result.bump == "none"
513 assert len(result.invisible) == 1
514 assert result.invisible[0].change_kind == "invisible"
515
516
517 class TestClassifyDeltaDelete:
518 def test_delete_public_unstable_is_minor(self) -> None:
519 result = classify_delta(_delta(_delete("src/a.py::compute")))
520 assert result.bump == "minor"
521 assert len(result.breaking) == 1
522 assert result.breaking[0].address == "src/a.py::compute"
523 assert result.breaking[0].change_kind == "breaking"
524 assert result.breaking[0].stability == "unstable"
525
526 def test_delete_public_stable_is_major(self) -> None:
527 manifest = _manifest_with_stable("src/a.py::compute")
528 result = classify_delta(_delta(_delete("src/a.py::compute")), manifest=manifest)
529 assert result.bump == "major"
530 assert result.breaking[0].stability == "stable"
531
532 def test_delete_private_symbol_is_invisible(self) -> None:
533 result = classify_delta(_delta(_delete("src/a.py::_internal")))
534 assert result.bump == "none"
535 assert len(result.invisible) == 1
536
537
538 class TestClassifyDeltaReplace:
539 def test_replace_implementation_change_is_patch(self) -> None:
540 result = classify_delta(_delta(_replace("src/a.py::compute", "implementation changed")))
541 assert result.bump == "patch"
542 assert len(result.implementation) == 1
543 assert result.implementation[0].change_kind == "implementation"
544 assert result.implementation[0].confidence == 1.0
545
546 def test_replace_signature_change_stable_is_major(self) -> None:
547 manifest = _manifest_with_stable("src/a.py::compute")
548 result = classify_delta(
549 _delta(_replace("src/a.py::compute", "signature changed")),
550 manifest=manifest,
551 )
552 assert result.bump == "major"
553 assert result.breaking[0].stability == "stable"
554 assert result.breaking[0].confidence == 1.0
555
556 def test_replace_signature_change_unstable_is_minor(self) -> None:
557 result = classify_delta(_delta(_replace("src/a.py::compute", "signature changed")))
558 assert result.bump == "minor"
559 assert result.breaking[0].stability == "unstable"
560
561 def test_replace_rename_stable_is_major(self) -> None:
562 manifest = _manifest_with_stable("src/a.py::compute")
563 result = classify_delta(
564 _delta(_replace("src/a.py::compute", "renamed to compute_total")),
565 manifest=manifest,
566 )
567 assert result.bump == "major"
568
569 def test_replace_rename_unstable_is_minor(self) -> None:
570 result = classify_delta(_delta(_replace("src/a.py::compute", "renamed to compute_total")))
571 assert result.bump == "minor"
572
573 def test_replace_unrecognised_summary_is_conservative_low_confidence(self) -> None:
574 result = classify_delta(_delta(_replace("src/a.py::compute", "reformatted")))
575 # Conservative: classified as breaking, unstable → minor
576 assert result.bump == "minor"
577 assert result.breaking[0].confidence == pytest.approx(0.4, abs=0.01)
578
579 def test_replace_private_symbol_is_invisible(self) -> None:
580 result = classify_delta(_delta(_replace("src/a.py::_helper", "signature changed")))
581 assert result.bump == "none"
582 assert result.invisible[0].change_kind == "invisible"
583
584
585 class TestClassifyDeltaPromotion:
586 def test_major_wins_over_minor(self) -> None:
587 manifest = _manifest_with_stable("src/a.py::old_func")
588 result = classify_delta(_delta(
589 _insert("src/a.py::new_func"), # additive, unstable → patch
590 _delete("src/a.py::old_func"), # breaking, stable → major
591 ), manifest=manifest)
592 assert result.bump == "major"
593
594 def test_minor_wins_over_patch(self) -> None:
595 manifest = _manifest_with_stable("src/a.py::new_public")
596 result = classify_delta(_delta(
597 _insert("src/a.py::new_public"), # additive, stable → minor
598 _replace("src/a.py::existing", "implementation changed"), # patch
599 ), manifest=manifest)
600 assert result.bump == "minor"
601
602 def test_multiple_breaking_addresses(self) -> None:
603 manifest = _manifest_with_stable("src/a.py::func_a", "src/b.py::func_b")
604 result = classify_delta(_delta(
605 _delete("src/a.py::func_a"),
606 _delete("src/b.py::func_b"),
607 ), manifest=manifest)
608 assert result.bump == "major"
609 addresses = result.breaking_addresses
610 assert "src/a.py::func_a" in addresses
611 assert "src/b.py::func_b" in addresses
612 assert addresses == sorted(addresses)
613
614
615 # ---------------------------------------------------------------------------
616 # classify_delta — invisible gates
617 # ---------------------------------------------------------------------------
618
619
620 class TestClassifyDeltaInvisibleGates:
621 @pytest.mark.parametrize("address", [
622 "LICENSE",
623 "LICENSE.md",
624 "README.md",
625 "CHANGELOG.md",
626 "docs/overview.md",
627 "tests/test_foo.py",
628 "test_helpers.py",
629 "conftest.py",
630 "requirements.txt",
631 "poetry.lock",
632 "foo.pyc",
633 ])
634 def test_insert_on_invisible_file_produces_no_bump(self, address: str) -> None:
635 result = classify_delta(_delta(_insert(address)))
636 assert result.bump == "none", f"Expected no bump for {address!r}"
637 assert len(result.invisible) == 1
638
639 @pytest.mark.parametrize("address", [
640 "LICENSE",
641 "docs/api.md",
642 "tests/test_core.py",
643 ])
644 def test_delete_on_invisible_file_produces_no_bump(self, address: str) -> None:
645 result = classify_delta(_delta(_delete(address)))
646 assert result.bump == "none"
647
648 def test_patch_op_on_invisible_file_is_invisible(self) -> None:
649 # PatchOp whose address is an invisible file — all child ops must also be invisible
650 child = _insert("tests/test_foo.py::helper")
651 patch_op = _patch("tests/test_foo.py", child)
652 result = classify_delta(_delta(patch_op))
653 assert result.bump == "none"
654 assert len(result.invisible) == 1
655
656 def test_mix_invisible_and_visible_ops(self) -> None:
657 manifest = _manifest_with_stable("src/a.py::compute")
658 result = classify_delta(_delta(
659 _delete("LICENSE"), # invisible
660 _delete("src/a.py::compute"), # breaking, stable → major
661 ), manifest=manifest)
662 assert result.bump == "major"
663 assert len(result.invisible) == 1
664 assert len(result.breaking) == 1
665
666
667 # ---------------------------------------------------------------------------
668 # classify_delta — experimental surface
669 # ---------------------------------------------------------------------------
670
671
672 class TestClassifyDeltaExperimental:
673 def test_delete_public_experimental_is_patch(self) -> None:
674 manifest = _manifest_with_experimental("src/a.py::feature")
675 result = classify_delta(_delta(_delete("src/a.py::feature")), manifest=manifest)
676 assert result.bump == "patch"
677 assert result.breaking[0].stability == "experimental"
678
679 def test_insert_public_experimental_is_patch(self) -> None:
680 manifest = _manifest_with_experimental("src/a.py::feature")
681 result = classify_delta(_delta(_insert("src/a.py::feature")), manifest=manifest)
682 assert result.bump == "patch"
683
684 def test_signature_change_experimental_is_patch(self) -> None:
685 manifest = _manifest_with_experimental("src/a.py::feature")
686 result = classify_delta(
687 _delta(_replace("src/a.py::feature", "signature changed")),
688 manifest=manifest,
689 )
690 assert result.bump == "patch"
691
692
693 # ---------------------------------------------------------------------------
694 # classify_delta — PatchOp recursion
695 # ---------------------------------------------------------------------------
696
697
698 class TestClassifyDeltaPatchOp:
699 def test_patch_op_recurses_into_children(self) -> None:
700 # Child insert of public unstable symbol → patch
701 child = _insert("src/a.py::compute::inner_func")
702 patch = _patch("src/a.py::compute", child)
703 result = classify_delta(_delta(patch))
704 assert result.bump == "patch"
705 assert len(result.additive) == 1
706
707 def test_patch_op_no_child_ops_is_implementation(self) -> None:
708 patch = _patch("src/a.py::compute")
709 result = classify_delta(_delta(patch))
710 assert result.bump == "patch"
711 assert result.implementation[0].confidence == pytest.approx(0.7, abs=0.01)
712
713 def test_patch_op_invisible_file_skips_children(self) -> None:
714 # Child would be breaking, but file is invisible → whole op is invisible
715 child = _delete("tests/test_foo.py::SomeClass")
716 patch = _patch("tests/test_foo.py", child)
717 result = classify_delta(_delta(patch))
718 assert result.bump == "none"
719 assert len(result.invisible) == 1
720
721 def test_patch_op_with_stable_child_delete_is_major(self) -> None:
722 manifest = _manifest_with_stable("src/a.py::compute::inner_public")
723 child = _delete("src/a.py::compute::inner_public")
724 patch = _patch("src/a.py::compute", child)
725 result = classify_delta(_delta(patch), manifest=manifest)
726 assert result.bump == "major"
727
728
729 # ---------------------------------------------------------------------------
730 # classify_delta — MoveOp and MutateOp
731 # ---------------------------------------------------------------------------
732
733
734 class TestClassifyDeltaMoveAndMutate:
735 def test_move_op_public_exported_is_implementation(self) -> None:
736 op = _move("src/a.py::compute", "src/b.py::compute")
737 result = classify_delta(_delta(op))
738 assert result.bump == "patch"
739 assert result.implementation[0].change_kind == "implementation"
740
741 def test_move_op_private_symbol_is_invisible(self) -> None:
742 op = _move("src/a.py::_helper", "src/a.py::_helper_v2")
743 result = classify_delta(_delta(op))
744 assert result.bump == "none"
745 assert result.invisible[0].change_kind == "invisible"
746
747 def test_mutate_op_public_is_implementation(self) -> None:
748 op = _mutate("track/note_1")
749 result = classify_delta(_delta(op, domain="midi"))
750 assert result.bump == "patch"
751 assert result.implementation[0].change_kind == "implementation"
752
753 def test_mutate_op_private_symbol_is_invisible(self) -> None:
754 # Private convention requires :: separator — underscore on a bare path
755 # does not mean private (MIDI tracks, non-Python assets, etc.)
756 op = _mutate("src/midi.py::_internal_note")
757 result = classify_delta(_delta(op, domain="code"))
758 assert result.bump == "none"
759
760
761 # ---------------------------------------------------------------------------
762 # classify_delta — DirectoryRenameOp
763 # ---------------------------------------------------------------------------
764
765
766 class TestClassifyDeltaDirectoryRename:
767 def _dir_rename_op(
768 self, from_address: str, address: str, domain: str = "code"
769 ) -> dict:
770 return {
771 "op": "directory_rename",
772 "address": address,
773 "from_address": from_address,
774 }
775
776 def test_code_domain_rename_is_breaking_low_confidence(self) -> None:
777 op = self._dir_rename_op("muse/core", "muse/engine", domain="code")
778 delta: StructuredDelta = {"domain": "code", "ops": [op], "summary": ""} # type: ignore[typeddict-item]
779 result = classify_delta(delta)
780 assert result.bump == "minor" # breaking on unstable surface
781 assert result.breaking[0].confidence == pytest.approx(0.5, abs=0.01)
782
783 def test_non_code_domain_rename_is_invisible(self) -> None:
784 op = self._dir_rename_op("tracks/verse", "tracks/intro", domain="midi")
785 delta: StructuredDelta = {"domain": "midi", "ops": [op], "summary": ""} # type: ignore[typeddict-item]
786 result = classify_delta(delta)
787 assert result.bump == "none"
788 assert result.invisible[0].change_kind == "invisible"
789
790 def test_invisible_directory_rename_is_invisible(self) -> None:
791 op = self._dir_rename_op("docs/api", "docs/reference", domain="code")
792 delta: StructuredDelta = {"domain": "code", "ops": [op], "summary": ""} # type: ignore[typeddict-item]
793 result = classify_delta(delta)
794 assert result.bump == "none"
795
796
797 # ---------------------------------------------------------------------------
798 # classify_delta — repo_root auto-loads manifest
799 # ---------------------------------------------------------------------------
800
801
802 class TestClassifyDeltaRepoRoot:
803 def test_repo_root_loads_stability_toml(self, tmp_path: pathlib.Path) -> None:
804 (tmp_path / ".muse").mkdir()
805 (tmp_path / ".muse" / "stability.toml").write_text(textwrap.dedent("""\
806 [stable]
807 symbols = ["src/a.py::compute"]
808 """))
809 # Delete of stable symbol → major
810 result = classify_delta(_delta(_delete("src/a.py::compute")), repo_root=tmp_path)
811 assert result.bump == "major"
812
813 def test_repo_root_missing_stability_toml_falls_back_to_empty(
814 self, tmp_path: pathlib.Path
815 ) -> None:
816 result = classify_delta(_delta(_delete("src/a.py::compute")), repo_root=tmp_path)
817 # No stability.toml → unstable → breaking → minor
818 assert result.bump == "minor"
819
820 def test_explicit_manifest_overrides_repo_root(self, tmp_path: pathlib.Path) -> None:
821 # repo_root has stable declaration, but explicit manifest overrides with no stable
822 (tmp_path / ".muse").mkdir()
823 (tmp_path / ".muse" / "stability.toml").write_text(textwrap.dedent("""\
824 [stable]
825 symbols = ["src/a.py::compute"]
826 """))
827 explicit_manifest = StabilityManifest.empty()
828 result = classify_delta(
829 _delta(_delete("src/a.py::compute")),
830 manifest=explicit_manifest,
831 repo_root=tmp_path,
832 )
833 # Explicit manifest (empty) takes priority → unstable → minor
834 assert result.bump == "minor"
835
836
837 # ---------------------------------------------------------------------------
838 # ConflictRecord (regression — must still work after semver classifier rewrite)
839 # ---------------------------------------------------------------------------
840
841
842 class TestConflictRecord:
843 def test_defaults(self) -> None:
844 cr = ConflictRecord(path="src/billing.py")
845 assert cr.conflict_type == "file_level"
846 assert cr.ours_summary == ""
847 assert cr.theirs_summary == ""
848 assert cr.addresses == []
849
850 def test_all_fields_settable(self) -> None:
851 cr = ConflictRecord(
852 path="src/billing.py",
853 conflict_type="symbol_edit_overlap",
854 ours_summary="renamed compute_total",
855 theirs_summary="modified compute_total",
856 addresses=["src/billing.py::compute_total"],
857 )
858 assert cr.path == "src/billing.py"
859 assert cr.conflict_type == "symbol_edit_overlap"
860 assert cr.ours_summary == "renamed compute_total"
861 assert cr.theirs_summary == "modified compute_total"
862 assert cr.addresses == ["src/billing.py::compute_total"]
863
864 def test_all_conflict_types_accepted(self) -> None:
865 for ct in [
866 "symbol_edit_overlap", "rename_edit", "move_edit",
867 "delete_use", "dependency_conflict", "file_level",
868 ]:
869 cr = ConflictRecord(path="f.py", conflict_type=ct)
870 assert cr.conflict_type == ct
871
872 def test_addresses_default_factory_is_independent(self) -> None:
873 cr1 = ConflictRecord(path="a.py")
874 cr2 = ConflictRecord(path="b.py")
875 cr1.addresses.append("a.py::f")
876 assert cr2.addresses == []
877
878 def test_field_names(self) -> None:
879 field_names = {f.name for f in fields(ConflictRecord)}
880 assert {"path", "conflict_type", "ours_summary", "theirs_summary", "addresses"} <= field_names
881
882
883 # ---------------------------------------------------------------------------
884 # SemVerBump literals
885 # ---------------------------------------------------------------------------
886
887
888 class TestSemVerBumpLiterals:
889 def test_all_values_are_valid_strings(self) -> None:
890 for val in ("major", "minor", "patch", "none"):
891 assert isinstance(val, str)
892
893 def test_classify_delta_returns_valid_bump(self) -> None:
894 result = classify_delta(_delta())
895 assert result.bump in ("major", "minor", "patch", "none")
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago