gabriel / muse public
semver_classifier.py python
907 lines 34.5 KB
Raw
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