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