"""Tests for the semver classifier (muse.core.semver_classifier). Coverage -------- StabilityManifest - empty() returns a manifest with all frozensets empty. - load() returns empty manifest when no stability.toml exists. - load() parses [stable], [unstable], [experimental], [invisible] sections. - stability_for() returns "stable" / "experimental" / "unstable" correctly. - stability_for() defaults to "unstable" for undeclared symbols. - stability_for() matches fnmatch glob patterns. - is_invisible() matches repo-specific invisible patterns. - is_invisible() returns False when no patterns match. _UNIVERSAL_INVISIBLE_PATTERNS - LICENSE, *.md, *.txt match. - docs/**, tests/** directories match. - test_*.py, conftest.py match. - *.lock, *.pyc match. - Source .py files do NOT match. - Binary files (*.png, *.mp3) do NOT match (not in universal patterns). ChangeClassification - All fields stored as-is (frozen dataclass). SemVerClassification - breaking_addresses returns sorted address list. - all_classifications returns flat list of all four groups. classify_delta — core bump matrix - Empty delta → bump="none", confidence=1.0. - Insert public unstable → bump="patch". - Insert public stable → bump="minor". - Delete public unstable → bump="minor" (breaking on unstable surface). - Delete public stable → bump="major". - Replace with implementation change → bump="patch". - Replace with signature change, stable → bump="major". - Replace with signature change, unstable → bump="minor". - Replace with rename, stable → bump="major". - Replace with unrecognised summary → bump="minor" (unstable) with confidence<1.0. - Multiple ops → highest bump wins. classify_delta — invisible gates - Op on LICENSE file → invisible, bump="none". - Op on *.md file → invisible, bump="none". - Op on docs/** path → invisible, bump="none". - Op on tests/** path → invisible, bump="none". - Op on test_*.py file → invisible, bump="none". - Op on *.pyc file → invisible, bump="none". - PatchOp on invisible file → invisible, bump="none". classify_delta — private symbol gate - Insert on underscore-prefixed symbol → invisible, bump="none". - Delete on underscore-prefixed symbol → invisible, bump="none". - Replace on underscore-prefixed symbol → invisible, bump="none". classify_delta — experimental surface - Delete public experimental → bump="patch". - Insert public experimental → bump="patch". - Signature change experimental → bump="patch". classify_delta — PatchOp recursion - PatchOp on non-invisible file with child insert → classifies children. - PatchOp on invisible file → entire op is invisible. - PatchOp with no child_ops → implementation change (confidence=0.7). classify_delta — MoveOp and MutateOp - MoveOp on public exported symbol → implementation change. - MutateOp on public exported symbol → implementation change. - MoveOp on private symbol → invisible. classify_delta — RenameOp - Rename in code domain → breaking (confidence=0.5). - Rename in non-code domain → invisible. - Rename of invisible directory → invisible. ConflictRecord - Default conflict_type is "file_level". - All fields settable. - addresses default factory is independent across instances. SemVerBump literals - All four valid values are plain strings. """ from __future__ import annotations from collections.abc import Mapping import pathlib import textwrap from dataclasses import fields import pytest from muse.core.paths import muse_dir, stability_toml_path from muse.core.semver_classifier import ( ChangeClassification, SemVerClassification, StabilityManifest, VisibilityTier, StabilityTier, ChangeKind, classify_delta, _is_universally_invisible, ) from muse.domain import ( ConflictRecord, DeleteOp, InsertOp, MoveOp, MutateOp, PatchOp, ReplaceOp, SemVerBump, StructuredDelta, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _delta( *ops: InsertOp | DeleteOp | ReplaceOp | MoveOp | PatchOp | MutateOp, domain: str = "code", ) -> StructuredDelta: return StructuredDelta(domain=domain, ops=list(ops), summary="test") def _insert(address: str) -> InsertOp: name = address.split("::")[-1] if "::" in address else address return InsertOp( op="insert", address=address, position=None, content_id=f"cid_{name}", content_summary=f"new function: {name}", ) def _delete(address: str) -> DeleteOp: return DeleteOp( op="delete", address=address, content_id=f"cid_{address}", content_summary=f"removed: {address}", ) def _replace(address: str, new_summary: str, old_summary: str = "") -> ReplaceOp: return ReplaceOp( op="replace", address=address, old_content_id="old_cid", new_content_id="new_cid", old_summary=old_summary or new_summary, new_summary=new_summary, ) def _move(old_address: str, new_address: str) -> MoveOp: return MoveOp( op="move", old_address=old_address, new_address=new_address, content_id="cid", content_summary=f"moved {old_address} → {new_address}", ) def _mutate(address: str) -> MutateOp: return MutateOp( op="mutate", address=address, field="velocity", old_value=64, new_value=80, ) def _patch(address: str, *child_ops: PatchOp) -> PatchOp: return PatchOp( op="patch", address=address, content_id_before="old", content_id_after="new", child_ops=list(child_ops), child_summary=f"{len(child_ops)} child ops", ) def _manifest_with_stable(*addresses: str) -> StabilityManifest: return StabilityManifest(stable=frozenset(addresses)) def _manifest_with_experimental(*addresses: str) -> StabilityManifest: return StabilityManifest(experimental=frozenset(addresses)) # --------------------------------------------------------------------------- # StabilityManifest # --------------------------------------------------------------------------- class TestStabilityManifestEmpty: def test_empty_has_no_declarations(self) -> None: m = StabilityManifest.empty() assert len(m.stable) == 0 assert len(m.unstable) == 0 assert len(m.experimental) == 0 assert len(m.invisible) == 0 def test_empty_stability_for_defaults_to_unstable(self) -> None: m = StabilityManifest.empty() assert m.stability_for("any/file.py::AnySymbol") == "unstable" def test_empty_is_invisible_is_always_false(self) -> None: m = StabilityManifest.empty() assert not m.is_invisible("any/file.py") class TestStabilityManifestLoad: def test_load_returns_empty_when_no_file(self, tmp_path: pathlib.Path) -> None: m = StabilityManifest.load(tmp_path) assert m == StabilityManifest.empty() def test_load_parses_stable_symbols(self, tmp_path: pathlib.Path) -> None: muse_dir(tmp_path).mkdir() stability_toml_path(tmp_path).write_text(textwrap.dedent("""\ [stable] symbols = ["muse/core/store.py::CommitRecord"] """)) m = StabilityManifest.load(tmp_path) assert "muse/core/store.py::CommitRecord" in m.stable def test_load_parses_stable_patterns(self, tmp_path: pathlib.Path) -> None: muse_dir(tmp_path).mkdir() stability_toml_path(tmp_path).write_text(textwrap.dedent("""\ [stable] patterns = ["muse/core/store.py::*"] """)) m = StabilityManifest.load(tmp_path) assert "muse/core/store.py::*" in m.stable def test_load_parses_experimental(self, tmp_path: pathlib.Path) -> None: muse_dir(tmp_path).mkdir() stability_toml_path(tmp_path).write_text(textwrap.dedent("""\ [experimental] symbols = ["muse/cli/commands/release.py::run_suggest"] """)) m = StabilityManifest.load(tmp_path) assert "muse/cli/commands/release.py::run_suggest" in m.experimental def test_load_parses_invisible_patterns(self, tmp_path: pathlib.Path) -> None: muse_dir(tmp_path).mkdir() stability_toml_path(tmp_path).write_text(textwrap.dedent("""\ [invisible] patterns = ["src/ts/**", "*.scss"] """)) m = StabilityManifest.load(tmp_path) assert "src/ts/**" in m.invisible assert "*.scss" in m.invisible def test_load_all_sections_together(self, tmp_path: pathlib.Path) -> None: muse_dir(tmp_path).mkdir() stability_toml_path(tmp_path).write_text(textwrap.dedent("""\ [stable] symbols = ["a.py::Foo"] [unstable] symbols = ["a.py::Bar"] [experimental] symbols = ["a.py::Baz"] [invisible] patterns = ["generated/**"] """)) m = StabilityManifest.load(tmp_path) assert "a.py::Foo" in m.stable assert "a.py::Bar" in m.unstable assert "a.py::Baz" in m.experimental assert "generated/**" in m.invisible class TestStabilityManifestStabilityFor: def test_declared_stable_exact_match(self) -> None: m = StabilityManifest(stable=frozenset({"a.py::Foo"})) assert m.stability_for("a.py::Foo") == "stable" def test_declared_experimental_exact_match(self) -> None: m = StabilityManifest(experimental=frozenset({"a.py::Baz"})) assert m.stability_for("a.py::Baz") == "experimental" def test_undeclared_defaults_to_unstable(self) -> None: m = StabilityManifest(stable=frozenset({"a.py::Foo"})) assert m.stability_for("a.py::Bar") == "unstable" def test_stable_pattern_glob(self) -> None: m = StabilityManifest(stable=frozenset({"muse/core/store.py::*"})) assert m.stability_for("muse/core/store.py::CommitRecord") == "stable" assert m.stability_for("muse/core/store.py::SnapshotRecord") == "stable" def test_stable_wins_over_experimental_when_both_match(self) -> None: # stable is checked first m = StabilityManifest( stable=frozenset({"a.py::Foo"}), experimental=frozenset({"a.py::Foo"}), ) assert m.stability_for("a.py::Foo") == "stable" def test_empty_manifest_always_unstable(self) -> None: m = StabilityManifest.empty() assert m.stability_for("anything.py::anything") == "unstable" class TestStabilityManifestIsInvisible: def test_invisible_pattern_matches(self) -> None: m = StabilityManifest(invisible=frozenset({"src/ts/**"})) assert m.is_invisible("src/ts/client.ts") def test_no_pattern_returns_false(self) -> None: m = StabilityManifest.empty() assert not m.is_invisible("src/main.py") def test_unmatched_pattern_returns_false(self) -> None: m = StabilityManifest(invisible=frozenset({"generated/**"})) assert not m.is_invisible("src/main.py") # --------------------------------------------------------------------------- # _UNIVERSAL_INVISIBLE_PATTERNS # --------------------------------------------------------------------------- class TestUniversalInvisiblePatterns: @pytest.mark.parametrize("path", [ "LICENSE", "LICENSE.md", "COPYING", "NOTICE", "README", "README.md", "CHANGELOG.md", "CHANGES", "HISTORY.txt", "docs/index.md", "docs/api/reference.rst", "doc/overview.txt", "tests/test_foo.py", "test/unit/bar.py", "spec/integration.js", "test_helpers.py", "conftest.py", "foo_test.py", "muse/core/__pycache__/store.cpython-312.pyc", "foo.pyc", "requirements.txt", "requirements-dev.txt", "poetry.lock", "package-lock.json", "yarn.lock", "Pipfile.lock", "foo.md", "foo.rst", "foo.txt", ".gitignore", ".gitattributes", ".museignore", ".editorconfig", "Makefile", ]) def test_invisible(self, path: str) -> None: assert _is_universally_invisible(path), f"Expected {path!r} to be invisible" @pytest.mark.parametrize("path", [ "muse/core/store.py", "muse/domain.py", "src/main.py", "musehub/services/wire.py", "muse/core/semver_classifier.py", "setup.py", "pyproject.toml", ]) def test_not_invisible(self, path: str) -> None: assert not _is_universally_invisible(path), f"Expected {path!r} to NOT be invisible" # --------------------------------------------------------------------------- # ChangeClassification and SemVerClassification # --------------------------------------------------------------------------- class TestChangeClassification: def test_frozen_stores_all_fields(self) -> None: cc = ChangeClassification( address="a.py::Foo", change_kind="breaking", stability="stable", visibility="exported", confidence=1.0, reason="deleted from stable surface", ) assert cc.address == "a.py::Foo" assert cc.change_kind == "breaking" assert cc.stability == "stable" assert cc.visibility == "exported" assert cc.confidence == 1.0 assert cc.reason == "deleted from stable surface" def test_frozen_is_immutable(self) -> None: cc = ChangeClassification( address="a.py::Foo", change_kind="additive", stability="unstable", visibility="exported", confidence=0.9, reason="new symbol", ) with pytest.raises((AttributeError, TypeError)): cc.address = "b.py::Bar" # type: ignore[misc] class TestSemVerClassification: def _make_cc(self, kind: ChangeKind, address: str = "a.py::Foo") -> ChangeClassification: return ChangeClassification( address=address, change_kind=kind, stability="stable", visibility="exported", confidence=1.0, reason="test", ) def test_breaking_addresses_sorted(self) -> None: svc = SemVerClassification( bump="major", confidence=1.0, breaking=[ self._make_cc("breaking", "b.py::Z"), self._make_cc("breaking", "a.py::A"), ], additive=[], implementation=[], invisible=[], ) assert svc.breaking_addresses == ["a.py::A", "b.py::Z"] def test_breaking_addresses_empty_when_no_breaking(self) -> None: svc = SemVerClassification( bump="patch", confidence=1.0, breaking=[], additive=[], implementation=[self._make_cc("implementation")], invisible=[], ) assert svc.breaking_addresses == [] def test_all_classifications_flat(self) -> None: b = self._make_cc("breaking") a = self._make_cc("additive") i = self._make_cc("implementation") inv = self._make_cc("invisible") svc = SemVerClassification( bump="major", confidence=1.0, breaking=[b], additive=[a], implementation=[i], invisible=[inv], ) all_cc = svc.all_classifications assert len(all_cc) == 4 assert b in all_cc assert a in all_cc assert i in all_cc assert inv in all_cc # --------------------------------------------------------------------------- # classify_delta — core bump matrix # --------------------------------------------------------------------------- class TestClassifyDeltaEmpty: def test_empty_ops_is_none(self) -> None: result = classify_delta(_delta()) assert result.bump == "none" assert result.confidence == 1.0 assert result.breaking == [] assert result.additive == [] assert result.implementation == [] assert result.invisible == [] class TestClassifyDeltaInsert: def test_insert_public_unstable_is_patch(self) -> None: # Default: no manifest → unstable → additive → PATCH result = classify_delta(_delta(_insert("src/a.py::compute"))) assert result.bump == "patch" assert len(result.additive) == 1 assert result.additive[0].address == "src/a.py::compute" assert result.additive[0].change_kind == "additive" def test_insert_public_stable_is_minor(self) -> None: manifest = _manifest_with_stable("src/a.py::compute") result = classify_delta(_delta(_insert("src/a.py::compute")), manifest=manifest) assert result.bump == "minor" def test_insert_private_symbol_is_invisible(self) -> None: result = classify_delta(_delta(_insert("src/a.py::_helper"))) assert result.bump == "none" assert len(result.invisible) == 1 assert result.invisible[0].change_kind == "invisible" class TestClassifyDeltaDelete: def test_delete_public_unstable_is_minor(self) -> None: result = classify_delta(_delta(_delete("src/a.py::compute"))) assert result.bump == "minor" assert len(result.breaking) == 1 assert result.breaking[0].address == "src/a.py::compute" assert result.breaking[0].change_kind == "breaking" assert result.breaking[0].stability == "unstable" def test_delete_public_stable_is_major(self) -> None: manifest = _manifest_with_stable("src/a.py::compute") result = classify_delta(_delta(_delete("src/a.py::compute")), manifest=manifest) assert result.bump == "major" assert result.breaking[0].stability == "stable" def test_delete_private_symbol_is_invisible(self) -> None: result = classify_delta(_delta(_delete("src/a.py::_internal"))) assert result.bump == "none" assert len(result.invisible) == 1 class TestClassifyDeltaReplace: def test_replace_implementation_change_is_patch(self) -> None: result = classify_delta(_delta(_replace("src/a.py::compute", "implementation changed"))) assert result.bump == "patch" assert len(result.implementation) == 1 assert result.implementation[0].change_kind == "implementation" assert result.implementation[0].confidence == 1.0 def test_replace_signature_change_stable_is_major(self) -> None: manifest = _manifest_with_stable("src/a.py::compute") result = classify_delta( _delta(_replace("src/a.py::compute", "signature changed")), manifest=manifest, ) assert result.bump == "major" assert result.breaking[0].stability == "stable" assert result.breaking[0].confidence == 1.0 def test_replace_signature_change_unstable_is_minor(self) -> None: result = classify_delta(_delta(_replace("src/a.py::compute", "signature changed"))) assert result.bump == "minor" assert result.breaking[0].stability == "unstable" def test_replace_rename_stable_is_major(self) -> None: manifest = _manifest_with_stable("src/a.py::compute") result = classify_delta( _delta(_replace("src/a.py::compute", "renamed to compute_total")), manifest=manifest, ) assert result.bump == "major" def test_replace_rename_unstable_is_minor(self) -> None: result = classify_delta(_delta(_replace("src/a.py::compute", "renamed to compute_total"))) assert result.bump == "minor" def test_replace_unrecognised_summary_is_conservative_low_confidence(self) -> None: result = classify_delta(_delta(_replace("src/a.py::compute", "reformatted"))) # Conservative: classified as breaking, unstable → minor assert result.bump == "minor" assert result.breaking[0].confidence == pytest.approx(0.4, abs=0.01) def test_replace_private_symbol_is_invisible(self) -> None: result = classify_delta(_delta(_replace("src/a.py::_helper", "signature changed"))) assert result.bump == "none" assert result.invisible[0].change_kind == "invisible" class TestClassifyDeltaPromotion: def test_major_wins_over_minor(self) -> None: manifest = _manifest_with_stable("src/a.py::old_func") result = classify_delta(_delta( _insert("src/a.py::new_func"), # additive, unstable → patch _delete("src/a.py::old_func"), # breaking, stable → major ), manifest=manifest) assert result.bump == "major" def test_minor_wins_over_patch(self) -> None: manifest = _manifest_with_stable("src/a.py::new_public") result = classify_delta(_delta( _insert("src/a.py::new_public"), # additive, stable → minor _replace("src/a.py::existing", "implementation changed"), # patch ), manifest=manifest) assert result.bump == "minor" def test_multiple_breaking_addresses(self) -> None: manifest = _manifest_with_stable("src/a.py::func_a", "src/b.py::func_b") result = classify_delta(_delta( _delete("src/a.py::func_a"), _delete("src/b.py::func_b"), ), manifest=manifest) assert result.bump == "major" addresses = result.breaking_addresses assert "src/a.py::func_a" in addresses assert "src/b.py::func_b" in addresses assert addresses == sorted(addresses) # --------------------------------------------------------------------------- # classify_delta — invisible gates # --------------------------------------------------------------------------- class TestClassifyDeltaInvisibleGates: @pytest.mark.parametrize("address", [ "LICENSE", "LICENSE.md", "README.md", "CHANGELOG.md", "docs/overview.md", "tests/test_foo.py", "test_helpers.py", "conftest.py", "requirements.txt", "poetry.lock", "foo.pyc", ]) def test_insert_on_invisible_file_produces_no_bump(self, address: str) -> None: result = classify_delta(_delta(_insert(address))) assert result.bump == "none", f"Expected no bump for {address!r}" assert len(result.invisible) == 1 @pytest.mark.parametrize("address", [ "LICENSE", "docs/api.md", "tests/test_core.py", ]) def test_delete_on_invisible_file_produces_no_bump(self, address: str) -> None: result = classify_delta(_delta(_delete(address))) assert result.bump == "none" def test_patch_op_on_invisible_file_is_invisible(self) -> None: # PatchOp whose address is an invisible file — all child ops must also be invisible child = _insert("tests/test_foo.py::helper") patch_op = _patch("tests/test_foo.py", child) result = classify_delta(_delta(patch_op)) assert result.bump == "none" assert len(result.invisible) == 1 def test_mix_invisible_and_visible_ops(self) -> None: manifest = _manifest_with_stable("src/a.py::compute") result = classify_delta(_delta( _delete("LICENSE"), # invisible _delete("src/a.py::compute"), # breaking, stable → major ), manifest=manifest) assert result.bump == "major" assert len(result.invisible) == 1 assert len(result.breaking) == 1 # --------------------------------------------------------------------------- # classify_delta — experimental surface # --------------------------------------------------------------------------- class TestClassifyDeltaExperimental: def test_delete_public_experimental_is_patch(self) -> None: manifest = _manifest_with_experimental("src/a.py::feature") result = classify_delta(_delta(_delete("src/a.py::feature")), manifest=manifest) assert result.bump == "patch" assert result.breaking[0].stability == "experimental" def test_insert_public_experimental_is_patch(self) -> None: manifest = _manifest_with_experimental("src/a.py::feature") result = classify_delta(_delta(_insert("src/a.py::feature")), manifest=manifest) assert result.bump == "patch" def test_signature_change_experimental_is_patch(self) -> None: manifest = _manifest_with_experimental("src/a.py::feature") result = classify_delta( _delta(_replace("src/a.py::feature", "signature changed")), manifest=manifest, ) assert result.bump == "patch" # --------------------------------------------------------------------------- # classify_delta — PatchOp recursion # --------------------------------------------------------------------------- class TestClassifyDeltaPatchOp: def test_patch_op_recurses_into_children(self) -> None: # Child insert of public unstable symbol → patch child = _insert("src/a.py::compute::inner_func") patch = _patch("src/a.py::compute", child) result = classify_delta(_delta(patch)) assert result.bump == "patch" assert len(result.additive) == 1 def test_patch_op_no_child_ops_is_implementation(self) -> None: patch = _patch("src/a.py::compute") result = classify_delta(_delta(patch)) assert result.bump == "patch" assert result.implementation[0].confidence == pytest.approx(0.7, abs=0.01) def test_patch_op_invisible_file_skips_children(self) -> None: # Child would be breaking, but file is invisible → whole op is invisible child = _delete("tests/test_foo.py::SomeClass") patch = _patch("tests/test_foo.py", child) result = classify_delta(_delta(patch)) assert result.bump == "none" assert len(result.invisible) == 1 def test_patch_op_with_stable_child_delete_is_major(self) -> None: manifest = _manifest_with_stable("src/a.py::compute::inner_public") child = _delete("src/a.py::compute::inner_public") patch = _patch("src/a.py::compute", child) result = classify_delta(_delta(patch), manifest=manifest) assert result.bump == "major" # --------------------------------------------------------------------------- # classify_delta — MoveOp and MutateOp # --------------------------------------------------------------------------- class TestClassifyDeltaMoveAndMutate: def test_move_op_public_exported_is_implementation(self) -> None: op = _move("src/a.py::compute", "src/b.py::compute") result = classify_delta(_delta(op)) assert result.bump == "patch" assert result.implementation[0].change_kind == "implementation" def test_move_op_private_symbol_is_invisible(self) -> None: op = _move("src/a.py::_helper", "src/a.py::_helper_v2") result = classify_delta(_delta(op)) assert result.bump == "none" assert result.invisible[0].change_kind == "invisible" def test_mutate_op_public_is_implementation(self) -> None: op = _mutate("track/note_1") result = classify_delta(_delta(op, domain="midi")) assert result.bump == "patch" assert result.implementation[0].change_kind == "implementation" def test_mutate_op_private_symbol_is_invisible(self) -> None: # Private convention requires :: separator — underscore on a bare path # does not mean private (MIDI tracks, non-Python assets, etc.) op = _mutate("src/midi.py::_internal_note") result = classify_delta(_delta(op, domain="code")) assert result.bump == "none" # --------------------------------------------------------------------------- # classify_delta — RenameOp # --------------------------------------------------------------------------- class TestClassifyDeltaRenameOp: def _dir_rename_op( self, from_address: str, address: str, domain: str = "code" ) -> Mapping[str, object]: return { "op": "rename", "address": address, "from_address": from_address, } def test_code_domain_rename_is_breaking_low_confidence(self) -> None: op = self._dir_rename_op("muse/core", "muse/engine", domain="code") delta: StructuredDelta = {"domain": "code", "ops": [op], "summary": ""} # type: ignore[typeddict-item] result = classify_delta(delta) assert result.bump == "minor" # breaking on unstable surface assert result.breaking[0].confidence == pytest.approx(0.5, abs=0.01) def test_non_code_domain_rename_is_invisible(self) -> None: op = self._dir_rename_op("tracks/verse", "tracks/intro", domain="midi") delta: StructuredDelta = {"domain": "midi", "ops": [op], "summary": ""} # type: ignore[typeddict-item] result = classify_delta(delta) assert result.bump == "none" assert result.invisible[0].change_kind == "invisible" def test_invisible_directory_rename_is_invisible(self) -> None: op = self._dir_rename_op("docs/api", "docs/reference", domain="code") delta: StructuredDelta = {"domain": "code", "ops": [op], "summary": ""} # type: ignore[typeddict-item] result = classify_delta(delta) assert result.bump == "none" # --------------------------------------------------------------------------- # classify_delta — repo_root auto-loads manifest # --------------------------------------------------------------------------- class TestClassifyDeltaRepoRoot: def test_repo_root_loads_stability_toml(self, tmp_path: pathlib.Path) -> None: muse_dir(tmp_path).mkdir() stability_toml_path(tmp_path).write_text(textwrap.dedent("""\ [stable] symbols = ["src/a.py::compute"] """)) # Delete of stable symbol → major result = classify_delta(_delta(_delete("src/a.py::compute")), repo_root=tmp_path) assert result.bump == "major" def test_repo_root_missing_stability_toml_falls_back_to_empty( self, tmp_path: pathlib.Path ) -> None: result = classify_delta(_delta(_delete("src/a.py::compute")), repo_root=tmp_path) # No stability.toml → unstable → breaking → minor assert result.bump == "minor" def test_explicit_manifest_overrides_repo_root(self, tmp_path: pathlib.Path) -> None: # repo_root has stable declaration, but explicit manifest overrides with no stable muse_dir(tmp_path).mkdir() stability_toml_path(tmp_path).write_text(textwrap.dedent("""\ [stable] symbols = ["src/a.py::compute"] """)) explicit_manifest = StabilityManifest.empty() result = classify_delta( _delta(_delete("src/a.py::compute")), manifest=explicit_manifest, repo_root=tmp_path, ) # Explicit manifest (empty) takes priority → unstable → minor assert result.bump == "minor" # --------------------------------------------------------------------------- # ConflictRecord (regression — must still work after semver classifier rewrite) # --------------------------------------------------------------------------- class TestConflictRecord: def test_defaults(self) -> None: cr = ConflictRecord(path="src/billing.py") assert cr.conflict_type == "file_level" assert cr.ours_summary == "" assert cr.theirs_summary == "" assert cr.addresses == [] def test_all_fields_settable(self) -> None: cr = ConflictRecord( path="src/billing.py", conflict_type="symbol_edit_overlap", ours_summary="renamed compute_total", theirs_summary="modified compute_total", addresses=["src/billing.py::compute_total"], ) assert cr.path == "src/billing.py" assert cr.conflict_type == "symbol_edit_overlap" assert cr.ours_summary == "renamed compute_total" assert cr.theirs_summary == "modified compute_total" assert cr.addresses == ["src/billing.py::compute_total"] def test_all_conflict_types_accepted(self) -> None: for ct in [ "symbol_edit_overlap", "rename_edit", "move_edit", "delete_use", "dependency_conflict", "file_level", ]: cr = ConflictRecord(path="f.py", conflict_type=ct) assert cr.conflict_type == ct def test_addresses_default_factory_is_independent(self) -> None: cr1 = ConflictRecord(path="a.py") cr2 = ConflictRecord(path="b.py") cr1.addresses.append("a.py::f") assert cr2.addresses == [] def test_field_names(self) -> None: field_names = {f.name for f in fields(ConflictRecord)} assert {"path", "conflict_type", "ours_summary", "theirs_summary", "addresses"} <= field_names # --------------------------------------------------------------------------- # SemVerBump literals # --------------------------------------------------------------------------- class TestSemVerBumpLiterals: def test_all_values_are_valid_strings(self) -> None: for val in ("major", "minor", "patch", "none"): assert isinstance(val, str) def test_classify_delta_returns_valid_bump(self) -> None: result = classify_delta(_delta()) assert result.bump in ("major", "minor", "patch", "none")