gabriel / muse public
api_surface.py python
296 lines 10.2 KB
6acddccb fix: restore structured --help output for all CLI commands Gabriel Cardona <gabriel@tellurstori.com> 5d ago
1 """muse 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 Git cannot answer "what changed in the public API between v1.0 and v1.1?"
14 without an external diffing tool. Muse answers this in O(1) against committed
15 snapshots — no checkout required, no working-tree needed.
16
17 Usage::
18
19 muse api-surface
20 muse api-surface --commit HEAD~5
21 muse api-surface --diff main
22 muse api-surface --language Python
23 muse api-surface --json
24
25 With ``--diff REF``, shows a three-section report::
26
27 Public API surface — commit a1b2c3d4 vs commit e5f6a7b8
28 ──────────────────────────────────────────────────────────────
29
30 Added (3):
31 + src/billing.py::compute_tax function
32 + src/auth.py::refresh_token function
33 + src/models.py::User.to_json method
34
35 Removed (1):
36 - src/billing.py::compute_total function
37
38 Changed (2):
39 ~ src/billing.py::Invoice.pay method (signature_change)
40 ~ src/auth.py::validate_token function (impl_only)
41
42 Flags:
43
44 ``--commit, -c REF``
45 Show or compare from this commit (default: HEAD).
46
47 ``--diff REF``
48 Compare the commit from ``--commit`` against this ref.
49
50 ``--language LANG``
51 Filter to symbols in files of this language.
52
53 ``--json``
54 Emit results as JSON with a ``schema_version`` wrapper.
55 """
56
57 from __future__ import annotations
58
59 import argparse
60 import json
61 import logging
62 import pathlib
63 import sys
64
65 from muse._version import __version__
66 from muse.core.errors import ExitCode
67 from muse.core.repo import require_repo
68 from muse.core.store import get_commit_snapshot_manifest, read_current_branch, resolve_commit_ref
69 from muse.plugins.code._query import language_of, symbols_for_snapshot
70 from muse.plugins.code.ast_parser import SymbolRecord
71
72 logger = logging.getLogger(__name__)
73
74 _PUBLIC_KINDS: frozenset[str] = frozenset({
75 "function", "async_function", "class", "method", "async_method",
76 })
77
78
79 def _read_repo_id(root: pathlib.Path) -> str:
80 return str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
81
82
83 def _read_branch(root: pathlib.Path) -> str:
84 return read_current_branch(root)
85
86
87 def _is_public(name: str, kind: str) -> bool:
88 return kind in _PUBLIC_KINDS and not name.split(".")[-1].startswith("_")
89
90
91 def _public_symbols(
92 root: pathlib.Path,
93 manifest: dict[str, str],
94 language_filter: str | None,
95 ) -> dict[str, SymbolRecord]:
96 """Return all public symbols from *manifest* as a flat address → SymbolRecord dict."""
97 result: dict[str, SymbolRecord] = {}
98 sym_map = symbols_for_snapshot(root, manifest, language_filter=language_filter)
99 for _file, tree in sym_map.items():
100 for address, rec in tree.items():
101 if _is_public(rec["name"], rec["kind"]):
102 result[address] = rec
103 return result
104
105
106 def _classify_change(old: SymbolRecord, new: SymbolRecord) -> str:
107 """Return a human-readable classification of what changed."""
108 if old["content_id"] == new["content_id"]:
109 return "unchanged"
110 if old["signature_id"] != new["signature_id"]:
111 if old["body_hash"] != new["body_hash"]:
112 return "signature+impl"
113 return "signature_change"
114 return "impl_only"
115
116
117 class _ApiEntry:
118 def __init__(self, address: str, rec: SymbolRecord, language: str) -> None:
119 self.address = address
120 self.rec = rec
121 self.language = language
122
123 def to_dict(self) -> dict[str, str]:
124 return {
125 "address": self.address,
126 "kind": self.rec["kind"],
127 "name": self.rec["name"],
128 "qualified_name": self.rec["qualified_name"],
129 "language": self.language,
130 "content_id": self.rec["content_id"][:8],
131 "signature_id": self.rec["signature_id"][:8],
132 "body_hash": self.rec["body_hash"][:8],
133 }
134
135
136 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
137 """Register the api-surface subcommand."""
138 parser = subparsers.add_parser(
139 "api-surface",
140 help="Show the public API surface and how it changed between two commits.",
141 description=__doc__,
142 formatter_class=argparse.RawDescriptionHelpFormatter,
143 )
144 parser.add_argument(
145 "--commit", "-c", default=None, metavar="REF", dest="ref",
146 help="Show surface at this commit (default: HEAD).",
147 )
148 parser.add_argument(
149 "--diff", default=None, metavar="REF", dest="diff_ref",
150 help="Compare HEAD (or --commit) against this ref.",
151 )
152 parser.add_argument(
153 "--language", "-l", default=None, metavar="LANG", dest="language",
154 help="Filter to this language (Python, Go, Rust, …).",
155 )
156 parser.add_argument(
157 "--json", action="store_true", dest="as_json",
158 help="Emit results as JSON.",
159 )
160 parser.set_defaults(func=run)
161
162
163 def run(args: argparse.Namespace) -> None:
164 """Show the public API surface and how it changed between two commits.
165
166 A symbol is public when its kind is function/class/method (not import) and
167 its bare name does not start with ``_``.
168
169 With ``--diff REF``, shows three sections: Added, Removed, Changed.
170 Without ``--diff``, lists all public symbols at the given commit.
171
172 This command runs against committed snapshots only — no working-tree
173 parsing, no test execution.
174 """
175 ref: str | None = args.ref
176 diff_ref: str | None = args.diff_ref
177 language: str | None = args.language
178 as_json: bool = args.as_json
179
180 root = require_repo()
181 repo_id = _read_repo_id(root)
182 branch = _read_branch(root)
183
184 commit = resolve_commit_ref(root, repo_id, branch, ref)
185 if commit is None:
186 print(f"❌ Commit '{ref or 'HEAD'}' not found.", file=sys.stderr)
187 raise SystemExit(ExitCode.USER_ERROR)
188
189 manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {}
190 current_surface = _public_symbols(root, manifest, language)
191
192 if diff_ref is None:
193 # Just list the current surface.
194 entries = [
195 _ApiEntry(addr, rec, language_of(addr.split("::")[0]))
196 for addr, rec in sorted(current_surface.items())
197 ]
198 if as_json:
199 print(json.dumps(
200 {
201 "schema_version": __version__,
202 "commit": commit.commit_id[:8],
203 "language_filter": language,
204 "total": len(entries),
205 "symbols": [e.to_dict() for e in entries],
206 },
207 indent=2,
208 ))
209 return
210
211 print(f"\nPublic API surface — commit {commit.commit_id[:8]}")
212 if language:
213 print(f" (language: {language})")
214 print("─" * 62)
215 if not entries:
216 print(" (no public symbols found)")
217 return
218 max_addr = max(len(e.address) for e in entries)
219 for e in entries:
220 print(f" {e.address:<{max_addr}} {e.rec['kind']}")
221 print(f"\n {len(entries)} public symbol(s)")
222 return
223
224 # Diff mode.
225 base_commit = resolve_commit_ref(root, repo_id, branch, diff_ref)
226 if base_commit is None:
227 print(f"❌ Diff ref '{diff_ref}' not found.", file=sys.stderr)
228 raise SystemExit(ExitCode.USER_ERROR)
229
230 base_manifest = get_commit_snapshot_manifest(root, base_commit.commit_id) or {}
231 base_surface = _public_symbols(root, base_manifest, language)
232
233 added = {a: r for a, r in current_surface.items() if a not in base_surface}
234 removed = {a: r for a, r in base_surface.items() if a not in current_surface}
235 changed: dict[str, tuple[SymbolRecord, SymbolRecord, str]] = {}
236 for addr in current_surface:
237 if addr in base_surface:
238 cls = _classify_change(base_surface[addr], current_surface[addr])
239 if cls != "unchanged":
240 changed[addr] = (base_surface[addr], current_surface[addr], cls)
241
242 if as_json:
243 print(json.dumps(
244 {
245 "schema_version": __version__,
246 "commit": commit.commit_id[:8],
247 "base_commit": base_commit.commit_id[:8],
248 "language_filter": language,
249 "added": [
250 _ApiEntry(a, r, language_of(a.split("::")[0])).to_dict()
251 for a, r in sorted(added.items())
252 ],
253 "removed": [
254 _ApiEntry(a, r, language_of(a.split("::")[0])).to_dict()
255 for a, r in sorted(removed.items())
256 ],
257 "changed": [
258 {**_ApiEntry(a, new, language_of(a.split("::")[0])).to_dict(),
259 "change": cls}
260 for a, (_, new, cls) in sorted(changed.items())
261 ],
262 },
263 indent=2,
264 ))
265 return
266
267 print(
268 f"\nPublic API surface — commit {commit.commit_id[:8]} vs {base_commit.commit_id[:8]}"
269 )
270 if language:
271 print(f" (language: {language})")
272 print("─" * 62)
273
274 all_addrs = sorted(set(list(added) + list(removed) + list(changed)))
275 max_addr = max((len(a) for a in all_addrs), default=40)
276
277 if added:
278 print(f"\nAdded ({len(added)}):")
279 for addr, rec in sorted(added.items()):
280 print(f" + {addr:<{max_addr}} {rec['kind']}")
281
282 if removed:
283 print(f"\nRemoved ({len(removed)}):")
284 for addr, rec in sorted(removed.items()):
285 print(f" - {addr:<{max_addr}} {rec['kind']}")
286
287 if changed:
288 print(f"\nChanged ({len(changed)}):")
289 for addr, (_, new, cls) in sorted(changed.items()):
290 print(f" ~ {addr:<{max_addr}} {new['kind']} ({cls})")
291
292 if not added and not removed and not changed:
293 print("\n ✅ No public API changes detected.")
294 else:
295 n = len(added) + len(removed) + len(changed)
296 print(f"\n {n} public API change(s)")