api_surface.py
python
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8
docs(KD-STAGING): sync governance after KD-6b DONE
Human
13 days ago
| 1 | """muse code api-surface — public API surface tracking. |
| 2 | |
| 3 | Shows which symbols in a snapshot are part of the public API, and how the |
| 4 | public API changed between two commits. |
| 5 | |
| 6 | A symbol is **public** when all of the following hold: |
| 7 | |
| 8 | * ``kind`` is one of: ``function``, ``async_function``, ``class``, |
| 9 | ``method``, ``async_method`` |
| 10 | * ``name`` does not start with ``_`` (Python convention for private/internal) |
| 11 | * ``kind`` is not ``import`` |
| 12 | |
| 13 | Muse answers "what changed in the public API between v1.0 and v1.1?" in O(1) |
| 14 | against committed snapshots — no checkout required, no working-tree parsing. |
| 15 | The diff output also produces a ``semver_impact`` field (``MAJOR``/``MINOR``/ |
| 16 | ``PATCH``) that feeds directly into ``muse commit``'s bump proposal. |
| 17 | |
| 18 | Usage:: |
| 19 | |
| 20 | muse code api-surface |
| 21 | muse code api-surface --commit HEAD~5 |
| 22 | muse code api-surface --diff main |
| 23 | muse code api-surface --diff main --breaking |
| 24 | muse code api-surface --language Python |
| 25 | muse code api-surface --file src/billing.py |
| 26 | muse code api-surface --count |
| 27 | muse code api-surface --json |
| 28 | |
| 29 | With ``--diff REF``, shows a three-section report:: |
| 30 | |
| 31 | Public API surface — commit a1b2c3d4 vs commit e5f6a7b8 |
| 32 | ────────────────────────────────────────────────────────────── |
| 33 | |
| 34 | Added (3): |
| 35 | + src/billing.py::compute_tax function |
| 36 | + src/auth.py::refresh_token function |
| 37 | + src/models.py::User.to_json method |
| 38 | |
| 39 | Removed (1): |
| 40 | - src/billing.py::compute_total function ⚠ BREAKING |
| 41 | |
| 42 | Changed (2): |
| 43 | ~ src/billing.py::Invoice.pay method (signature_change) ⚠ BREAKING |
| 44 | ~ src/auth.py::validate_token function (impl_only) |
| 45 | |
| 46 | semver impact: MAJOR · stability: 75% · 3 breaking change(s) |
| 47 | |
| 48 | Flags: |
| 49 | |
| 50 | ``--commit, -c REF`` |
| 51 | Show or compare from this commit (default: HEAD). |
| 52 | |
| 53 | ``--diff REF`` |
| 54 | Compare the commit from ``--commit`` against this ref. |
| 55 | |
| 56 | ``--breaking`` |
| 57 | In diff mode, show only breaking changes (removed + signature_change/ |
| 58 | signature+impl). Exits non-zero when any breaking changes exist. |
| 59 | |
| 60 | ``--language LANG`` |
| 61 | Filter to symbols in files of this language. |
| 62 | |
| 63 | ``--file PATH`` |
| 64 | Filter to symbols in this file path (substring match). |
| 65 | |
| 66 | ``--count`` |
| 67 | Print only the total symbol count (or change count in diff mode). |
| 68 | Scriptable — exits non-zero when breaking changes exist with ``--diff``. |
| 69 | |
| 70 | ``--json`` |
| 71 | Emit results as JSON with ``commit_id`` (full SHA), ``semver_impact``, |
| 72 | ``stability_pct``, and ``breaking_count`` fields. |
| 73 | """ |
| 74 | |
| 75 | from __future__ import annotations |
| 76 | |
| 77 | import argparse |
| 78 | import json |
| 79 | import logging |
| 80 | import pathlib |
| 81 | import sys |
| 82 | from typing import Literal |
| 83 | |
| 84 | from muse.core.errors import ExitCode |
| 85 | from muse.core.repo import read_repo_id, require_repo |
| 86 | from muse.core.store import ( |
| 87 | Manifest, |
| 88 | get_commit_snapshot_manifest, |
| 89 | read_current_branch, |
| 90 | resolve_commit_ref, |
| 91 | ) |
| 92 | from muse.core.symbol_cache import SymbolCache, load_symbol_cache |
| 93 | from muse.plugins.code._query import language_of, symbols_for_snapshot |
| 94 | from muse.plugins.code.ast_parser import SymbolRecord |
| 95 | from muse.core.validation import sanitize_display |
| 96 | from typing import TypedDict |
| 97 | |
| 98 | logger = logging.getLogger(__name__) |
| 99 | |
| 100 | type FlatSymbolMap = dict[str, SymbolRecord] # address → symbol |
| 101 | type ChangedSymbolMap = dict[str, tuple[SymbolRecord, SymbolRecord, str]] # address → (base, cur, summary) |
| 102 | |
| 103 | |
| 104 | class _PublicSymbolDict(TypedDict): |
| 105 | """JSON-serialisable form of a :class:`_PublicSymbol`.""" |
| 106 | |
| 107 | address: str |
| 108 | kind: str |
| 109 | name: str |
| 110 | qualified_name: str |
| 111 | language: str |
| 112 | content_id: str |
| 113 | signature_id: str |
| 114 | body_hash: str |
| 115 | |
| 116 | SemverImpact = Literal["MAJOR", "MINOR", "PATCH", "NONE"] |
| 117 | |
| 118 | _PUBLIC_KINDS: frozenset[str] = frozenset({ |
| 119 | "function", "async_function", "class", "method", "async_method", |
| 120 | }) |
| 121 | |
| 122 | _BREAKING_CHANGES: frozenset[str] = frozenset({ |
| 123 | "signature_change", "signature+impl", |
| 124 | }) |
| 125 | |
| 126 | |
| 127 | # --------------------------------------------------------------------------- |
| 128 | # Domain helpers |
| 129 | # --------------------------------------------------------------------------- |
| 130 | |
| 131 | |
| 132 | def _is_public(name: str, kind: str) -> bool: |
| 133 | """Return True when the symbol meets the public-API criteria.""" |
| 134 | return kind in _PUBLIC_KINDS and not name.split(".")[-1].startswith("_") |
| 135 | |
| 136 | |
| 137 | def _public_symbols( |
| 138 | root: pathlib.Path, |
| 139 | manifest: Manifest, |
| 140 | language_filter: str | None, |
| 141 | file_filter: str | None, |
| 142 | cache: SymbolCache, |
| 143 | ) -> FlatSymbolMap: |
| 144 | """Return all public symbols from *manifest* as a flat ``address → SymbolRecord`` dict. |
| 145 | |
| 146 | Shares *cache* with the caller — no extra disk I/O. |
| 147 | """ |
| 148 | result: FlatSymbolMap = {} |
| 149 | sym_map = symbols_for_snapshot( |
| 150 | root, manifest, |
| 151 | language_filter=language_filter, |
| 152 | cache=cache, |
| 153 | ) |
| 154 | for file_path, tree in sym_map.items(): |
| 155 | if file_filter and file_filter not in file_path: |
| 156 | continue |
| 157 | for address, rec in tree.items(): |
| 158 | if _is_public(rec["name"], rec["kind"]): |
| 159 | result[address] = rec |
| 160 | return result |
| 161 | |
| 162 | |
| 163 | def _classify_change(old: SymbolRecord, new: SymbolRecord) -> str: |
| 164 | """Classify what changed between two versions of the same public symbol.""" |
| 165 | if old["content_id"] == new["content_id"]: |
| 166 | return "unchanged" |
| 167 | if old["signature_id"] != new["signature_id"]: |
| 168 | if old["body_hash"] != new["body_hash"]: |
| 169 | return "signature+impl" |
| 170 | return "signature_change" |
| 171 | return "impl_only" |
| 172 | |
| 173 | |
| 174 | def _semver_impact( |
| 175 | added: FlatSymbolMap, |
| 176 | removed: FlatSymbolMap, |
| 177 | changed: ChangedSymbolMap, |
| 178 | ) -> SemverImpact: |
| 179 | """Infer the minimum semver bump required by the observed API changes. |
| 180 | |
| 181 | Rules (in priority order): |
| 182 | - Any removal → MAJOR |
| 183 | - Any signature_change or signature+impl → MAJOR |
| 184 | - Any addition → MINOR |
| 185 | - Any impl_only change → PATCH |
| 186 | - No changes → NONE |
| 187 | """ |
| 188 | if removed: |
| 189 | return "MAJOR" |
| 190 | for _, (_, _, cls) in changed.items(): |
| 191 | if cls in _BREAKING_CHANGES: |
| 192 | return "MAJOR" |
| 193 | if added: |
| 194 | return "MINOR" |
| 195 | if changed: |
| 196 | return "PATCH" |
| 197 | return "NONE" |
| 198 | |
| 199 | |
| 200 | def _stability_pct( |
| 201 | base_size: int, |
| 202 | removed: FlatSymbolMap, |
| 203 | changed: ChangedSymbolMap, |
| 204 | ) -> int: |
| 205 | """Percentage of the base API surface that survived unchanged.""" |
| 206 | if base_size == 0: |
| 207 | return 100 |
| 208 | disturbed = len(removed) + len(changed) |
| 209 | return round((base_size - disturbed) / base_size * 100) |
| 210 | |
| 211 | |
| 212 | # --------------------------------------------------------------------------- |
| 213 | # Output helper |
| 214 | # --------------------------------------------------------------------------- |
| 215 | |
| 216 | |
| 217 | class _ApiEntry: |
| 218 | """Wraps one public symbol for display or JSON serialisation.""" |
| 219 | |
| 220 | __slots__ = ("address", "rec", "language") |
| 221 | |
| 222 | def __init__(self, address: str, rec: SymbolRecord, language: str) -> None: |
| 223 | self.address = address |
| 224 | self.rec = rec |
| 225 | self.language = language |
| 226 | |
| 227 | def to_dict(self) -> _PublicSymbolDict: |
| 228 | """Return a JSON-serialisable dict with full (untruncated) IDs.""" |
| 229 | return { |
| 230 | "address": self.address, |
| 231 | "kind": self.rec["kind"], |
| 232 | "name": self.rec["name"], |
| 233 | "qualified_name": self.rec["qualified_name"], |
| 234 | "language": self.language, |
| 235 | "content_id": self.rec["content_id"], |
| 236 | "signature_id": self.rec["signature_id"], |
| 237 | "body_hash": self.rec["body_hash"], |
| 238 | } |
| 239 | |
| 240 | |
| 241 | # --------------------------------------------------------------------------- |
| 242 | # CLI registration |
| 243 | # --------------------------------------------------------------------------- |
| 244 | |
| 245 | |
| 246 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 247 | """Register the api-surface subcommand.""" |
| 248 | parser = subparsers.add_parser( |
| 249 | "api-surface", |
| 250 | help="Show the public API surface and how it changed between two commits.", |
| 251 | description=__doc__, |
| 252 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 253 | ) |
| 254 | parser.add_argument( |
| 255 | "--commit", "-c", default=None, metavar="REF", dest="ref", |
| 256 | help="Show surface at this commit (default: HEAD).", |
| 257 | ) |
| 258 | parser.add_argument( |
| 259 | "--diff", default=None, metavar="REF", dest="diff_ref", |
| 260 | help="Compare HEAD (or --commit) against this ref.", |
| 261 | ) |
| 262 | parser.add_argument( |
| 263 | "--breaking", action="store_true", dest="breaking_only", |
| 264 | help="Show only breaking changes; exit non-zero when any exist.", |
| 265 | ) |
| 266 | parser.add_argument( |
| 267 | "--language", "-l", default=None, metavar="LANG", dest="language", |
| 268 | help="Filter to this language (Python, Go, Rust, …).", |
| 269 | ) |
| 270 | parser.add_argument( |
| 271 | "--file", default=None, metavar="PATH", dest="file_filter", |
| 272 | help="Filter to symbols in this file (substring match).", |
| 273 | ) |
| 274 | parser.add_argument( |
| 275 | "--count", action="store_true", dest="count_only", |
| 276 | help="Print only the total symbol count (scriptable).", |
| 277 | ) |
| 278 | parser.add_argument( |
| 279 | "--json", action="store_true", dest="as_json", |
| 280 | help="Emit results as JSON.", |
| 281 | ) |
| 282 | parser.set_defaults(func=run) |
| 283 | |
| 284 | |
| 285 | # --------------------------------------------------------------------------- |
| 286 | # Command entry point |
| 287 | # --------------------------------------------------------------------------- |
| 288 | |
| 289 | |
| 290 | def run(args: argparse.Namespace) -> None: |
| 291 | """Show the public API surface and how it changed between two commits.""" |
| 292 | ref: str | None = args.ref |
| 293 | diff_ref: str | None = args.diff_ref |
| 294 | language: str | None = args.language |
| 295 | file_filter: str | None = args.file_filter |
| 296 | breaking_only: bool = args.breaking_only |
| 297 | count_only: bool = args.count_only |
| 298 | as_json: bool = args.as_json |
| 299 | |
| 300 | if breaking_only and diff_ref is None: |
| 301 | print("❌ --breaking requires --diff REF.", file=sys.stderr) |
| 302 | raise SystemExit(ExitCode.USER_ERROR) |
| 303 | |
| 304 | root = require_repo() |
| 305 | |
| 306 | repo_id = read_repo_id(root) |
| 307 | |
| 308 | branch = read_current_branch(root) |
| 309 | cache = load_symbol_cache(root) |
| 310 | |
| 311 | commit = resolve_commit_ref(root, repo_id, branch, ref) |
| 312 | if commit is None: |
| 313 | print(f"❌ Commit '{ref or 'HEAD'}' not found.", file=sys.stderr) |
| 314 | raise SystemExit(ExitCode.USER_ERROR) |
| 315 | |
| 316 | manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {} |
| 317 | current_surface = _public_symbols(root, manifest, language, file_filter, cache) |
| 318 | |
| 319 | if diff_ref is None: |
| 320 | _run_list_mode( |
| 321 | root, commit.commit_id, current_surface, |
| 322 | language, file_filter, count_only, as_json, |
| 323 | ) |
| 324 | cache.save() |
| 325 | return |
| 326 | |
| 327 | base_commit = resolve_commit_ref(root, repo_id, branch, diff_ref) |
| 328 | if base_commit is None: |
| 329 | print(f"❌ Diff ref '{diff_ref}' not found.", file=sys.stderr) |
| 330 | raise SystemExit(ExitCode.USER_ERROR) |
| 331 | |
| 332 | base_manifest = get_commit_snapshot_manifest(root, base_commit.commit_id) or {} |
| 333 | base_surface = _public_symbols(root, base_manifest, language, file_filter, cache) |
| 334 | cache.save() |
| 335 | |
| 336 | added = {a: r for a, r in current_surface.items() if a not in base_surface} |
| 337 | removed = {a: r for a, r in base_surface.items() if a not in current_surface} |
| 338 | changed: ChangedSymbolMap = {} |
| 339 | for addr in current_surface: |
| 340 | if addr in base_surface: |
| 341 | cls = _classify_change(base_surface[addr], current_surface[addr]) |
| 342 | if cls != "unchanged": |
| 343 | changed[addr] = (base_surface[addr], current_surface[addr], cls) |
| 344 | |
| 345 | if breaking_only: |
| 346 | added = {} |
| 347 | removed_breaking = removed # all removals are breaking |
| 348 | changed = {a: v for a, v in changed.items() if v[2] in _BREAKING_CHANGES} |
| 349 | _run_diff_mode( |
| 350 | commit.commit_id, base_commit.commit_id, language, file_filter, |
| 351 | added, removed_breaking, changed, count_only, as_json, base_surface, |
| 352 | ) |
| 353 | else: |
| 354 | _run_diff_mode( |
| 355 | commit.commit_id, base_commit.commit_id, language, file_filter, |
| 356 | added, removed, changed, count_only, as_json, base_surface, |
| 357 | ) |
| 358 | |
| 359 | has_breaking = bool(removed) or any( |
| 360 | v[2] in _BREAKING_CHANGES for v in changed.values() |
| 361 | ) |
| 362 | if has_breaking: |
| 363 | raise SystemExit(ExitCode.USER_ERROR if breaking_only else 0) |
| 364 | |
| 365 | |
| 366 | def _run_list_mode( |
| 367 | root: pathlib.Path, |
| 368 | commit_id: str, |
| 369 | surface: FlatSymbolMap, |
| 370 | language: str | None, |
| 371 | file_filter: str | None, |
| 372 | count_only: bool, |
| 373 | as_json: bool, |
| 374 | ) -> None: |
| 375 | """Render the simple list (no --diff) output.""" |
| 376 | entries = [ |
| 377 | _ApiEntry(addr, rec, language_of(addr.split("::")[0])) |
| 378 | for addr, rec in sorted(surface.items()) |
| 379 | ] |
| 380 | |
| 381 | if count_only and not as_json: |
| 382 | print(len(entries)) |
| 383 | return |
| 384 | |
| 385 | if as_json: |
| 386 | print(json.dumps( |
| 387 | { |
| 388 | "commit_id": commit_id, |
| 389 | "language_filter": language, |
| 390 | "file_filter": file_filter, |
| 391 | "total": len(entries), |
| 392 | "results": [e.to_dict() for e in entries], |
| 393 | }, |
| 394 | indent=2, |
| 395 | )) |
| 396 | return |
| 397 | |
| 398 | print(f"\nPublic API surface — {commit_id[:8]}") |
| 399 | if language: |
| 400 | print(f" (language: {language})") |
| 401 | if file_filter: |
| 402 | print(f" (file: {file_filter})") |
| 403 | print("─" * 62) |
| 404 | if not entries: |
| 405 | print(" (no public symbols found)") |
| 406 | return |
| 407 | max_addr = max(len(e.address) for e in entries) |
| 408 | for e in entries: |
| 409 | print(f" {sanitize_display(e.address):<{max_addr}} {e.rec['kind']}") |
| 410 | print(f"\n {len(entries)} public symbol(s)") |
| 411 | |
| 412 | |
| 413 | def _run_diff_mode( |
| 414 | commit_id: str, |
| 415 | base_commit_id: str, |
| 416 | language: str | None, |
| 417 | file_filter: str | None, |
| 418 | added: FlatSymbolMap, |
| 419 | removed: FlatSymbolMap, |
| 420 | changed: ChangedSymbolMap, |
| 421 | count_only: bool, |
| 422 | as_json: bool, |
| 423 | base_surface: FlatSymbolMap, |
| 424 | ) -> None: |
| 425 | """Render the diff output.""" |
| 426 | impact = _semver_impact(added, removed, changed) |
| 427 | stability = _stability_pct(len(base_surface), removed, changed) |
| 428 | breaking_count = len(removed) + sum( |
| 429 | 1 for _, (_, _, cls) in changed.items() if cls in _BREAKING_CHANGES |
| 430 | ) |
| 431 | |
| 432 | total_changes = len(added) + len(removed) + len(changed) |
| 433 | |
| 434 | if count_only and not as_json: |
| 435 | print(total_changes) |
| 436 | return |
| 437 | |
| 438 | if as_json: |
| 439 | print(json.dumps( |
| 440 | { |
| 441 | "commit_id": commit_id, |
| 442 | "base_commit_id": base_commit_id, |
| 443 | "language_filter": language, |
| 444 | "file_filter": file_filter, |
| 445 | "semver_impact": impact, |
| 446 | "stability_pct": stability, |
| 447 | "breaking_count": breaking_count, |
| 448 | "added": [ |
| 449 | _ApiEntry(a, r, language_of(a.split("::")[0])).to_dict() |
| 450 | for a, r in sorted(added.items()) |
| 451 | ], |
| 452 | "removed": [ |
| 453 | _ApiEntry(a, r, language_of(a.split("::")[0])).to_dict() |
| 454 | for a, r in sorted(removed.items()) |
| 455 | ], |
| 456 | "changed": [ |
| 457 | { |
| 458 | **_ApiEntry(a, new, language_of(a.split("::")[0])).to_dict(), |
| 459 | "change": cls, |
| 460 | "breaking": cls in _BREAKING_CHANGES, |
| 461 | } |
| 462 | for a, (_, new, cls) in sorted(changed.items()) |
| 463 | ], |
| 464 | }, |
| 465 | indent=2, |
| 466 | )) |
| 467 | return |
| 468 | |
| 469 | print(f"\nPublic API surface — {commit_id[:8]} vs {base_commit_id[:8]}") |
| 470 | if language: |
| 471 | print(f" (language: {language})") |
| 472 | if file_filter: |
| 473 | print(f" (file: {file_filter})") |
| 474 | print("─" * 62) |
| 475 | |
| 476 | all_addrs = sorted(set(list(added) + list(removed) + list(changed))) |
| 477 | max_addr = max((len(a) for a in all_addrs), default=40) |
| 478 | |
| 479 | if added: |
| 480 | print(f"\nAdded ({len(added)}):") |
| 481 | for addr, rec in sorted(added.items()): |
| 482 | print(f" + {sanitize_display(addr):<{max_addr}} {rec['kind']}") |
| 483 | |
| 484 | if removed: |
| 485 | print(f"\nRemoved ({len(removed)}):") |
| 486 | for addr, rec in sorted(removed.items()): |
| 487 | print(f" - {sanitize_display(addr):<{max_addr}} {rec['kind']} ⚠ BREAKING") |
| 488 | |
| 489 | if changed: |
| 490 | print(f"\nChanged ({len(changed)}):") |
| 491 | for addr, (_, new, cls) in sorted(changed.items()): |
| 492 | breaking_tag = " ⚠ BREAKING" if cls in _BREAKING_CHANGES else "" |
| 493 | print(f" ~ {sanitize_display(addr):<{max_addr}} {new['kind']} ({cls}){breaking_tag}") |
| 494 | |
| 495 | if not added and not removed and not changed: |
| 496 | print("\n ✅ No public API changes detected.") |
| 497 | else: |
| 498 | print(f"\n semver impact: {impact} · stability: {stability}% · {breaking_count} breaking change(s)") |
File History
1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8
docs(KD-STAGING): sync governance after KD-6b DONE
Human
13 days ago