gabriel / muse public

test_sem_ver.py file-level

at sha256:d · View file ↗ · Intel ↗

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