"""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. """ import pathlib import re import tomllib from dataclasses import dataclass, field from fnmatch import fnmatch from typing import Literal from muse.core.paths import stability_toml_path as _stability_toml_path from muse.core.types import SemVerBump from muse.domain import ( DomainOp, 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 = _stability_toml_path(repo_root) 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 == "rename": out.append(_classify_rename_op(op, domain, manifest)) # type: ignore[arg-type] # Unknown op types: skip — future-proof against new op kinds. def _classify_patch_op( op: DomainOp, 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: DomainOp, 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: DomainOp, 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: DomainOp, 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: DomainOp, 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: DomainOp, 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_rename_op( op: DomainOp, domain: str, manifest: StabilityManifest, ) -> ChangeClassification: """Classify a RenameOp (op="rename"). 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"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"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"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))