"""Semver classifier — structured, evidence-driven semantic version inference. Architecture ------------ :: StructuredDelta │ ▼ UniversalInvisibleRules ← gates out noise: docs, tests, licenses, assets │ ▼ StabilityManifest ← optional .muse/stability.toml declaration │ explicit visibility + stability per symbol/pattern ▼ ChangeClassifier ← per-op: ChangeKind + VisibilityTier + │ StabilityTier + confidence + reason ▼ SemVerAggregator ← folds ChangeClassifications → SemVerBump │ ▼ SemVerClassification ← bump, confidence, full evidence breakdown Stability tiers ~~~~~~~~~~~~~~~ ``stable`` Symbols that have made a public contract commitment. Breaking changes here are always MAJOR. Additive changes are MINOR. Declare via ``.muse/stability.toml [stable]``. ``unstable`` Symbols that are public but still evolving. Breaking changes are MINOR (the surface was never committed to). Additive changes are PATCH. **This is the default when no stability.toml exists — the correct starting point for any new API.** ``experimental`` Explicitly provisional symbols. Any change (breaking or additive) is PATCH. Use for features gated behind flags or not yet advertised. Declare via ``.muse/stability.toml [experimental]``. Visibility tiers ~~~~~~~~~~~~~~~~ ``exported`` Part of the public API surface — used by external consumers. Heuristic: non-underscore-prefixed symbol not matched by any invisible pattern. Can be upgraded to ``stable`` or downgraded to ``internal`` via manifest. ``internal`` Crosses module boundaries but not exposed to external consumers. Not currently produced by heuristics — requires explicit manifest declaration. ``private`` Underscore-prefixed; local to the defining file. Changes here are always ``invisible`` for versioning purposes. ``invisible`` Never part of any version signal: documentation, tests, licenses, lock files, build artifacts, VCS metadata. Matched against ``_UNIVERSAL_INVISIBLE_PATTERNS`` or ``.muse/stability.toml [invisible]``. Change kinds ~~~~~~~~~~~~ ``breaking`` A consumer relying on this symbol can no longer do what they were doing. Triggered by: deletion, rename, signature-incompatible replacement. ``additive`` New capability; existing consumers are unaffected. Triggered by: insertion of a symbol onto the visible surface. ``implementation`` Internal behaviour changed; public contract intact. Triggered by: body change with identical signature. ``invisible`` No version signal whatsoever. Triggered by: universal invisible rules, manifest invisible patterns, or private symbol change. Bump matrix ~~~~~~~~~~~ +-------------------+----------+----------+----------------+ | Stability tier | breaking | additive | implementation | +===================+==========+==========+================+ | stable | MAJOR | MINOR | PATCH | +-------------------+----------+----------+----------------+ | unstable | MINOR | PATCH | PATCH | +-------------------+----------+----------+----------------+ | experimental | PATCH | PATCH | PATCH | +-------------------+----------+----------+----------------+ | (invisible) | none | none | none | +-------------------+----------+----------+----------------+ There is no pre-1.0 adjustment. The version number is a structural fact. To signal "this API has not made stability commitments", leave symbols undeclared (stability defaults to ``unstable``). The first MAJOR bump is the first time a ``stable`` surface breaks — which is exactly what MAJOR should mean. """ from __future__ import annotations import pathlib import re import tomllib from dataclasses import dataclass, field from fnmatch import fnmatch from typing import Literal from muse.domain import ( DomainOp, SemVerBump, StructuredDelta, ) __all__ = [ "VisibilityTier", "StabilityTier", "ChangeKind", "ChangeClassification", "SemVerClassification", "StabilityManifest", "classify_delta", ] # --------------------------------------------------------------------------- # Tier and kind type aliases # --------------------------------------------------------------------------- VisibilityTier = Literal["exported", "internal", "private", "invisible"] StabilityTier = Literal["stable", "unstable", "experimental"] ChangeKind = Literal["breaking", "additive", "implementation", "invisible"] # --------------------------------------------------------------------------- # Universal invisible patterns # --------------------------------------------------------------------------- #: File-path patterns that are never part of any version signal, regardless #: of repo or domain. Matched against the file portion of an op address. #: #: Repos may extend this list via ``.muse/stability.toml [invisible]``. #: Repos may NOT shrink it — these are universal invariants. _UNIVERSAL_INVISIBLE_PATTERNS: frozenset[str] = frozenset({ # Licences and legal "LICENSE", "LICENSE.*", "COPYING", "COPYING.*", "NOTICE", "NOTICE.*", # Human-readable prose "*.md", "*.rst", "*.txt", "*.adoc", "README", "README.*", "CHANGELOG", "CHANGELOG.*", "CHANGES", "CHANGES.*", "HISTORY", "HISTORY.*", # Documentation directories "docs/**", "doc/**", "documentation/**", # Tests (never part of the public API surface) "tests/**", "test/**", "spec/**", "test_*.py", "*_test.py", "conftest.py", "*.bats", "*.test.ts", "*.spec.ts", "*.test.js", "*.spec.js", # Python bytecode "**/__pycache__/**", "*.pyc", "*.pyo", # Dependency and lock files "*.lock", "package-lock.json", "yarn.lock", "Pipfile.lock", "poetry.lock", "requirements*.txt", # VCS and tool metadata ".museattributes", ".museignore", ".gitattributes", ".gitignore", ".editorconfig", ".prettierrc*", ".eslintrc*", # Build artifacts and cache markers "*.cache-id", "*.min.js", "*.min.css", # CI / deployment config ".github/**", ".gitlab-ci*", "Jenkinsfile", "Makefile", "makefile", }) # Bump ordering used for promotion comparisons. _BUMP_RANK: dict[SemVerBump, int] = { "none": 0, "patch": 1, "minor": 2, "major": 3 } # --------------------------------------------------------------------------- # StabilityManifest # --------------------------------------------------------------------------- @dataclass(frozen=True) class StabilityManifest: """Loaded from ``.muse/stability.toml`` — declares stability tiers. Repos that have made API commitments should maintain this file. Repos without it work correctly; all symbols default to ``unstable``, meaning breaking changes produce MINOR bumps rather than MAJOR. File format:: [stable] # Exact symbol addresses or fnmatch glob patterns. symbols = ["muse/core/store.py::CommitRecord"] patterns = ["muse/core/store.py::*"] [unstable] symbols = ["muse/cli/commands/release.py::run_suggest"] [experimental] symbols = [] [invisible] # File-path patterns suppressing all version signal. # Added on top of _UNIVERSAL_INVISIBLE_PATTERNS, never instead. patterns = ["src/ts/**", "*.scss"] Attributes: stable: Addresses/patterns committed to stable contract. unstable: Explicitly unstable (default for undeclared symbols). experimental: Explicitly experimental/provisional. invisible: Repo-specific invisible patterns (file paths only). """ stable: frozenset[str] = field(default_factory=frozenset) unstable: frozenset[str] = field(default_factory=frozenset) experimental: frozenset[str] = field(default_factory=frozenset) invisible: frozenset[str] = field(default_factory=frozenset) @classmethod def empty(cls) -> "StabilityManifest": """Return a manifest with no declarations (all symbols default to unstable).""" return cls() @classmethod def load(cls, repo_root: pathlib.Path) -> "StabilityManifest": """Load from ``/.muse/stability.toml``. Returns an empty manifest if the file does not exist. Invalid TOML is re-raised as ``tomllib.TOMLDecodeError``. Args: repo_root: Repository root (the directory containing ``.muse/``). Returns: Populated :class:`StabilityManifest`. """ path = repo_root / ".muse" / "stability.toml" if not path.exists(): return cls.empty() data = tomllib.loads(path.read_text(encoding="utf-8")) def _collect(section: str) -> frozenset[str]: sec = data.get(section, {}) return frozenset(sec.get("symbols", []) + sec.get("patterns", [])) return cls( stable=_collect("stable"), unstable=_collect("unstable"), experimental=_collect("experimental"), invisible=frozenset(data.get("invisible", {}).get("patterns", [])), ) def stability_for(self, address: str) -> StabilityTier: """Return the stability tier for *address*, defaulting to ``unstable``. Checks ``stable`` first, then ``experimental``. Explicit ``unstable`` declarations and undeclared symbols both return ``"unstable"``. Args: address: Symbol address (e.g. ``"muse/core/store.py::CommitRecord"``). Returns: ``"stable"``, ``"unstable"``, or ``"experimental"``. """ if self._matches(address, self.stable): return "stable" if self._matches(address, self.experimental): return "experimental" return "unstable" def is_invisible(self, address: str) -> bool: """Return True if *address* matches any repo-specific invisible pattern. Does not include :data:`_UNIVERSAL_INVISIBLE_PATTERNS` — callers should check universal rules separately via :func:`_is_universally_invisible`. Args: address: Op address (file path or ``file::symbol``). """ return self._matches(_file_part(address), self.invisible) def _matches(self, address: str, patterns: frozenset[str]) -> bool: """True if *address* or its file-path prefix matches any pattern.""" fp = _file_part(address) for pattern in patterns: if _fnmatch_path(address, pattern) or _fnmatch_path(fp, pattern): return True return False # --------------------------------------------------------------------------- # ChangeClassification and SemVerClassification # --------------------------------------------------------------------------- @dataclass(frozen=True) class ChangeClassification: """Classification of a single op from a :class:`~muse.domain.StructuredDelta`. Every op that flows through :func:`classify_delta` produces one of these. The full list is available on :class:`SemVerClassification` grouped by change kind, giving agents and humans complete evidence for any bump. Attributes: address: Op address (e.g. ``"muse/core/store.py::CommitRecord"``). change_kind: What happened — ``"breaking"``, ``"additive"``, ``"implementation"``, or ``"invisible"``. stability: Stability tier of the symbol at the time of the change. visibility: Visibility tier — ``"exported"``, ``"internal"``, ``"private"``, or ``"invisible"``. confidence: ``0.0``–``1.0``. ``1.0`` = certain; lower values flag heuristic guesses (e.g. unrecognised summary strings). reason: Human/agent-readable explanation of why this classification was assigned. Suitable for display in ``muse release suggest`` output and CI reports. """ address: str change_kind: ChangeKind stability: StabilityTier visibility: VisibilityTier confidence: float reason: str @dataclass(frozen=True) class SemVerClassification: """Full classification of a :class:`~muse.domain.StructuredDelta`. The ``bump`` field is the headline result. The four lists (``breaking``, ``additive``, ``implementation``, ``invisible``) are the full evidence: every op that contributed to the classification is present in exactly one list. Attributes: bump: Aggregated semantic version bump. confidence: Minimum confidence across the ops that drove ``bump``. ``1.0`` means every driving op was classified with certainty. Values below ``0.7`` should be reviewed. breaking: Ops classified as breaking. additive: Ops classified as additive (new surface). implementation: Ops classified as implementation-only. invisible: Ops that carry no version signal. """ bump: SemVerBump confidence: float breaking: list[ChangeClassification] additive: list[ChangeClassification] implementation: list[ChangeClassification] invisible: list[ChangeClassification] @property def breaking_addresses(self) -> list[str]: """Sorted list of addresses from :attr:`breaking` classifications. Equivalent to ``[c.address for c in self.breaking]``, sorted. Provided as a convenience for callers that store only addresses (e.g. :attr:`~muse.core.store.CommitRecord.breaking_changes`). """ return sorted(c.address for c in self.breaking) @property def all_classifications(self) -> list[ChangeClassification]: """All classifications in a single flat list (order unspecified).""" return self.breaking + self.additive + self.implementation + self.invisible # --------------------------------------------------------------------------- # Public entry point # --------------------------------------------------------------------------- def classify_delta( delta: StructuredDelta, manifest: StabilityManifest | None = None, repo_root: pathlib.Path | None = None, ) -> SemVerClassification: """Classify a :class:`~muse.domain.StructuredDelta` into a full semver classification. This is the single entry point for all semver inference in Muse. Priority order for classification decisions: 1. Universal invisible rules — file-path patterns that can never be API. 2. ``StabilityManifest.invisible`` — repo-specific invisible patterns. 3. Visibility heuristic — underscore-prefixed symbols are private. 4. ``StabilityManifest.stability_for()`` — explicit stability declarations. 5. Default stability — ``unstable`` when no manifest declaration exists. Args: delta: The structured delta from ``plugin.diff()``. Must contain an ``"ops"`` key; other keys are optional. manifest: Pre-loaded :class:`StabilityManifest`. When ``None`` and *repo_root* is provided, the manifest is loaded from ``/.muse/stability.toml``. When both are ``None``, only universal invisible rules and naming heuristics are applied. repo_root: Repository root for auto-loading the stability manifest. Ignored when *manifest* is explicitly supplied. Returns: :class:`SemVerClassification` with the aggregated bump and full evidence breakdown. Examples:: # Minimal usage — no stability manifest delta = plugin.diff(base_snap, target_snap, repo_root=root) result = classify_delta(delta) print(result.bump) # "minor" print(result.breaking_addresses) # [] # With auto-loaded manifest from repo result = classify_delta(delta, repo_root=pathlib.Path("/repo")) print(result.confidence) # 1.0 # Pre-loaded manifest (e.g. in tests) manifest = StabilityManifest.load(root) result = classify_delta(delta, manifest=manifest) """ if manifest is None and repo_root is not None: manifest = StabilityManifest.load(repo_root) if manifest is None: manifest = StabilityManifest.empty() domain = delta.get("domain", "") ops: list[DomainOp] = delta.get("ops", []) # type: ignore[assignment] classifications: list[ChangeClassification] = [] _classify_ops(ops, domain, manifest, classifications) return _aggregate(classifications) # --------------------------------------------------------------------------- # Internal — op classification # --------------------------------------------------------------------------- def _classify_ops( ops: list[DomainOp], domain: str, manifest: StabilityManifest, out: list[ChangeClassification], ) -> None: """Recursively classify all ops, appending to *out* in-place.""" for op in ops: op_type = op.get("op", "") if op_type == "patch": _classify_patch_op(op, domain, manifest, out) # type: ignore[arg-type] elif op_type == "insert": out.append(_classify_insert(op, manifest)) # type: ignore[arg-type] elif op_type == "delete": out.append(_classify_delete(op, manifest)) # type: ignore[arg-type] elif op_type == "replace": out.append(_classify_replace(op, manifest)) # type: ignore[arg-type] elif op_type == "move": out.append(_classify_move(op, manifest)) # type: ignore[arg-type] elif op_type == "mutate": out.append(_classify_mutate(op, manifest)) # type: ignore[arg-type] elif op_type == "directory_rename": out.append(_classify_directory_rename(op, domain, manifest)) # type: ignore[arg-type] # Unknown op types: skip — future-proof against new op kinds. def _classify_patch_op( op: dict, domain: str, manifest: StabilityManifest, out: list[ChangeClassification], ) -> None: """Classify a PatchOp by recursing into its child_ops. The PatchOp's own address (the file path) is used for invisible checks. If the file is universally or manifest-invisible, all child ops are also invisible — no recursion needed. """ address: str = str(op.get("address", "")) if _is_universally_invisible(address) or manifest.is_invisible(address): out.append(ChangeClassification( address=address, change_kind="invisible", stability="unstable", visibility="invisible", confidence=0.99, reason=f"file matches invisible pattern — no version signal", )) return child_ops: list[DomainOp] = op.get("child_ops", []) # type: ignore[assignment] if child_ops: _classify_ops(child_ops, domain, manifest, out) else: # PatchOp with no child_ops — implementation-level change. out.append(ChangeClassification( address=address, change_kind="implementation", stability=manifest.stability_for(address), visibility=_visibility(address, manifest), confidence=0.7, reason="file modified (no symbol-level diff available) — implementation change assumed", )) def _classify_insert(op: dict, manifest: StabilityManifest) -> ChangeClassification: """Classify an InsertOp.""" address: str = str(op.get("address", "")) vis = _visibility(address, manifest) if vis == "invisible": return ChangeClassification( address=address, change_kind="invisible", stability="unstable", visibility="invisible", confidence=0.99, reason="matches invisible pattern — file/symbol is not API", ) if vis == "private": return ChangeClassification( address=address, change_kind="invisible", stability="unstable", visibility="private", confidence=0.95, reason="private symbol (underscore prefix) — no API impact", ) stability = manifest.stability_for(address) return ChangeClassification( address=address, change_kind="additive", stability=stability, visibility=vis, confidence=1.0, reason=f"new symbol on {stability} surface — additive change", ) def _classify_delete(op: dict, manifest: StabilityManifest) -> ChangeClassification: """Classify a DeleteOp.""" address: str = str(op.get("address", "")) vis = _visibility(address, manifest) if vis == "invisible": return ChangeClassification( address=address, change_kind="invisible", stability="unstable", visibility="invisible", confidence=0.99, reason="matches invisible pattern — file/symbol is not API", ) if vis == "private": return ChangeClassification( address=address, change_kind="invisible", stability="unstable", visibility="private", confidence=0.95, reason="private symbol (underscore prefix) — no API impact", ) stability = manifest.stability_for(address) return ChangeClassification( address=address, change_kind="breaking", stability=stability, visibility=vis, confidence=1.0, reason=f"symbol deleted from {stability} surface — callers will fail", ) def _classify_replace(op: dict, manifest: StabilityManifest) -> ChangeClassification: """Classify a ReplaceOp by inspecting its summary strings.""" address: str = str(op.get("address", "")) new_summary: str = str(op.get("new_summary", "")) old_summary: str = str(op.get("old_summary", "")) vis = _visibility(address, manifest) if vis == "invisible": return ChangeClassification( address=address, change_kind="invisible", stability="unstable", visibility="invisible", confidence=0.99, reason="matches invisible pattern — file/symbol is not API", ) if vis == "private": return ChangeClassification( address=address, change_kind="invisible", stability="unstable", visibility="private", confidence=0.95, reason="private symbol (underscore prefix) — no API impact", ) stability = manifest.stability_for(address) # ── Rename / move detection ─────────────────────────────────────────────── if ( new_summary.startswith("renamed to ") or new_summary.startswith("moved to ") or new_summary.startswith("moved from ") ): return ChangeClassification( address=address, change_kind="breaking", stability=stability, visibility=vis, confidence=1.0, reason=f"symbol renamed/moved on {stability} surface — callers will fail: {new_summary}", ) # ── Signature change detection ──────────────────────────────────────────── if "signature" in new_summary or "signature" in old_summary: return ChangeClassification( address=address, change_kind="breaking", stability=stability, visibility=vis, confidence=1.0, reason=f"signature changed on {stability} symbol — call-site incompatible", ) # ── Implementation-only change ──────────────────────────────────────────── if "implementation" in new_summary or "implementation" in old_summary: return ChangeClassification( address=address, change_kind="implementation", stability=stability, visibility=vis, confidence=1.0, reason="implementation changed — public contract intact", ) # ── Catch-all: unrecognised summary ────────────────────────────────────── # The diff did not produce a recognised summary pattern. We cannot # determine change kind with certainty. Classify as breaking with low # confidence so the bump is conservative but the confidence score # signals that human/agent review is warranted. return ChangeClassification( address=address, change_kind="breaking", stability=stability, visibility=vis, confidence=0.4, reason=( f"unrecognised summary on {stability} symbol — classified as breaking " f"(conservative); review recommended. " f"old={old_summary!r} new={new_summary!r}" ), ) def _classify_move(op: dict, manifest: StabilityManifest) -> ChangeClassification: """Classify a MoveOp (ordered-sequence repositioning). MoveOp represents an element changing position within an ordered sequence (e.g. a MIDI note moved to a different beat). This is an implementation- level change — the element exists at a different position but its identity and content are unchanged. It does not break callers who access by identity rather than position. Uses ``old_address`` as the canonical address (the pre-move location that callers held references to). Falls back to ``address`` for forward compat. """ address: str = str(op.get("old_address", "") or op.get("address", "")) vis = _visibility(address, manifest) if vis in ("invisible", "private"): return ChangeClassification( address=address, change_kind="invisible", stability="unstable", visibility=vis, confidence=0.9, reason="move within ordered sequence — private or invisible symbol", ) return ChangeClassification( address=address, change_kind="implementation", stability=manifest.stability_for(address), visibility=vis, confidence=0.9, reason="element repositioned in ordered sequence — content unchanged", ) def _classify_mutate(op: dict, manifest: StabilityManifest) -> ChangeClassification: """Classify a MutateOp (field-level mutation, e.g. MIDI note attributes). Field mutations are implementation-level changes within a domain element. They do not remove or rename the element itself. """ address: str = str(op.get("address", "")) vis = _visibility(address, manifest) if vis in ("invisible", "private"): return ChangeClassification( address=address, change_kind="invisible", stability="unstable", visibility=vis, confidence=0.9, reason="field mutation on private/invisible element", ) return ChangeClassification( address=address, change_kind="implementation", stability=manifest.stability_for(address), visibility=vis, confidence=0.9, reason="field-level mutation — element identity unchanged", ) def _classify_directory_rename( op: dict, domain: str, manifest: StabilityManifest, ) -> ChangeClassification: """Classify a DirectoryRenameOp. For the ``code`` domain, renaming a directory is potentially breaking because module import paths change. For other domains, directory structure is organisational and not part of the public API. Confidence is 0.5 for code-domain renames not matched by the invisible pattern set — the classifier cannot determine whether the directory is an importable package without inspecting ``__init__.py`` presence. """ address: str = str(op.get("address", "")) from_address: str = str(op.get("from_address", "")) check_address = from_address or address if _is_universally_invisible(check_address) or manifest.is_invisible(check_address): return ChangeClassification( address=address, change_kind="invisible", stability="unstable", visibility="invisible", confidence=0.99, reason=f"directory rename on invisible path '{from_address}' → '{address}'", ) if domain != "code": return ChangeClassification( address=address, change_kind="invisible", stability="unstable", visibility="invisible", confidence=0.8, reason=f"directory rename in non-code domain '{domain}' — not API", ) # Code domain: potentially breaking (import path change). return ChangeClassification( address=address, change_kind="breaking", stability=manifest.stability_for(address), visibility="exported", confidence=0.5, reason=( f"directory renamed '{from_address}' → '{address}' in code domain — " "import paths may break; confidence 0.5 (cannot verify __init__.py presence)" ), ) # --------------------------------------------------------------------------- # Internal — aggregation # --------------------------------------------------------------------------- def _aggregate(classifications: list[ChangeClassification]) -> SemVerClassification: """Fold *classifications* into a :class:`SemVerClassification`. Applies the bump matrix to each non-invisible classification, promotes to the highest resulting bump, and computes confidence as the minimum across the driving (bump-raising) classifications. """ breaking: list[ChangeClassification] = [] additive: list[ChangeClassification] = [] implementation: list[ChangeClassification] = [] invisible: list[ChangeClassification] = [] for c in classifications: if c.change_kind == "breaking": breaking.append(c) elif c.change_kind == "additive": additive.append(c) elif c.change_kind == "implementation": implementation.append(c) else: invisible.append(c) bump: SemVerBump = "none" drivers: list[ChangeClassification] = [] for c in breaking + additive + implementation: candidate = _bump_for(c.stability, c.change_kind) rank_candidate = _BUMP_RANK[candidate] rank_current = _BUMP_RANK[bump] if rank_candidate > rank_current: bump = candidate drivers = [c] elif rank_candidate == rank_current and candidate != "none": drivers.append(c) confidence = min((d.confidence for d in drivers), default=1.0) return SemVerClassification( bump=bump, confidence=confidence, breaking=breaking, additive=additive, implementation=implementation, invisible=invisible, ) def _bump_for(stability: StabilityTier, change_kind: ChangeKind) -> SemVerBump: """Return the SemVerBump for a (stability, change_kind) pair. Implements the bump matrix documented in the module docstring. ``invisible`` change_kind always returns ``"none"``. """ if change_kind == "invisible": return "none" if change_kind == "implementation": return "patch" # breaking or additive: if stability == "stable": return "major" if change_kind == "breaking" else "minor" if stability == "experimental": return "patch" # unstable (default): return "minor" if change_kind == "breaking" else "patch" # --------------------------------------------------------------------------- # Internal — visibility helpers # --------------------------------------------------------------------------- def _visibility(address: str, manifest: StabilityManifest) -> VisibilityTier: """Return the visibility tier for *address*. Priority: 1. Universal invisible patterns (always wins). 2. Manifest invisible patterns. 3. Private heuristic (underscore-prefixed innermost symbol name). 4. Exported (default for everything else). ``internal`` is not produced by heuristics — it requires an explicit manifest declaration (future work). """ fp = _file_part(address) if _is_universally_invisible(fp): return "invisible" if manifest.is_invisible(address): return "invisible" if _is_private(address): return "private" return "exported" def _is_universally_invisible(address: str) -> bool: """Return True if the file portion of *address* matches any universal invisible pattern.""" fp = _file_part(address) name = pathlib.PurePosixPath(fp).name for pattern in _UNIVERSAL_INVISIBLE_PATTERNS: if "**" in pattern: # Split on ** and check prefix/suffix. if _glob_star_match(fp, pattern): return True elif "/" in pattern: # Path pattern — match against full file path. if fnmatch(fp, pattern): return True else: # Filename-only pattern — match against the bare filename. if fnmatch(name, pattern): return True return False def _is_private(address: str) -> bool: """Return True if the innermost symbol name starts with an underscore. For file-only addresses (no ``::``), returns False — files are not private merely because their name starts with ``_`` (e.g. ``_muse`` zsh completion files are public by convention in that ecosystem). """ if "::" not in address: return False symbol_part = address.split("::")[-1] name = symbol_part.split(".")[-1] # handle nested names like Class.method return name.startswith("_") def _file_part(address: str) -> str: """Extract the file path portion of an address (before ``::``). ``"muse/core/store.py::CommitRecord"`` → ``"muse/core/store.py"`` ``"LICENSE"`` → ``"LICENSE"`` """ return address.split("::")[0] if "::" in address else address def _fnmatch_path(path: str, pattern: str) -> bool: """fnmatch with ``**`` wildcard support.""" if "**" in pattern: return _glob_star_match(path, pattern) return fnmatch(path, pattern) def _glob_star_match(path: str, pattern: str) -> bool: """Match *path* against *pattern* where ``**`` matches any path segments. Converts the glob pattern to a regex: ``**`` → ``.*``, ``*`` → ``[^/]*``. All other regex metacharacters are escaped. """ regex = re.escape(pattern).replace(r"\*\*", ".*").replace(r"\*", "[^/]*") return bool(re.fullmatch(regex, path))