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