gabriel / muse public
api_surface.py python
535 lines 18.1 KB
Raw
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 70 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 import argparse
76 import json
77 import logging
78 import pathlib
79 import sys
80 from collections.abc import Callable
81 from typing import Literal, TypedDict
82
83 from muse.core.errors import ExitCode
84 from muse.core.repo import require_repo
85 from muse.core.types import Manifest
86 from muse.core.refs import read_current_branch
87 from muse.core.commits import resolve_commit_ref
88 from muse.core.snapshots import get_commit_snapshot_manifest
89 from muse.core.symbol_cache import SymbolCache, load_symbol_cache
90 from muse.core.envelope import EnvelopeJson, make_envelope
91 from muse.core.timing import start_timer
92 from muse.plugins.code._query import language_of, symbols_for_snapshot
93 from muse.plugins.code.ast_parser import SymbolRecord
94 from muse.core.validation import sanitize_display
95
96 logger = logging.getLogger(__name__)
97
98 type FlatSymbolMap = dict[str, SymbolRecord] # address → symbol
99 type ChangedSymbolMap = dict[str, tuple[SymbolRecord, SymbolRecord, str]] # address → (base, cur, summary)
100
101 class _PublicSymbolDict(TypedDict):
102 """JSON-serialisable form of a :class:`_PublicSymbol`."""
103
104 address: str
105 kind: str
106 name: str
107 qualified_name: str
108 language: str
109 content_id: str
110 signature_id: str
111 body_hash: str
112
113 class _ChangedSymbolDict(_PublicSymbolDict, total=False):
114 change: str
115 breaking: bool
116
117 class _ListJson(EnvelopeJson):
118 """JSON envelope for list mode (no --diff)."""
119
120 commit_id: str
121 language_filter: str | None
122 file_filter: str | None
123 total: int
124 results: list[_PublicSymbolDict]
125
126 class _DiffJson(EnvelopeJson):
127 """JSON envelope for diff mode (--diff REF)."""
128
129 commit_id: str
130 base_commit_id: str
131 language_filter: str | None
132 file_filter: str | None
133 semver_impact: str
134 stability_pct: int
135 breaking_count: int
136 added: list[_PublicSymbolDict]
137 removed: list[_PublicSymbolDict]
138 changed: list[_ChangedSymbolDict]
139
140 SemverImpact = Literal["MAJOR", "MINOR", "PATCH", "NONE"]
141
142 _PUBLIC_KINDS: frozenset[str] = frozenset({
143 "function", "async_function", "class", "method", "async_method",
144 })
145
146 _BREAKING_CHANGES: frozenset[str] = frozenset({
147 "signature_change", "signature+impl",
148 })
149
150 # ---------------------------------------------------------------------------
151 # Domain helpers
152 # ---------------------------------------------------------------------------
153
154 def _is_public(name: str, kind: str) -> bool:
155 """Return True when the symbol meets the public-API criteria."""
156 return kind in _PUBLIC_KINDS and not name.split(".")[-1].startswith("_")
157
158 def _public_symbols(
159 root: pathlib.Path,
160 manifest: Manifest,
161 language_filter: str | None,
162 file_filter: str | None,
163 cache: SymbolCache,
164 ) -> FlatSymbolMap:
165 """Return all public symbols from *manifest* as a flat ``address → SymbolRecord`` dict.
166
167 Shares *cache* with the caller — no extra disk I/O.
168 """
169 result: FlatSymbolMap = {}
170 sym_map = symbols_for_snapshot(
171 root, manifest,
172 language_filter=language_filter,
173 cache=cache,
174 )
175 for file_path, tree in sym_map.items():
176 if file_filter and file_filter not in file_path:
177 continue
178 for address, rec in tree.items():
179 if _is_public(rec["name"], rec["kind"]):
180 result[address] = rec
181 return result
182
183 def _classify_change(old: SymbolRecord, new: SymbolRecord) -> str:
184 """Classify what changed between two versions of the same public symbol."""
185 if old["content_id"] == new["content_id"]:
186 return "unchanged"
187 if old["signature_id"] != new["signature_id"]:
188 if old["body_hash"] != new["body_hash"]:
189 return "signature+impl"
190 return "signature_change"
191 return "impl_only"
192
193 def _semver_impact(
194 added: FlatSymbolMap,
195 removed: FlatSymbolMap,
196 changed: ChangedSymbolMap,
197 ) -> SemverImpact:
198 """Infer the minimum semver bump required by the observed API changes.
199
200 Rules (in priority order):
201 - Any removal → MAJOR
202 - Any signature_change or signature+impl → MAJOR
203 - Any addition → MINOR
204 - Any impl_only change → PATCH
205 - No changes → NONE
206 """
207 if removed:
208 return "MAJOR"
209 for _, (_, _, cls) in changed.items():
210 if cls in _BREAKING_CHANGES:
211 return "MAJOR"
212 if added:
213 return "MINOR"
214 if changed:
215 return "PATCH"
216 return "NONE"
217
218 def _stability_pct(
219 base_size: int,
220 removed: FlatSymbolMap,
221 changed: ChangedSymbolMap,
222 ) -> int:
223 """Percentage of the base API surface that survived unchanged."""
224 if base_size == 0:
225 return 100
226 disturbed = len(removed) + len(changed)
227 return round((base_size - disturbed) / base_size * 100)
228
229 # ---------------------------------------------------------------------------
230 # Output helper
231 # ---------------------------------------------------------------------------
232
233 class _ApiEntry:
234 """Wraps one public symbol for display or JSON serialisation."""
235
236 __slots__ = ("address", "rec", "language")
237
238 def __init__(self, address: str, rec: SymbolRecord, language: str) -> None:
239 self.address = address
240 self.rec = rec
241 self.language = language
242
243 def to_dict(self) -> _PublicSymbolDict:
244 """Return a JSON-serialisable dict with full (untruncated) IDs."""
245 return {
246 "address": self.address,
247 "kind": self.rec["kind"],
248 "name": self.rec["name"],
249 "qualified_name": self.rec["qualified_name"],
250 "language": self.language,
251 "content_id": self.rec["content_id"],
252 "signature_id": self.rec["signature_id"],
253 "body_hash": self.rec["body_hash"],
254 }
255
256 # ---------------------------------------------------------------------------
257 # CLI registration
258 # ---------------------------------------------------------------------------
259
260 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
261 """Register the api-surface subcommand."""
262 parser = subparsers.add_parser(
263 "api-surface",
264 help="Show the public API surface and how it changed between two commits.",
265 description=__doc__,
266 formatter_class=argparse.RawDescriptionHelpFormatter,
267 )
268 parser.add_argument(
269 "--commit", "-c", default=None, metavar="REF", dest="ref",
270 help="Show surface at this commit (default: HEAD).",
271 )
272 parser.add_argument(
273 "--diff", default=None, metavar="REF", dest="diff_ref",
274 help="Compare HEAD (or --commit) against this ref.",
275 )
276 parser.add_argument(
277 "--breaking", action="store_true", dest="breaking_only",
278 help="Show only breaking changes; exit non-zero when any exist.",
279 )
280 parser.add_argument(
281 "--language", "-l", default=None, metavar="LANG", dest="language",
282 help="Filter to this language (Python, Go, Rust, …).",
283 )
284 parser.add_argument(
285 "--file", default=None, metavar="PATH", dest="file_filter",
286 help="Filter to symbols in this file (substring match).",
287 )
288 parser.add_argument(
289 "--count", action="store_true", dest="count_only",
290 help="Print only the total symbol count (scriptable).",
291 )
292 parser.add_argument(
293 "--json", "-j",
294 action="store_true", dest="json_out",
295 help="Emit results as JSON (agent-friendly; -j is a shorthand alias).",
296 )
297 parser.set_defaults(func=run)
298
299 # ---------------------------------------------------------------------------
300 # Command entry point
301 # ---------------------------------------------------------------------------
302
303 def run(args: argparse.Namespace) -> None:
304 """Show the public API surface and how it changed between two commits.
305
306 Without ``--diff``, lists all public symbols at the given ref.
307 With ``--diff``, shows added, removed, and changed symbols between two refs.
308 Use ``--breaking`` (requires ``--diff``) to show only breaking changes.
309
310 Agent quickstart
311 ----------------
312 ::
313
314 muse api-surface --json # surface at HEAD
315 muse api-surface --diff main --json # changes vs main
316 muse api-surface --diff main --breaking --json # breaking changes only
317
318 JSON fields
319 -----------
320 symbols List of public symbol records at the target ref (without --diff).
321 added Symbols added since --diff ref.
322 removed Symbols removed since --diff ref.
323 changed Symbols whose signature changed since --diff ref.
324 breaking Subset of ``changed`` that are backward-incompatible.
325 total Total count of public symbols.
326
327 Exit codes
328 ----------
329 0 Success.
330 1 ``--breaking`` without ``--diff``, or commit not found.
331 2 Not inside a Muse repository.
332 """
333 elapsed = start_timer()
334 ref: str | None = args.ref
335 diff_ref: str | None = args.diff_ref
336 language: str | None = args.language
337 file_filter: str | None = args.file_filter
338 breaking_only: bool = args.breaking_only
339 count_only: bool = args.count_only
340 json_out: bool = args.json_out
341
342 if breaking_only and diff_ref is None:
343 print("❌ --breaking requires --diff REF.", file=sys.stderr)
344 raise SystemExit(ExitCode.USER_ERROR)
345
346 root = require_repo()
347
348
349 branch = read_current_branch(root)
350 cache = load_symbol_cache(root)
351
352 commit = resolve_commit_ref(root, branch, ref)
353 if commit is None:
354 print(f"❌ Commit '{ref or 'HEAD'}' not found.", file=sys.stderr)
355 raise SystemExit(ExitCode.USER_ERROR)
356
357 manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {}
358 current_surface = _public_symbols(root, manifest, language, file_filter, cache)
359
360 if diff_ref is None:
361 _run_list_mode(
362 root, commit.commit_id, current_surface,
363 language, file_filter, count_only, json_out, elapsed,
364 )
365 cache.save()
366 return
367
368 base_commit = resolve_commit_ref(root, branch, diff_ref)
369 if base_commit is None:
370 print(f"❌ Diff ref '{diff_ref}' not found.", file=sys.stderr)
371 raise SystemExit(ExitCode.USER_ERROR)
372
373 base_manifest = get_commit_snapshot_manifest(root, base_commit.commit_id) or {}
374 base_surface = _public_symbols(root, base_manifest, language, file_filter, cache)
375 cache.save()
376
377 added = {a: r for a, r in current_surface.items() if a not in base_surface}
378 removed = {a: r for a, r in base_surface.items() if a not in current_surface}
379 changed: ChangedSymbolMap = {}
380 for addr in current_surface:
381 if addr in base_surface:
382 cls = _classify_change(base_surface[addr], current_surface[addr])
383 if cls != "unchanged":
384 changed[addr] = (base_surface[addr], current_surface[addr], cls)
385
386 if breaking_only:
387 added = {}
388 removed_breaking = removed # all removals are breaking
389 changed = {a: v for a, v in changed.items() if v[2] in _BREAKING_CHANGES}
390 _run_diff_mode(
391 commit.commit_id, base_commit.commit_id, language, file_filter,
392 added, removed_breaking, changed, count_only, json_out, base_surface, elapsed,
393 )
394 else:
395 _run_diff_mode(
396 commit.commit_id, base_commit.commit_id, language, file_filter,
397 added, removed, changed, count_only, json_out, base_surface, elapsed,
398 )
399
400 has_breaking = bool(removed) or any(
401 v[2] in _BREAKING_CHANGES for v in changed.values()
402 )
403 if has_breaking:
404 raise SystemExit(ExitCode.USER_ERROR if breaking_only else 0)
405
406 def _run_list_mode(
407 root: pathlib.Path,
408 commit_id: str,
409 surface: FlatSymbolMap,
410 language: str | None,
411 file_filter: str | None,
412 count_only: bool,
413 as_json: bool,
414 elapsed: Callable[[], float],
415 ) -> None:
416 """Render the simple list (no --diff) output."""
417 entries = [
418 _ApiEntry(addr, rec, language_of(addr.split("::")[0]))
419 for addr, rec in sorted(surface.items())
420 ]
421
422 if count_only and not as_json:
423 print(len(entries))
424 return
425
426 if as_json:
427 print(json.dumps(_ListJson(
428 **make_envelope(elapsed),
429 commit_id=commit_id,
430 language_filter=language,
431 file_filter=file_filter,
432 total=len(entries),
433 results=[e.to_dict() for e in entries],
434 )))
435 return
436
437 print(f"\nPublic API surface — {commit_id}")
438 if language:
439 print(f" (language: {language})")
440 if file_filter:
441 print(f" (file: {file_filter})")
442 print("─" * 62)
443 if not entries:
444 print(" (no public symbols found)")
445 return
446 max_addr = max(len(e.address) for e in entries)
447 for e in entries:
448 print(f" {sanitize_display(e.address):<{max_addr}} {e.rec['kind']}")
449 print(f"\n {len(entries)} public symbol(s)")
450
451 def _run_diff_mode(
452 commit_id: str,
453 base_commit_id: str,
454 language: str | None,
455 file_filter: str | None,
456 added: FlatSymbolMap,
457 removed: FlatSymbolMap,
458 changed: ChangedSymbolMap,
459 count_only: bool,
460 as_json: bool,
461 base_surface: FlatSymbolMap,
462 elapsed: Callable[[], float],
463 ) -> None:
464 """Render the diff output."""
465 impact = _semver_impact(added, removed, changed)
466 stability = _stability_pct(len(base_surface), removed, changed)
467 breaking_count = len(removed) + sum(
468 1 for _, (_, _, cls) in changed.items() if cls in _BREAKING_CHANGES
469 )
470
471 total_changes = len(added) + len(removed) + len(changed)
472
473 if count_only and not as_json:
474 print(total_changes)
475 return
476
477 if as_json:
478 print(json.dumps(_DiffJson(
479 **make_envelope(elapsed),
480 commit_id=commit_id,
481 base_commit_id=base_commit_id,
482 language_filter=language,
483 file_filter=file_filter,
484 semver_impact=impact,
485 stability_pct=stability,
486 breaking_count=breaking_count,
487 added=[
488 _ApiEntry(a, r, language_of(a.split("::")[0])).to_dict()
489 for a, r in sorted(added.items())
490 ],
491 removed=[
492 _ApiEntry(a, r, language_of(a.split("::")[0])).to_dict()
493 for a, r in sorted(removed.items())
494 ],
495 changed=[
496 {
497 **_ApiEntry(a, new, language_of(a.split("::")[0])).to_dict(),
498 "change": cls,
499 "breaking": cls in _BREAKING_CHANGES,
500 }
501 for a, (_, new, cls) in sorted(changed.items())
502 ],
503 )))
504 return
505
506 print(f"\nPublic API surface — {commit_id} vs {base_commit_id}")
507 if language:
508 print(f" (language: {language})")
509 if file_filter:
510 print(f" (file: {file_filter})")
511 print("─" * 62)
512
513 all_addrs = sorted(set(list(added) + list(removed) + list(changed)))
514 max_addr = max((len(a) for a in all_addrs), default=40)
515
516 if added:
517 print(f"\nAdded ({len(added)}):")
518 for addr, rec in sorted(added.items()):
519 print(f" + {sanitize_display(addr):<{max_addr}} {rec['kind']}")
520
521 if removed:
522 print(f"\nRemoved ({len(removed)}):")
523 for addr, rec in sorted(removed.items()):
524 print(f" - {sanitize_display(addr):<{max_addr}} {rec['kind']} ⚠ BREAKING")
525
526 if changed:
527 print(f"\nChanged ({len(changed)}):")
528 for addr, (_, new, cls) in sorted(changed.items()):
529 breaking_tag = " ⚠ BREAKING" if cls in _BREAKING_CHANGES else ""
530 print(f" ~ {sanitize_display(addr):<{max_addr}} {new['kind']} ({cls}){breaking_tag}")
531
532 if not added and not removed and not changed:
533 print("\n ✅ No public API changes detected.")
534 else:
535 print(f"\n semver impact: {impact} · stability: {stability}% · {breaking_count} breaking change(s)")
File History 3 commits
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 70 days ago
sha256:a317886dc0496c4af7b285b3e41c86c4c34ea2e79afc63b8829aadb1ada7903f chore: bump version to 0.2.0rc15 to match musehub#113 fix release Sonnet 4.6 patch 70 days ago
sha256:f3b726b50f0aee3622bba751e0a67aa7ae4cf75a798477dbce581940b6a9cf70 feat: migrate invariants cache to .muse/cache/invariants.ms… Sonnet 4.6 patch 137 days ago