test_sem_ver.py
python
sha256:d11a87833d5fad6059b7662844bf5448a8911a17cce7a51811f71ad394f248eb
bump to v0.2.0rc13
Human
patch
6 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 — 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") |
File History
1 commit
sha256:d11a87833d5fad6059b7662844bf5448a8911a17cce7a51811f71ad394f248eb
bump to v0.2.0rc13
Human
patch
6 days ago