semver_classifier.py
python
sha256:ac29b8ba9021514a03ab2782d92bf67671f0efa5b3b70d46f7598c5d4e923378
docs: record muse#74 Phase 4 live verification (SCR_13, SCR_14)
Sonnet 5
13 days ago
| 1 | """Semver classifier — structured, evidence-driven semantic version inference. |
| 2 | |
| 3 | Architecture |
| 4 | ------------ |
| 5 | |
| 6 | :: |
| 7 | |
| 8 | StructuredDelta |
| 9 | │ |
| 10 | ▼ |
| 11 | UniversalInvisibleRules ← gates out noise: docs, tests, licenses, assets |
| 12 | │ |
| 13 | ▼ |
| 14 | StabilityManifest ← optional .muse/stability.toml declaration |
| 15 | │ explicit visibility + stability per symbol/pattern |
| 16 | ▼ |
| 17 | ChangeClassifier ← per-op: ChangeKind + VisibilityTier + |
| 18 | │ StabilityTier + confidence + reason |
| 19 | ▼ |
| 20 | SemVerAggregator ← folds ChangeClassifications → SemVerBump |
| 21 | │ |
| 22 | ▼ |
| 23 | SemVerClassification ← bump, confidence, full evidence breakdown |
| 24 | |
| 25 | Stability tiers |
| 26 | ~~~~~~~~~~~~~~~ |
| 27 | ``stable`` |
| 28 | Symbols that have made a public contract commitment. Breaking changes |
| 29 | here are always MAJOR. Additive changes are MINOR. Declare via |
| 30 | ``.muse/stability.toml [stable]``. |
| 31 | |
| 32 | ``unstable`` |
| 33 | Symbols that are public but still evolving. Breaking changes are MINOR |
| 34 | (the surface was never committed to). Additive changes are PATCH. |
| 35 | **This is the default when no stability.toml exists — the correct |
| 36 | starting point for any new API.** |
| 37 | |
| 38 | ``experimental`` |
| 39 | Explicitly provisional symbols. Any change (breaking or additive) is |
| 40 | PATCH. Use for features gated behind flags or not yet advertised. |
| 41 | Declare via ``.muse/stability.toml [experimental]``. |
| 42 | |
| 43 | Visibility tiers |
| 44 | ~~~~~~~~~~~~~~~~ |
| 45 | ``exported`` |
| 46 | Part of the public API surface — used by external consumers. Heuristic: |
| 47 | non-underscore-prefixed symbol not matched by any invisible pattern. |
| 48 | Can be upgraded to ``stable`` or downgraded to ``internal`` via manifest. |
| 49 | |
| 50 | ``internal`` |
| 51 | Crosses module boundaries but not exposed to external consumers. Not |
| 52 | currently produced by heuristics — requires explicit manifest declaration. |
| 53 | |
| 54 | ``private`` |
| 55 | Underscore-prefixed; local to the defining file. Changes here are |
| 56 | always ``invisible`` for versioning purposes. |
| 57 | |
| 58 | ``invisible`` |
| 59 | Never part of any version signal: documentation, tests, licenses, |
| 60 | lock files, build artifacts, VCS metadata. Matched against |
| 61 | ``_UNIVERSAL_INVISIBLE_PATTERNS`` or ``.muse/stability.toml [invisible]``. |
| 62 | |
| 63 | Change kinds |
| 64 | ~~~~~~~~~~~~ |
| 65 | ``breaking`` |
| 66 | A consumer relying on this symbol can no longer do what they were doing. |
| 67 | Triggered by: deletion, rename, signature-incompatible replacement. |
| 68 | |
| 69 | ``additive`` |
| 70 | New capability; existing consumers are unaffected. |
| 71 | Triggered by: insertion of a symbol onto the visible surface. |
| 72 | |
| 73 | ``implementation`` |
| 74 | Internal behaviour changed; public contract intact. |
| 75 | Triggered by: body change with identical signature. |
| 76 | |
| 77 | ``invisible`` |
| 78 | No version signal whatsoever. |
| 79 | Triggered by: universal invisible rules, manifest invisible patterns, |
| 80 | or private symbol change. |
| 81 | |
| 82 | Bump matrix |
| 83 | ~~~~~~~~~~~ |
| 84 | +-------------------+----------+----------+----------------+ |
| 85 | | Stability tier | breaking | additive | implementation | |
| 86 | +===================+==========+==========+================+ |
| 87 | | stable | MAJOR | MINOR | PATCH | |
| 88 | +-------------------+----------+----------+----------------+ |
| 89 | | unstable | MINOR | PATCH | PATCH | |
| 90 | +-------------------+----------+----------+----------------+ |
| 91 | | experimental | PATCH | PATCH | PATCH | |
| 92 | +-------------------+----------+----------+----------------+ |
| 93 | | (invisible) | none | none | none | |
| 94 | +-------------------+----------+----------+----------------+ |
| 95 | |
| 96 | There is no pre-1.0 adjustment. The version number is a structural fact. |
| 97 | To signal "this API has not made stability commitments", leave symbols |
| 98 | undeclared (stability defaults to ``unstable``). The first MAJOR bump |
| 99 | is the first time a ``stable`` surface breaks — which is exactly what |
| 100 | MAJOR should mean. |
| 101 | """ |
| 102 | |
| 103 | import pathlib |
| 104 | import re |
| 105 | import tomllib |
| 106 | from dataclasses import dataclass, field |
| 107 | from fnmatch import fnmatch |
| 108 | from typing import Literal |
| 109 | |
| 110 | from muse.core.paths import stability_toml_path as _stability_toml_path |
| 111 | from muse.core.types import SemVerBump |
| 112 | from muse.domain import ( |
| 113 | DomainOp, |
| 114 | StructuredDelta, |
| 115 | ) |
| 116 | |
| 117 | __all__ = [ |
| 118 | "VisibilityTier", |
| 119 | "StabilityTier", |
| 120 | "ChangeKind", |
| 121 | "ChangeClassification", |
| 122 | "SemVerClassification", |
| 123 | "StabilityManifest", |
| 124 | "classify_delta", |
| 125 | ] |
| 126 | |
| 127 | # --------------------------------------------------------------------------- |
| 128 | # Tier and kind type aliases |
| 129 | # --------------------------------------------------------------------------- |
| 130 | |
| 131 | VisibilityTier = Literal["exported", "internal", "private", "invisible"] |
| 132 | StabilityTier = Literal["stable", "unstable", "experimental"] |
| 133 | ChangeKind = Literal["breaking", "additive", "implementation", "invisible"] |
| 134 | |
| 135 | # --------------------------------------------------------------------------- |
| 136 | # Universal invisible patterns |
| 137 | # --------------------------------------------------------------------------- |
| 138 | |
| 139 | #: File-path patterns that are never part of any version signal, regardless |
| 140 | #: of repo or domain. Matched against the file portion of an op address. |
| 141 | #: |
| 142 | #: Repos may extend this list via ``.muse/stability.toml [invisible]``. |
| 143 | #: Repos may NOT shrink it — these are universal invariants. |
| 144 | _UNIVERSAL_INVISIBLE_PATTERNS: frozenset[str] = frozenset({ |
| 145 | # Licences and legal |
| 146 | "LICENSE", "LICENSE.*", "COPYING", "COPYING.*", "NOTICE", "NOTICE.*", |
| 147 | # Human-readable prose |
| 148 | "*.md", "*.rst", "*.txt", "*.adoc", |
| 149 | "README", "README.*", |
| 150 | "CHANGELOG", "CHANGELOG.*", "CHANGES", "CHANGES.*", "HISTORY", "HISTORY.*", |
| 151 | # Documentation directories |
| 152 | "docs/**", "doc/**", "documentation/**", |
| 153 | # Tests (never part of the public API surface) |
| 154 | "tests/**", "test/**", "spec/**", |
| 155 | "test_*.py", "*_test.py", "conftest.py", |
| 156 | "*.bats", "*.test.ts", "*.spec.ts", "*.test.js", "*.spec.js", |
| 157 | # Python bytecode |
| 158 | "**/__pycache__/**", "*.pyc", "*.pyo", |
| 159 | # Dependency and lock files |
| 160 | "*.lock", "package-lock.json", "yarn.lock", "Pipfile.lock", |
| 161 | "poetry.lock", "requirements*.txt", |
| 162 | # VCS and tool metadata |
| 163 | ".museattributes", ".museignore", |
| 164 | ".gitattributes", ".gitignore", |
| 165 | ".editorconfig", ".prettierrc*", ".eslintrc*", |
| 166 | # Build artifacts and cache markers |
| 167 | "*.cache-id", "*.min.js", "*.min.css", |
| 168 | # CI / deployment config |
| 169 | ".github/**", ".gitlab-ci*", "Jenkinsfile", |
| 170 | "Makefile", "makefile", |
| 171 | }) |
| 172 | |
| 173 | # Bump ordering used for promotion comparisons. |
| 174 | _BUMP_RANK: dict[SemVerBump, int] = { |
| 175 | "none": 0, "patch": 1, "minor": 2, "major": 3 |
| 176 | } |
| 177 | |
| 178 | # --------------------------------------------------------------------------- |
| 179 | # StabilityManifest |
| 180 | # --------------------------------------------------------------------------- |
| 181 | |
| 182 | @dataclass(frozen=True) |
| 183 | class StabilityManifest: |
| 184 | """Loaded from ``.muse/stability.toml`` — declares stability tiers. |
| 185 | |
| 186 | Repos that have made API commitments should maintain this file. Repos |
| 187 | without it work correctly; all symbols default to ``unstable``, meaning |
| 188 | breaking changes produce MINOR bumps rather than MAJOR. |
| 189 | |
| 190 | File format:: |
| 191 | |
| 192 | [stable] |
| 193 | # Exact symbol addresses or fnmatch glob patterns. |
| 194 | symbols = ["muse/core/store.py::CommitRecord"] |
| 195 | patterns = ["muse/core/store.py::*"] |
| 196 | |
| 197 | [unstable] |
| 198 | symbols = ["muse/cli/commands/release.py::run_suggest"] |
| 199 | |
| 200 | [experimental] |
| 201 | symbols = [] |
| 202 | |
| 203 | [invisible] |
| 204 | # File-path patterns suppressing all version signal. |
| 205 | # Added on top of _UNIVERSAL_INVISIBLE_PATTERNS, never instead. |
| 206 | patterns = ["src/ts/**", "*.scss"] |
| 207 | |
| 208 | Attributes: |
| 209 | stable: Addresses/patterns committed to stable contract. |
| 210 | unstable: Explicitly unstable (default for undeclared symbols). |
| 211 | experimental: Explicitly experimental/provisional. |
| 212 | invisible: Repo-specific invisible patterns (file paths only). |
| 213 | """ |
| 214 | |
| 215 | stable: frozenset[str] = field(default_factory=frozenset) |
| 216 | unstable: frozenset[str] = field(default_factory=frozenset) |
| 217 | experimental: frozenset[str] = field(default_factory=frozenset) |
| 218 | invisible: frozenset[str] = field(default_factory=frozenset) |
| 219 | |
| 220 | @classmethod |
| 221 | def empty(cls) -> "StabilityManifest": |
| 222 | """Return a manifest with no declarations (all symbols default to unstable).""" |
| 223 | return cls() |
| 224 | |
| 225 | @classmethod |
| 226 | def load(cls, repo_root: pathlib.Path) -> "StabilityManifest": |
| 227 | """Load from ``<repo_root>/.muse/stability.toml``. |
| 228 | |
| 229 | Returns an empty manifest if the file does not exist. Invalid TOML |
| 230 | is re-raised as ``tomllib.TOMLDecodeError``. |
| 231 | |
| 232 | Args: |
| 233 | repo_root: Repository root (the directory containing ``.muse/``). |
| 234 | |
| 235 | Returns: |
| 236 | Populated :class:`StabilityManifest`. |
| 237 | """ |
| 238 | path = _stability_toml_path(repo_root) |
| 239 | if not path.exists(): |
| 240 | return cls.empty() |
| 241 | data = tomllib.loads(path.read_text(encoding="utf-8")) |
| 242 | |
| 243 | def _collect(section: str) -> frozenset[str]: |
| 244 | sec = data.get(section, {}) |
| 245 | return frozenset(sec.get("symbols", []) + sec.get("patterns", [])) |
| 246 | |
| 247 | return cls( |
| 248 | stable=_collect("stable"), |
| 249 | unstable=_collect("unstable"), |
| 250 | experimental=_collect("experimental"), |
| 251 | invisible=frozenset(data.get("invisible", {}).get("patterns", [])), |
| 252 | ) |
| 253 | |
| 254 | def stability_for(self, address: str) -> StabilityTier: |
| 255 | """Return the stability tier for *address*, defaulting to ``unstable``. |
| 256 | |
| 257 | Checks ``stable`` first, then ``experimental``. Explicit ``unstable`` |
| 258 | declarations and undeclared symbols both return ``"unstable"``. |
| 259 | |
| 260 | Args: |
| 261 | address: Symbol address (e.g. ``"muse/core/store.py::CommitRecord"``). |
| 262 | |
| 263 | Returns: |
| 264 | ``"stable"``, ``"unstable"``, or ``"experimental"``. |
| 265 | """ |
| 266 | if self._matches(address, self.stable): |
| 267 | return "stable" |
| 268 | if self._matches(address, self.experimental): |
| 269 | return "experimental" |
| 270 | return "unstable" |
| 271 | |
| 272 | def is_invisible(self, address: str) -> bool: |
| 273 | """Return True if *address* matches any repo-specific invisible pattern. |
| 274 | |
| 275 | Does not include :data:`_UNIVERSAL_INVISIBLE_PATTERNS` — callers should |
| 276 | check universal rules separately via :func:`_is_universally_invisible`. |
| 277 | |
| 278 | Args: |
| 279 | address: Op address (file path or ``file::symbol``). |
| 280 | """ |
| 281 | return self._matches(_file_part(address), self.invisible) |
| 282 | |
| 283 | def _matches(self, address: str, patterns: frozenset[str]) -> bool: |
| 284 | """True if *address* or its file-path prefix matches any pattern.""" |
| 285 | fp = _file_part(address) |
| 286 | for pattern in patterns: |
| 287 | if _fnmatch_path(address, pattern) or _fnmatch_path(fp, pattern): |
| 288 | return True |
| 289 | return False |
| 290 | |
| 291 | # --------------------------------------------------------------------------- |
| 292 | # ChangeClassification and SemVerClassification |
| 293 | # --------------------------------------------------------------------------- |
| 294 | |
| 295 | @dataclass(frozen=True) |
| 296 | class ChangeClassification: |
| 297 | """Classification of a single op from a :class:`~muse.domain.StructuredDelta`. |
| 298 | |
| 299 | Every op that flows through :func:`classify_delta` produces one of these. |
| 300 | The full list is available on :class:`SemVerClassification` grouped by |
| 301 | change kind, giving agents and humans complete evidence for any bump. |
| 302 | |
| 303 | Attributes: |
| 304 | address: Op address (e.g. ``"muse/core/store.py::CommitRecord"``). |
| 305 | change_kind: What happened — ``"breaking"``, ``"additive"``, |
| 306 | ``"implementation"``, or ``"invisible"``. |
| 307 | stability: Stability tier of the symbol at the time of the change. |
| 308 | visibility: Visibility tier — ``"exported"``, ``"internal"``, |
| 309 | ``"private"``, or ``"invisible"``. |
| 310 | confidence: ``0.0``–``1.0``. ``1.0`` = certain; lower values flag |
| 311 | heuristic guesses (e.g. unrecognised summary strings). |
| 312 | reason: Human/agent-readable explanation of why this classification |
| 313 | was assigned. Suitable for display in ``muse release suggest`` |
| 314 | output and CI reports. |
| 315 | """ |
| 316 | |
| 317 | address: str |
| 318 | change_kind: ChangeKind |
| 319 | stability: StabilityTier |
| 320 | visibility: VisibilityTier |
| 321 | confidence: float |
| 322 | reason: str |
| 323 | |
| 324 | @dataclass(frozen=True) |
| 325 | class SemVerClassification: |
| 326 | """Full classification of a :class:`~muse.domain.StructuredDelta`. |
| 327 | |
| 328 | The ``bump`` field is the headline result. The four lists (``breaking``, |
| 329 | ``additive``, ``implementation``, ``invisible``) are the full evidence: |
| 330 | every op that contributed to the classification is present in exactly one |
| 331 | list. |
| 332 | |
| 333 | Attributes: |
| 334 | bump: Aggregated semantic version bump. |
| 335 | confidence: Minimum confidence across the ops that drove ``bump``. |
| 336 | ``1.0`` means every driving op was classified with |
| 337 | certainty. Values below ``0.7`` should be reviewed. |
| 338 | breaking: Ops classified as breaking. |
| 339 | additive: Ops classified as additive (new surface). |
| 340 | implementation: Ops classified as implementation-only. |
| 341 | invisible: Ops that carry no version signal. |
| 342 | """ |
| 343 | |
| 344 | bump: SemVerBump |
| 345 | confidence: float |
| 346 | breaking: list[ChangeClassification] |
| 347 | additive: list[ChangeClassification] |
| 348 | implementation: list[ChangeClassification] |
| 349 | invisible: list[ChangeClassification] |
| 350 | |
| 351 | @property |
| 352 | def breaking_addresses(self) -> list[str]: |
| 353 | """Sorted list of addresses from :attr:`breaking` classifications. |
| 354 | |
| 355 | Equivalent to ``[c.address for c in self.breaking]``, sorted. |
| 356 | Provided as a convenience for callers that store only addresses |
| 357 | (e.g. :attr:`~muse.core.store.CommitRecord.breaking_changes`). |
| 358 | """ |
| 359 | return sorted(c.address for c in self.breaking) |
| 360 | |
| 361 | @property |
| 362 | def all_classifications(self) -> list[ChangeClassification]: |
| 363 | """All classifications in a single flat list (order unspecified).""" |
| 364 | return self.breaking + self.additive + self.implementation + self.invisible |
| 365 | |
| 366 | # --------------------------------------------------------------------------- |
| 367 | # Public entry point |
| 368 | # --------------------------------------------------------------------------- |
| 369 | |
| 370 | def classify_delta( |
| 371 | delta: StructuredDelta, |
| 372 | manifest: StabilityManifest | None = None, |
| 373 | repo_root: pathlib.Path | None = None, |
| 374 | ) -> SemVerClassification: |
| 375 | """Classify a :class:`~muse.domain.StructuredDelta` into a full semver classification. |
| 376 | |
| 377 | This is the single entry point for all semver inference in Muse. |
| 378 | |
| 379 | Priority order for classification decisions: |
| 380 | |
| 381 | 1. Universal invisible rules — file-path patterns that can never be API. |
| 382 | 2. ``StabilityManifest.invisible`` — repo-specific invisible patterns. |
| 383 | 3. Visibility heuristic — underscore-prefixed symbols are private. |
| 384 | 4. ``StabilityManifest.stability_for()`` — explicit stability declarations. |
| 385 | 5. Default stability — ``unstable`` when no manifest declaration exists. |
| 386 | |
| 387 | Args: |
| 388 | delta: The structured delta from ``plugin.diff()``. Must contain |
| 389 | an ``"ops"`` key; other keys are optional. |
| 390 | manifest: Pre-loaded :class:`StabilityManifest`. When ``None`` and |
| 391 | *repo_root* is provided, the manifest is loaded from |
| 392 | ``<repo_root>/.muse/stability.toml``. When both are |
| 393 | ``None``, only universal invisible rules and naming |
| 394 | heuristics are applied. |
| 395 | repo_root: Repository root for auto-loading the stability manifest. |
| 396 | Ignored when *manifest* is explicitly supplied. |
| 397 | |
| 398 | Returns: |
| 399 | :class:`SemVerClassification` with the aggregated bump and full |
| 400 | evidence breakdown. |
| 401 | |
| 402 | Examples:: |
| 403 | |
| 404 | # Minimal usage — no stability manifest |
| 405 | delta = plugin.diff(base_snap, target_snap, repo_root=root) |
| 406 | result = classify_delta(delta) |
| 407 | print(result.bump) # "minor" |
| 408 | print(result.breaking_addresses) # [] |
| 409 | |
| 410 | # With auto-loaded manifest from repo |
| 411 | result = classify_delta(delta, repo_root=pathlib.Path("/repo")) |
| 412 | print(result.confidence) # 1.0 |
| 413 | |
| 414 | # Pre-loaded manifest (e.g. in tests) |
| 415 | manifest = StabilityManifest.load(root) |
| 416 | result = classify_delta(delta, manifest=manifest) |
| 417 | """ |
| 418 | if manifest is None and repo_root is not None: |
| 419 | manifest = StabilityManifest.load(repo_root) |
| 420 | if manifest is None: |
| 421 | manifest = StabilityManifest.empty() |
| 422 | |
| 423 | domain = delta.get("domain", "") |
| 424 | ops: list[DomainOp] = delta.get("ops", []) # type: ignore[assignment] |
| 425 | |
| 426 | classifications: list[ChangeClassification] = [] |
| 427 | _classify_ops(ops, domain, manifest, classifications) |
| 428 | return _aggregate(classifications) |
| 429 | |
| 430 | # --------------------------------------------------------------------------- |
| 431 | # Internal — op classification |
| 432 | # --------------------------------------------------------------------------- |
| 433 | |
| 434 | def _classify_ops( |
| 435 | ops: list[DomainOp], |
| 436 | domain: str, |
| 437 | manifest: StabilityManifest, |
| 438 | out: list[ChangeClassification], |
| 439 | ) -> None: |
| 440 | """Recursively classify all ops, appending to *out* in-place.""" |
| 441 | for op in ops: |
| 442 | op_type = op.get("op", "") |
| 443 | |
| 444 | if op_type == "patch": |
| 445 | _classify_patch_op(op, domain, manifest, out) # type: ignore[arg-type] |
| 446 | elif op_type == "insert": |
| 447 | out.append(_classify_insert(op, manifest)) # type: ignore[arg-type] |
| 448 | elif op_type == "delete": |
| 449 | out.append(_classify_delete(op, manifest)) # type: ignore[arg-type] |
| 450 | elif op_type == "replace": |
| 451 | out.append(_classify_replace(op, manifest)) # type: ignore[arg-type] |
| 452 | elif op_type == "move": |
| 453 | out.append(_classify_move(op, manifest)) # type: ignore[arg-type] |
| 454 | elif op_type == "mutate": |
| 455 | out.append(_classify_mutate(op, manifest)) # type: ignore[arg-type] |
| 456 | elif op_type == "rename": |
| 457 | out.append(_classify_rename_op(op, domain, manifest)) # type: ignore[arg-type] |
| 458 | # Unknown op types: skip — future-proof against new op kinds. |
| 459 | |
| 460 | def _classify_patch_op( |
| 461 | op: DomainOp, |
| 462 | domain: str, |
| 463 | manifest: StabilityManifest, |
| 464 | out: list[ChangeClassification], |
| 465 | ) -> None: |
| 466 | """Classify a PatchOp by recursing into its child_ops. |
| 467 | |
| 468 | The PatchOp's own address (the file path) is used for invisible checks. |
| 469 | If the file is universally or manifest-invisible, all child ops are also |
| 470 | invisible — no recursion needed. |
| 471 | """ |
| 472 | address: str = str(op.get("address", "")) |
| 473 | |
| 474 | if _is_universally_invisible(address) or manifest.is_invisible(address): |
| 475 | out.append(ChangeClassification( |
| 476 | address=address, |
| 477 | change_kind="invisible", |
| 478 | stability="unstable", |
| 479 | visibility="invisible", |
| 480 | confidence=0.99, |
| 481 | reason=f"file matches invisible pattern — no version signal", |
| 482 | )) |
| 483 | return |
| 484 | |
| 485 | child_ops: list[DomainOp] = op.get("child_ops", []) # type: ignore[assignment] |
| 486 | if child_ops: |
| 487 | _classify_ops(child_ops, domain, manifest, out) |
| 488 | else: |
| 489 | # PatchOp with no child_ops — implementation-level change. |
| 490 | out.append(ChangeClassification( |
| 491 | address=address, |
| 492 | change_kind="implementation", |
| 493 | stability=manifest.stability_for(address), |
| 494 | visibility=_visibility(address, manifest), |
| 495 | confidence=0.7, |
| 496 | reason="file modified (no symbol-level diff available) — implementation change assumed", |
| 497 | )) |
| 498 | |
| 499 | def _classify_insert(op: DomainOp, manifest: StabilityManifest) -> ChangeClassification: |
| 500 | """Classify an InsertOp.""" |
| 501 | address: str = str(op.get("address", "")) |
| 502 | vis = _visibility(address, manifest) |
| 503 | |
| 504 | if vis == "invisible": |
| 505 | return ChangeClassification( |
| 506 | address=address, |
| 507 | change_kind="invisible", |
| 508 | stability="unstable", |
| 509 | visibility="invisible", |
| 510 | confidence=0.99, |
| 511 | reason="matches invisible pattern — file/symbol is not API", |
| 512 | ) |
| 513 | if vis == "private": |
| 514 | return ChangeClassification( |
| 515 | address=address, |
| 516 | change_kind="invisible", |
| 517 | stability="unstable", |
| 518 | visibility="private", |
| 519 | confidence=0.95, |
| 520 | reason="private symbol (underscore prefix) — no API impact", |
| 521 | ) |
| 522 | |
| 523 | stability = manifest.stability_for(address) |
| 524 | return ChangeClassification( |
| 525 | address=address, |
| 526 | change_kind="additive", |
| 527 | stability=stability, |
| 528 | visibility=vis, |
| 529 | confidence=1.0, |
| 530 | reason=f"new symbol on {stability} surface — additive change", |
| 531 | ) |
| 532 | |
| 533 | def _classify_delete(op: DomainOp, manifest: StabilityManifest) -> ChangeClassification: |
| 534 | """Classify a DeleteOp.""" |
| 535 | address: str = str(op.get("address", "")) |
| 536 | vis = _visibility(address, manifest) |
| 537 | |
| 538 | if vis == "invisible": |
| 539 | return ChangeClassification( |
| 540 | address=address, |
| 541 | change_kind="invisible", |
| 542 | stability="unstable", |
| 543 | visibility="invisible", |
| 544 | confidence=0.99, |
| 545 | reason="matches invisible pattern — file/symbol is not API", |
| 546 | ) |
| 547 | if vis == "private": |
| 548 | return ChangeClassification( |
| 549 | address=address, |
| 550 | change_kind="invisible", |
| 551 | stability="unstable", |
| 552 | visibility="private", |
| 553 | confidence=0.95, |
| 554 | reason="private symbol (underscore prefix) — no API impact", |
| 555 | ) |
| 556 | |
| 557 | stability = manifest.stability_for(address) |
| 558 | return ChangeClassification( |
| 559 | address=address, |
| 560 | change_kind="breaking", |
| 561 | stability=stability, |
| 562 | visibility=vis, |
| 563 | confidence=1.0, |
| 564 | reason=f"symbol deleted from {stability} surface — callers will fail", |
| 565 | ) |
| 566 | |
| 567 | def _classify_replace(op: DomainOp, manifest: StabilityManifest) -> ChangeClassification: |
| 568 | """Classify a ReplaceOp by inspecting its summary strings.""" |
| 569 | address: str = str(op.get("address", "")) |
| 570 | new_summary: str = str(op.get("new_summary", "")) |
| 571 | old_summary: str = str(op.get("old_summary", "")) |
| 572 | vis = _visibility(address, manifest) |
| 573 | |
| 574 | if vis == "invisible": |
| 575 | return ChangeClassification( |
| 576 | address=address, |
| 577 | change_kind="invisible", |
| 578 | stability="unstable", |
| 579 | visibility="invisible", |
| 580 | confidence=0.99, |
| 581 | reason="matches invisible pattern — file/symbol is not API", |
| 582 | ) |
| 583 | if vis == "private": |
| 584 | return ChangeClassification( |
| 585 | address=address, |
| 586 | change_kind="invisible", |
| 587 | stability="unstable", |
| 588 | visibility="private", |
| 589 | confidence=0.95, |
| 590 | reason="private symbol (underscore prefix) — no API impact", |
| 591 | ) |
| 592 | |
| 593 | stability = manifest.stability_for(address) |
| 594 | |
| 595 | # ── Rename / move detection ─────────────────────────────────────────────── |
| 596 | if ( |
| 597 | new_summary.startswith("renamed to ") |
| 598 | or new_summary.startswith("moved to ") |
| 599 | or new_summary.startswith("moved from ") |
| 600 | ): |
| 601 | return ChangeClassification( |
| 602 | address=address, |
| 603 | change_kind="breaking", |
| 604 | stability=stability, |
| 605 | visibility=vis, |
| 606 | confidence=1.0, |
| 607 | reason=f"symbol renamed/moved on {stability} surface — callers will fail: {new_summary}", |
| 608 | ) |
| 609 | |
| 610 | # ── Signature change detection ──────────────────────────────────────────── |
| 611 | if "signature" in new_summary or "signature" in old_summary: |
| 612 | return ChangeClassification( |
| 613 | address=address, |
| 614 | change_kind="breaking", |
| 615 | stability=stability, |
| 616 | visibility=vis, |
| 617 | confidence=1.0, |
| 618 | reason=f"signature changed on {stability} symbol — call-site incompatible", |
| 619 | ) |
| 620 | |
| 621 | # ── Implementation-only change ──────────────────────────────────────────── |
| 622 | if "implementation" in new_summary or "implementation" in old_summary: |
| 623 | return ChangeClassification( |
| 624 | address=address, |
| 625 | change_kind="implementation", |
| 626 | stability=stability, |
| 627 | visibility=vis, |
| 628 | confidence=1.0, |
| 629 | reason="implementation changed — public contract intact", |
| 630 | ) |
| 631 | |
| 632 | # ── Catch-all: unrecognised summary ────────────────────────────────────── |
| 633 | # The diff did not produce a recognised summary pattern. We cannot |
| 634 | # determine change kind with certainty. Classify as breaking with low |
| 635 | # confidence so the bump is conservative but the confidence score |
| 636 | # signals that human/agent review is warranted. |
| 637 | return ChangeClassification( |
| 638 | address=address, |
| 639 | change_kind="breaking", |
| 640 | stability=stability, |
| 641 | visibility=vis, |
| 642 | confidence=0.4, |
| 643 | reason=( |
| 644 | f"unrecognised summary on {stability} symbol — classified as breaking " |
| 645 | f"(conservative); review recommended. " |
| 646 | f"old={old_summary!r} new={new_summary!r}" |
| 647 | ), |
| 648 | ) |
| 649 | |
| 650 | def _classify_move(op: DomainOp, manifest: StabilityManifest) -> ChangeClassification: |
| 651 | """Classify a MoveOp (ordered-sequence repositioning). |
| 652 | |
| 653 | MoveOp represents an element changing position within an ordered sequence |
| 654 | (e.g. a MIDI note moved to a different beat). This is an implementation- |
| 655 | level change — the element exists at a different position but its identity |
| 656 | and content are unchanged. It does not break callers who access by |
| 657 | identity rather than position. |
| 658 | |
| 659 | Uses ``old_address`` as the canonical address (the pre-move location that |
| 660 | callers held references to). Falls back to ``address`` for forward compat. |
| 661 | """ |
| 662 | address: str = str(op.get("old_address", "") or op.get("address", "")) |
| 663 | vis = _visibility(address, manifest) |
| 664 | if vis in ("invisible", "private"): |
| 665 | return ChangeClassification( |
| 666 | address=address, |
| 667 | change_kind="invisible", |
| 668 | stability="unstable", |
| 669 | visibility=vis, |
| 670 | confidence=0.9, |
| 671 | reason="move within ordered sequence — private or invisible symbol", |
| 672 | ) |
| 673 | return ChangeClassification( |
| 674 | address=address, |
| 675 | change_kind="implementation", |
| 676 | stability=manifest.stability_for(address), |
| 677 | visibility=vis, |
| 678 | confidence=0.9, |
| 679 | reason="element repositioned in ordered sequence — content unchanged", |
| 680 | ) |
| 681 | |
| 682 | def _classify_mutate(op: DomainOp, manifest: StabilityManifest) -> ChangeClassification: |
| 683 | """Classify a MutateOp (field-level mutation, e.g. MIDI note attributes). |
| 684 | |
| 685 | Field mutations are implementation-level changes within a domain element. |
| 686 | They do not remove or rename the element itself. |
| 687 | """ |
| 688 | address: str = str(op.get("address", "")) |
| 689 | vis = _visibility(address, manifest) |
| 690 | if vis in ("invisible", "private"): |
| 691 | return ChangeClassification( |
| 692 | address=address, |
| 693 | change_kind="invisible", |
| 694 | stability="unstable", |
| 695 | visibility=vis, |
| 696 | confidence=0.9, |
| 697 | reason="field mutation on private/invisible element", |
| 698 | ) |
| 699 | return ChangeClassification( |
| 700 | address=address, |
| 701 | change_kind="implementation", |
| 702 | stability=manifest.stability_for(address), |
| 703 | visibility=vis, |
| 704 | confidence=0.9, |
| 705 | reason="field-level mutation — element identity unchanged", |
| 706 | ) |
| 707 | |
| 708 | def _classify_rename_op( |
| 709 | op: DomainOp, |
| 710 | domain: str, |
| 711 | manifest: StabilityManifest, |
| 712 | ) -> ChangeClassification: |
| 713 | """Classify a RenameOp (op="rename"). |
| 714 | |
| 715 | For the ``code`` domain, renaming a directory is potentially breaking |
| 716 | because module import paths change. For other domains, directory structure |
| 717 | is organisational and not part of the public API. |
| 718 | |
| 719 | Confidence is 0.5 for code-domain renames not matched by the invisible |
| 720 | pattern set — the classifier cannot determine whether the directory is an |
| 721 | importable package without inspecting ``__init__.py`` presence. |
| 722 | """ |
| 723 | address: str = str(op.get("address", "")) |
| 724 | from_address: str = str(op.get("from_address", "")) |
| 725 | check_address = from_address or address |
| 726 | |
| 727 | if _is_universally_invisible(check_address) or manifest.is_invisible(check_address): |
| 728 | return ChangeClassification( |
| 729 | address=address, |
| 730 | change_kind="invisible", |
| 731 | stability="unstable", |
| 732 | visibility="invisible", |
| 733 | confidence=0.99, |
| 734 | reason=f"rename on invisible path '{from_address}' → '{address}'", |
| 735 | ) |
| 736 | |
| 737 | if domain != "code": |
| 738 | return ChangeClassification( |
| 739 | address=address, |
| 740 | change_kind="invisible", |
| 741 | stability="unstable", |
| 742 | visibility="invisible", |
| 743 | confidence=0.8, |
| 744 | reason=f"rename in non-code domain '{domain}' — not API", |
| 745 | ) |
| 746 | |
| 747 | # Code domain: potentially breaking (import path change). |
| 748 | return ChangeClassification( |
| 749 | address=address, |
| 750 | change_kind="breaking", |
| 751 | stability=manifest.stability_for(address), |
| 752 | visibility="exported", |
| 753 | confidence=0.5, |
| 754 | reason=( |
| 755 | f"renamed '{from_address}' → '{address}' in code domain — " |
| 756 | "import paths may break; confidence 0.5 (cannot verify __init__.py presence)" |
| 757 | ), |
| 758 | ) |
| 759 | |
| 760 | # --------------------------------------------------------------------------- |
| 761 | # Internal — aggregation |
| 762 | # --------------------------------------------------------------------------- |
| 763 | |
| 764 | def _aggregate(classifications: list[ChangeClassification]) -> SemVerClassification: |
| 765 | """Fold *classifications* into a :class:`SemVerClassification`. |
| 766 | |
| 767 | Applies the bump matrix to each non-invisible classification, promotes |
| 768 | to the highest resulting bump, and computes confidence as the minimum |
| 769 | across the driving (bump-raising) classifications. |
| 770 | """ |
| 771 | breaking: list[ChangeClassification] = [] |
| 772 | additive: list[ChangeClassification] = [] |
| 773 | implementation: list[ChangeClassification] = [] |
| 774 | invisible: list[ChangeClassification] = [] |
| 775 | |
| 776 | for c in classifications: |
| 777 | if c.change_kind == "breaking": |
| 778 | breaking.append(c) |
| 779 | elif c.change_kind == "additive": |
| 780 | additive.append(c) |
| 781 | elif c.change_kind == "implementation": |
| 782 | implementation.append(c) |
| 783 | else: |
| 784 | invisible.append(c) |
| 785 | |
| 786 | bump: SemVerBump = "none" |
| 787 | drivers: list[ChangeClassification] = [] |
| 788 | |
| 789 | for c in breaking + additive + implementation: |
| 790 | candidate = _bump_for(c.stability, c.change_kind) |
| 791 | rank_candidate = _BUMP_RANK[candidate] |
| 792 | rank_current = _BUMP_RANK[bump] |
| 793 | if rank_candidate > rank_current: |
| 794 | bump = candidate |
| 795 | drivers = [c] |
| 796 | elif rank_candidate == rank_current and candidate != "none": |
| 797 | drivers.append(c) |
| 798 | |
| 799 | confidence = min((d.confidence for d in drivers), default=1.0) |
| 800 | |
| 801 | return SemVerClassification( |
| 802 | bump=bump, |
| 803 | confidence=confidence, |
| 804 | breaking=breaking, |
| 805 | additive=additive, |
| 806 | implementation=implementation, |
| 807 | invisible=invisible, |
| 808 | ) |
| 809 | |
| 810 | def _bump_for(stability: StabilityTier, change_kind: ChangeKind) -> SemVerBump: |
| 811 | """Return the SemVerBump for a (stability, change_kind) pair. |
| 812 | |
| 813 | Implements the bump matrix documented in the module docstring. |
| 814 | ``invisible`` change_kind always returns ``"none"``. |
| 815 | """ |
| 816 | if change_kind == "invisible": |
| 817 | return "none" |
| 818 | if change_kind == "implementation": |
| 819 | return "patch" |
| 820 | # breaking or additive: |
| 821 | if stability == "stable": |
| 822 | return "major" if change_kind == "breaking" else "minor" |
| 823 | if stability == "experimental": |
| 824 | return "patch" |
| 825 | # unstable (default): |
| 826 | return "minor" if change_kind == "breaking" else "patch" |
| 827 | |
| 828 | # --------------------------------------------------------------------------- |
| 829 | # Internal — visibility helpers |
| 830 | # --------------------------------------------------------------------------- |
| 831 | |
| 832 | def _visibility(address: str, manifest: StabilityManifest) -> VisibilityTier: |
| 833 | """Return the visibility tier for *address*. |
| 834 | |
| 835 | Priority: |
| 836 | 1. Universal invisible patterns (always wins). |
| 837 | 2. Manifest invisible patterns. |
| 838 | 3. Private heuristic (underscore-prefixed innermost symbol name). |
| 839 | 4. Exported (default for everything else). |
| 840 | |
| 841 | ``internal`` is not produced by heuristics — it requires an explicit |
| 842 | manifest declaration (future work). |
| 843 | """ |
| 844 | fp = _file_part(address) |
| 845 | if _is_universally_invisible(fp): |
| 846 | return "invisible" |
| 847 | if manifest.is_invisible(address): |
| 848 | return "invisible" |
| 849 | if _is_private(address): |
| 850 | return "private" |
| 851 | return "exported" |
| 852 | |
| 853 | def _is_universally_invisible(address: str) -> bool: |
| 854 | """Return True if the file portion of *address* matches any universal invisible pattern.""" |
| 855 | fp = _file_part(address) |
| 856 | name = pathlib.PurePosixPath(fp).name |
| 857 | |
| 858 | for pattern in _UNIVERSAL_INVISIBLE_PATTERNS: |
| 859 | if "**" in pattern: |
| 860 | # Split on ** and check prefix/suffix. |
| 861 | if _glob_star_match(fp, pattern): |
| 862 | return True |
| 863 | elif "/" in pattern: |
| 864 | # Path pattern — match against full file path. |
| 865 | if fnmatch(fp, pattern): |
| 866 | return True |
| 867 | else: |
| 868 | # Filename-only pattern — match against the bare filename. |
| 869 | if fnmatch(name, pattern): |
| 870 | return True |
| 871 | return False |
| 872 | |
| 873 | def _is_private(address: str) -> bool: |
| 874 | """Return True if the innermost symbol name starts with an underscore. |
| 875 | |
| 876 | For file-only addresses (no ``::``), returns False — files are not |
| 877 | private merely because their name starts with ``_`` (e.g. ``_muse`` |
| 878 | zsh completion files are public by convention in that ecosystem). |
| 879 | """ |
| 880 | if "::" not in address: |
| 881 | return False |
| 882 | symbol_part = address.split("::")[-1] |
| 883 | name = symbol_part.split(".")[-1] # handle nested names like Class.method |
| 884 | return name.startswith("_") |
| 885 | |
| 886 | def _file_part(address: str) -> str: |
| 887 | """Extract the file path portion of an address (before ``::``). |
| 888 | |
| 889 | ``"muse/core/store.py::CommitRecord"`` → ``"muse/core/store.py"`` |
| 890 | ``"LICENSE"`` → ``"LICENSE"`` |
| 891 | """ |
| 892 | return address.split("::")[0] if "::" in address else address |
| 893 | |
| 894 | def _fnmatch_path(path: str, pattern: str) -> bool: |
| 895 | """fnmatch with ``**`` wildcard support.""" |
| 896 | if "**" in pattern: |
| 897 | return _glob_star_match(path, pattern) |
| 898 | return fnmatch(path, pattern) |
| 899 | |
| 900 | def _glob_star_match(path: str, pattern: str) -> bool: |
| 901 | """Match *path* against *pattern* where ``**`` matches any path segments. |
| 902 | |
| 903 | Converts the glob pattern to a regex: ``**`` → ``.*``, ``*`` → ``[^/]*``. |
| 904 | All other regex metacharacters are escaped. |
| 905 | """ |
| 906 | regex = re.escape(pattern).replace(r"\*\*", ".*").replace(r"\*", "[^/]*") |
| 907 | return bool(re.fullmatch(regex, path)) |
File History
2 commits
sha256:ac29b8ba9021514a03ab2782d92bf67671f0efa5b3b70d46f7598c5d4e923378
docs: record muse#74 Phase 4 live verification (SCR_13, SCR_14)
Sonnet 5
13 days ago
sha256:2562dffa0a0822ac1bdea854f9b267843c6ce95b497a9dc5c55837c80a3ebd0a
feat: domain_command_registry — Phase 1 of muse#74 (SCR_01-03)
Sonnet 4.6
patch
13 days ago