semver.py
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 1 | """Semantic versioning types, parsing, and release-channel helpers. |
| 2 | |
| 3 | This module is intentionally pure — it performs no I/O and has no dependency |
| 4 | on the object store. Anything that needs to reason about version strings, |
| 5 | release channels, or changelog shape should import from here rather than from |
| 6 | :mod:`muse.core.store`. |
| 7 | |
| 8 | Public API |
| 9 | ---------- |
| 10 | Types |
| 11 | :class:`SemVerTag` — parsed version components |
| 12 | :class:`ReleaseChannel` — ``"stable" | "beta" | "alpha" | "nightly"`` |
| 13 | :class:`ChangelogEntry` — one commit's contribution to a release |
| 14 | :class:`SemanticReleaseReport` — full snapshot analysis attached to a release |
| 15 | |
| 16 | Functions |
| 17 | :func:`parse_semver` — ``"v1.2.3-beta.1"`` → :class:`SemVerTag` |
| 18 | :func:`semver_to_str` — :class:`SemVerTag` → ``"v1.2.3-beta.1"`` |
| 19 | :func:`semver_channel` — infer :class:`ReleaseChannel` from pre-release label |
| 20 | """ |
| 21 | |
| 22 | import re |
| 23 | from typing import Literal, TypedDict |
| 24 | |
| 25 | from muse.core.types import SemVerBump |
| 26 | |
| 27 | # --------------------------------------------------------------------------- |
| 28 | # Release channels |
| 29 | # --------------------------------------------------------------------------- |
| 30 | |
| 31 | #: Named release channels. More expressive than a boolean ``is_prerelease``. |
| 32 | #: See docs/versioning.md for the full alpha/beta/rc/nightly maturity model. |
| 33 | ReleaseChannel = Literal["stable", "beta", "alpha", "rc", "nightly"] |
| 34 | |
| 35 | type _ChannelMap = dict[str, ReleaseChannel] |
| 36 | |
| 37 | #: Maps the user-supplied ``--channel`` string to the canonical channel value. |
| 38 | #: Unknown strings fall back to ``"stable"`` at the call sites that use this. |
| 39 | _CHANNEL_MAP: _ChannelMap = { |
| 40 | "stable": "stable", |
| 41 | "beta": "beta", |
| 42 | "alpha": "alpha", |
| 43 | "rc": "rc", |
| 44 | "nightly": "nightly", |
| 45 | } |
| 46 | |
| 47 | # --------------------------------------------------------------------------- |
| 48 | # Semver types |
| 49 | # --------------------------------------------------------------------------- |
| 50 | |
| 51 | #: Semver pre-release / build suffixes use only alphanumerics, hyphens, dots. |
| 52 | _SEMVER_PRE_RE = re.compile(r"^[0-9A-Za-z\-\.]*$") |
| 53 | |
| 54 | #: Strict semver regex (vMAJOR.MINOR.PATCH[-pre][+build]). |
| 55 | _SEMVER_RE = re.compile( |
| 56 | r"^v?(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)" |
| 57 | r"(?:-(?P<pre>[0-9A-Za-z\-]+(?:\.[0-9A-Za-z\-]+)*))?" |
| 58 | r"(?:\+(?P<build>[0-9A-Za-z\-]+(?:\.[0-9A-Za-z\-]+)*))?$" |
| 59 | ) |
| 60 | |
| 61 | class SemVerTag(TypedDict): |
| 62 | """Parsed semantic version components.""" |
| 63 | |
| 64 | major: int |
| 65 | minor: int |
| 66 | patch: int |
| 67 | pre: str # "" for stable releases; "beta.1", "alpha.2", etc. |
| 68 | build: str # "" unless a build metadata suffix is present |
| 69 | |
| 70 | # --------------------------------------------------------------------------- |
| 71 | # Changelog and release-report types |
| 72 | # --------------------------------------------------------------------------- |
| 73 | |
| 74 | class ChangelogEntry(TypedDict): |
| 75 | """One commit's contribution to a release changelog. |
| 76 | |
| 77 | Auto-populated by walking the commit graph from the previous release tag |
| 78 | to the current HEAD. The ``sem_ver_bump`` field drives grouping in the |
| 79 | rendered changelog so callers never need to parse commit messages. |
| 80 | """ |
| 81 | |
| 82 | commit_id: str |
| 83 | message: str |
| 84 | sem_ver_bump: SemVerBump |
| 85 | breaking_changes: list[str] |
| 86 | author: str |
| 87 | committed_at: str |
| 88 | agent_id: str |
| 89 | model_id: str |
| 90 | |
| 91 | class LanguageStat(TypedDict): |
| 92 | """File and symbol counts for a single programming language in a snapshot.""" |
| 93 | |
| 94 | language: str |
| 95 | files: int |
| 96 | symbols: int |
| 97 | |
| 98 | class SymbolKindCount(TypedDict): |
| 99 | """Count of symbols of a specific kind in a snapshot.""" |
| 100 | |
| 101 | kind: str |
| 102 | count: int |
| 103 | |
| 104 | class ApiChangeSummary(TypedDict): |
| 105 | """A single public-API symbol that was added, removed, or modified.""" |
| 106 | |
| 107 | address: str |
| 108 | language: str |
| 109 | kind: str # matches SymbolKind literals |
| 110 | change: str # "added" | "removed" | "modified" |
| 111 | |
| 112 | class FileHotspot(TypedDict): |
| 113 | """A file and the number of times it was touched across the release's commits.""" |
| 114 | |
| 115 | file_path: str |
| 116 | change_count: int |
| 117 | language: str |
| 118 | |
| 119 | class RefactorEventSummary(TypedDict): |
| 120 | """A single structural refactoring event detected across the release's commits.""" |
| 121 | |
| 122 | kind: str # "move" | "insert" | "delete" | "patch" (matches core DomainOp.op) |
| 123 | address: str |
| 124 | detail: str |
| 125 | commit_id: str |
| 126 | |
| 127 | class SemanticReleaseReport(TypedDict): |
| 128 | """Semantic analysis of a release, computed at push time from the object store. |
| 129 | |
| 130 | Populated by ``muse.plugins.code.release_analysis.compute_release_analysis`` |
| 131 | before the release is transmitted to a remote. MuseHub stores it verbatim |
| 132 | and renders it in the release detail page. |
| 133 | |
| 134 | All list fields default to ``[]`` and all int fields default to ``0`` so |
| 135 | that a partial or failed analysis still produces a valid, displayable report. |
| 136 | """ |
| 137 | |
| 138 | # Snapshot composition |
| 139 | languages: list[LanguageStat] |
| 140 | total_files: int |
| 141 | semantic_files: int # files with AST-level symbol support |
| 142 | total_symbols: int |
| 143 | symbols_by_kind: list[SymbolKindCount] |
| 144 | |
| 145 | # Delta (what changed in this release vs previous release) |
| 146 | files_changed: int |
| 147 | api_added: list[ApiChangeSummary] |
| 148 | api_removed: list[ApiChangeSummary] |
| 149 | api_modified: list[ApiChangeSummary] |
| 150 | file_hotspots: list[FileHotspot] |
| 151 | refactor_events: list[RefactorEventSummary] |
| 152 | |
| 153 | # Provenance aggregated from changelog commits |
| 154 | breaking_changes: list[str] # deduplicated across all changelog entries |
| 155 | human_commits: int |
| 156 | agent_commits: int |
| 157 | unique_agents: list[str] |
| 158 | unique_models: list[str] |
| 159 | reviewers: list[str] |
| 160 | |
| 161 | # --------------------------------------------------------------------------- |
| 162 | # Semver functions |
| 163 | # --------------------------------------------------------------------------- |
| 164 | |
| 165 | def parse_semver(version: str) -> SemVerTag: |
| 166 | """Parse *version* into a :class:`SemVerTag`. |
| 167 | |
| 168 | Accepts both ``v1.2.3`` and ``1.2.3`` forms. Raises :exc:`ValueError` |
| 169 | for any string that does not conform to Semantic Versioning 2.0.0. |
| 170 | """ |
| 171 | m = _SEMVER_RE.fullmatch(version.strip()) |
| 172 | if not m: |
| 173 | raise ValueError( |
| 174 | f"Version {version!r} is not valid semver (expected vMAJOR.MINOR.PATCH[-pre][+build])." |
| 175 | ) |
| 176 | return SemVerTag( |
| 177 | major=int(m.group("major")), |
| 178 | minor=int(m.group("minor")), |
| 179 | patch=int(m.group("patch")), |
| 180 | pre=m.group("pre") or "", |
| 181 | build=m.group("build") or "", |
| 182 | ) |
| 183 | |
| 184 | def semver_to_str(sv: SemVerTag) -> str: |
| 185 | """Render a :class:`SemVerTag` back to a canonical version string.""" |
| 186 | base = f"v{sv['major']}.{sv['minor']}.{sv['patch']}" |
| 187 | if sv["pre"]: |
| 188 | base = f"{base}-{sv['pre']}" |
| 189 | if sv["build"]: |
| 190 | base = f"{base}+{sv['build']}" |
| 191 | return base |
| 192 | |
| 193 | def semver_channel(sv: SemVerTag) -> ReleaseChannel: |
| 194 | """Infer the release channel from a semver pre-release label. |
| 195 | |
| 196 | If the caller does not supply an explicit channel, this provides a |
| 197 | sensible default: pre-releases starting with ``'nightly'`` → ``nightly``, |
| 198 | ``'alpha'`` → ``alpha``, ``'beta'`` → ``beta``, ``'rc'`` → ``rc``, anything |
| 199 | else → ``stable``. See docs/versioning.md for the maturity ordering |
| 200 | (nightly < alpha < beta < rc < stable) these checks are listed in. |
| 201 | """ |
| 202 | pre = sv["pre"].lower() |
| 203 | if pre.startswith("nightly"): |
| 204 | return "nightly" |
| 205 | if pre.startswith("alpha"): |
| 206 | return "alpha" |
| 207 | if pre.startswith("beta"): |
| 208 | return "beta" |
| 209 | if pre.startswith("rc"): |
| 210 | return "rc" |
| 211 | return "stable" |