api_surface.py python
498 lines 16.3 KB
Raw
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