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