gabriel / muse public
compare.py python
471 lines 17.4 KB
Raw
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be Merge branch 'fix/hub-user-read-update-path' into dev Human 11 days ago
1 """muse code compare — semantic comparison between any two historical snapshots.
2
3 ``muse diff`` compares the working tree to HEAD. ``muse code compare``
4 compares any two historical commits — a full semantic diff between a release
5 tag and the current HEAD, between the start and end of a sprint, between two
6 branches.
7
8 Unlike a line-level diff, ``muse code compare`` tracks:
9
10 - Functions / classes **added** or **removed**
11 - Symbols **renamed** or **moved** across files (not just deleted + re-added)
12 - **Signature changes** vs **implementation-only** changes
13
14 Pass ``--semver`` to get an automatic MAJOR / MINOR / PATCH recommendation
15 based on public API changes: removed symbols → MAJOR, added symbols → MINOR,
16 implementation-only changes → PATCH.
17
18 Usage::
19
20 muse code compare HEAD~10 HEAD (use commit SHAs, not ~ notation)
21 muse code compare a3f2c9 cb4afa
22 muse code compare a3f2c9 cb4afa --kind function
23 muse code compare a3f2c9 cb4afa --file billing.py
24 muse code compare a3f2c9 cb4afa --semver
25 muse code compare a3f2c9 cb4afa --stat
26 muse code compare a3f2c9 cb4afa --json
27
28 Output::
29
30 Semantic comparison
31 From: a3f2c9e1 "Add billing module"
32 To: cb4afaed "Merge: release v1.0"
33
34 src/billing.py
35 added compute_invoice_total (renamed from calculate_total)
36 modified Invoice.to_dict (signature changed)
37 moved validate_amount → src/validation.py
38
39 src/validation.py (new file)
40 added validate_amount (moved from src/billing.py)
41
42 7 symbol change(s) across 3 file(s)
43 SemVer impact: MINOR
44 """
45
46 import argparse
47 import json
48 import logging
49 import pathlib
50 import sys
51 from typing import TypedDict
52
53 from muse.core.types import Manifest
54 from muse.core.errors import ExitCode
55 from muse.core.repo import require_repo
56 from muse.core.refs import read_current_branch
57 from muse.core.commits import resolve_commit_ref
58 from muse.core.snapshots import get_commit_snapshot_manifest
59 from muse.core.timing import start_timer
60 from muse.core.envelope import EnvelopeJson, make_envelope
61 from muse.core.symbol_cache import load_symbol_cache
62 from muse.domain import DomainOp
63 from muse.plugins.code._query import language_of, normalise_language, symbols_for_snapshot
64 from muse.plugins.code.symbol_diff import build_diff_ops
65 from muse.core.validation import sanitize_display
66
67 logger = logging.getLogger(__name__)
68
69 class _OpSummary(TypedDict):
70 op: str
71 address: str
72 detail: str
73
74 class _CommitRef(TypedDict):
75 commit_id: str
76 message: str
77
78 class _CompareFilters(TypedDict):
79 kind: str | None
80 file: str | None
81 language: str | None
82
83 class _CompareStat(TypedDict):
84 files_changed: int
85 symbols_added: int
86 symbols_removed: int
87 symbols_modified: int
88 semver_impact: str
89
90 # ``from`` is a Python keyword, so use the functional TypedDict form for the
91 # domain payload, then inherit into a class-based TypedDict that also inherits
92 # EnvelopeJson — giving us proper wire-shape typing without workarounds.
93 _ComparePayload = TypedDict("_ComparePayload", {
94 "from": _CommitRef,
95 "to": _CommitRef,
96 "filters": _CompareFilters,
97 "stat": _CompareStat,
98 "ops": list[_OpSummary],
99 })
100
101 class _CompareJson(_ComparePayload, EnvelopeJson):
102 """Full wire shape for ``muse code compare --json``."""
103
104 def _first_line(message: str) -> str:
105 """Return the first non-empty line of a commit message."""
106 for line in message.splitlines():
107 stripped = line.strip()
108 if stripped:
109 return stripped
110 return message.strip()
111
112 def _is_public_name(address: str) -> bool:
113 """Return True if the symbol is a public, non-import API symbol.
114
115 Excludes:
116 - Import pseudo-symbols (``::import::*``) — dependency management, not API.
117 - Private names starting with ``_``.
118 """
119 if "::import::" in address:
120 return False
121 name = address.split("::")[-1] if "::" in address else address
122 return not name.startswith("_")
123
124 def _assess_semver(ops: list[DomainOp]) -> tuple[str, list[str]]:
125 """Return (bump_level, reasons) from the diff ops.
126
127 Rules applied to public symbols only (names not starting with ``_``):
128
129 MAJOR — symbol removed, or signature / rename / move change.
130 MINOR — symbol added (no MAJOR triggers).
131 PATCH — only implementation changes.
132 NONE — empty diff.
133 """
134 reasons: list[str] = []
135 has_major = False
136 has_minor = False
137 has_patch = False
138
139 for op in ops:
140 if op["op"] == "patch":
141 for child in op["child_ops"]:
142 addr: str = child["address"]
143 if not _is_public_name(addr):
144 continue
145 if child["op"] == "delete":
146 has_major = True
147 reasons.append(f"removed: {addr}")
148 elif child["op"] == "insert":
149 has_minor = True
150 elif child["op"] == "replace":
151 ns: str = child["new_summary"]
152 if any(kw in ns for kw in ("signature", "renamed", "moved")):
153 has_major = True
154 reasons.append(f"breaking: {addr} — {ns}")
155 else:
156 has_patch = True
157 elif op["op"] == "delete":
158 addr2: str = op["address"]
159 if _is_public_name(addr2):
160 has_major = True
161 reasons.append(f"file removed: {addr2}")
162 elif op["op"] == "insert":
163 if _is_public_name(op["address"]):
164 has_minor = True
165
166 if has_major:
167 return "MAJOR", reasons
168 if has_minor:
169 return "MINOR", []
170 if has_patch:
171 return "PATCH", []
172 return "NONE", []
173
174 def _count_ops(ops: list[DomainOp]) -> tuple[int, int, int, int]:
175 """Return (added, removed, modified, files_changed) from diff ops."""
176 added = removed = modified = 0
177 files: set[str] = set()
178 for op in ops:
179 if op["op"] == "patch":
180 if op["child_ops"]:
181 files.add(op["address"])
182 for child in op["child_ops"]:
183 if child["op"] == "insert":
184 added += 1
185 elif child["op"] == "delete":
186 removed += 1
187 elif child["op"] == "replace":
188 modified += 1
189 elif op["op"] == "insert":
190 files.add(op["address"])
191 added += 1
192 elif op["op"] == "delete":
193 files.add(op["address"])
194 removed += 1
195 elif op["op"] == "replace":
196 files.add(op["address"])
197 modified += 1
198 return added, removed, modified, len(files)
199
200 def _format_child_op(op: DomainOp) -> str:
201 """Return a compact one-line description of a symbol-level op."""
202 addr: str = op["address"]
203 name = addr.split("::")[-1] if "::" in addr else addr
204 if op["op"] == "insert":
205 summary: str = op["content_summary"]
206 moved = (
207 f" (moved from {summary.split('moved from')[-1].strip()})"
208 if "moved from" in summary else ""
209 )
210 return f" added {name}{moved}"
211 if op["op"] == "delete":
212 summary2: str = op["content_summary"]
213 moved2 = (
214 f" (moved to {summary2.split('moved to')[-1].strip()})"
215 if "moved to" in summary2 else ""
216 )
217 return f" removed {name}{moved2}"
218 if op["op"] == "replace":
219 ns: str = op["new_summary"]
220 detail = f" ({ns})" if ns else ""
221 return f" modified {name}{detail}"
222 return f" changed {name}"
223
224 def _flatten_ops(ops: list[DomainOp]) -> list[_OpSummary]:
225 """Flatten all ops to a serialisable summary list."""
226 result: list[_OpSummary] = []
227 for op in ops:
228 if op["op"] == "patch":
229 for child in op["child_ops"]:
230 if child["op"] == "insert":
231 detail: str = child["content_summary"]
232 elif child["op"] == "delete":
233 detail = child["content_summary"]
234 elif child["op"] == "replace":
235 detail = child["new_summary"]
236 else:
237 detail = ""
238 result.append(_OpSummary(
239 op=child["op"],
240 address=child["address"],
241 detail=detail,
242 ))
243 elif op["op"] == "insert":
244 result.append(_OpSummary(op="insert", address=op["address"], detail=op["content_summary"]))
245 elif op["op"] == "delete":
246 result.append(_OpSummary(op="delete", address=op["address"], detail=op["content_summary"]))
247 elif op["op"] == "replace":
248 result.append(_OpSummary(op="replace", address=op["address"], detail=op["new_summary"]))
249 else:
250 result.append(_OpSummary(op=op["op"], address=op["address"], detail=""))
251 return result
252
253 def _filter_manifest_by_language(
254 manifest: Manifest, language: str
255 ) -> Manifest:
256 return {p: h for p, h in manifest.items() if language_of(p) == language}
257
258 def _filter_ops_by_file(ops: list[DomainOp], file_filter: str) -> list[DomainOp]:
259 """Keep only ops whose address is or ends with *file_filter*."""
260 return [
261 op for op in ops
262 if op.get("address", "") == file_filter
263 or op.get("address", "").endswith(f"/{file_filter}")
264 ]
265
266 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
267 """Register the compare subcommand."""
268 parser = subparsers.add_parser(
269 "compare",
270 help="Deep semantic comparison between any two historical snapshots.",
271 description=__doc__,
272 formatter_class=argparse.RawDescriptionHelpFormatter,
273 )
274 parser.add_argument("ref_a", metavar="REF-A", help="Base commit (older).")
275 parser.add_argument("ref_b", metavar="REF-B", help="Target commit (newer).")
276 parser.add_argument(
277 "--kind", "-k", default=None, metavar="KIND", dest="kind_filter",
278 help="Restrict to symbols of this kind (function, class, method, …).",
279 )
280 parser.add_argument(
281 "--file", "-f", default=None, metavar="FILE", dest="file_filter",
282 help="Only show changes in this file (accepts a path suffix, e.g. 'billing.py').",
283 )
284 parser.add_argument(
285 "--language", "-l", default=None, metavar="LANG", dest="language_filter",
286 help="Only show changes in files of this language (case-insensitive).",
287 )
288 parser.add_argument(
289 "--stat",
290 action="store_true",
291 help="Print a summary (counts + SemVer impact) instead of the full listing.",
292 )
293 parser.add_argument(
294 "--semver",
295 action="store_true",
296 help=(
297 "Append a MAJOR / MINOR / PATCH recommendation based on public API changes: "
298 "symbol removed → MAJOR, symbol added → MINOR, impl-only → PATCH."
299 ),
300 )
301 parser.add_argument(
302 "--json", "-j", action="store_true", dest="json_out",
303 help="Emit results as JSON.",
304 )
305 parser.set_defaults(func=run)
306
307 def run(args: argparse.Namespace) -> None:
308 """Deep semantic comparison between any two historical snapshots.
309
310 Reads both commits from the object store, parses AST symbol trees for all
311 semantic files, and produces a full symbol-level delta: added, removed,
312 renamed, moved, and modified symbols. Use it to understand the semantic
313 scope of a release, sprint, or branch divergence at the function level.
314
315 Agent quickstart
316 ----------------
317 ::
318
319 muse code compare HEAD~10 HEAD --json
320 muse code compare v0.1.0 dev --kind function --json
321 muse code compare main feat/new --semver --json
322
323 JSON fields
324 -----------
325 ref_a First (older) ref.
326 ref_b Second (newer) ref.
327 added List of symbol addresses added in ref_b.
328 removed List of symbol addresses removed from ref_a.
329 modified List of symbol addresses present in both but changed.
330 renamed List of ``{from, to}`` rename objects.
331 moved List of ``{address, from_file, to_file}`` move objects.
332 total Total change count.
333 sem_ver_bump Inferred bump (with ``--semver``): ``"major"``, ``"minor"``,
334 ``"patch"``, or ``"none"``.
335 exit_code Integer exit code (see below).
336
337 Exit codes
338 ----------
339 0 Comparison complete.
340 1 Ref not found or invalid arguments.
341 2 Not inside a Muse repository.
342 """
343 elapsed = start_timer()
344 ref_a: str = args.ref_a
345 ref_b: str = args.ref_b
346 kind_filter: str | None = args.kind_filter
347 file_filter: str | None = args.file_filter
348 language_filter: str | None = args.language_filter
349 stat_only: bool = args.stat
350 show_semver: bool = args.semver
351 json_out: bool = args.json_out
352
353 if language_filter is not None:
354 language_filter = normalise_language(language_filter)
355 if kind_filter is not None:
356 kind_filter = kind_filter.strip().lower()
357
358 root = require_repo()
359 branch = read_current_branch(root)
360
361 commit_a = resolve_commit_ref(root, branch, ref_a)
362 if commit_a is None:
363 print(f"❌ Commit '{ref_a}' not found.", file=sys.stderr)
364 raise SystemExit(ExitCode.USER_ERROR)
365
366 commit_b = resolve_commit_ref(root, branch, ref_b)
367 if commit_b is None:
368 print(f"❌ Commit '{ref_b}' not found.", file=sys.stderr)
369 raise SystemExit(ExitCode.USER_ERROR)
370
371 manifest_a: Manifest = get_commit_snapshot_manifest(root, commit_a.commit_id) or {}
372 manifest_b: Manifest = get_commit_snapshot_manifest(root, commit_b.commit_id) or {}
373
374 # Apply language filter at the manifest level so cross-file move detection
375 # in build_diff_ops only considers the requested language.
376 if language_filter is not None:
377 manifest_a = _filter_manifest_by_language(manifest_a, language_filter)
378 manifest_b = _filter_manifest_by_language(manifest_b, language_filter)
379
380 # Load the symbol cache once and share it across both snapshot loads.
381 shared_cache = load_symbol_cache(root)
382
383 trees_a = symbols_for_snapshot(
384 root, manifest_a, kind_filter=kind_filter, cache=shared_cache
385 )
386 trees_b = symbols_for_snapshot(
387 root, manifest_b, kind_filter=kind_filter, cache=shared_cache
388 )
389
390 ops = build_diff_ops(manifest_a, manifest_b, trees_a, trees_b)
391
392 # Post-filter by --file (applied after build_diff_ops so cross-file move
393 # detection still runs on the full op set).
394 if file_filter is not None:
395 ops = _filter_ops_by_file(ops, file_filter)
396
397 semver_bump, semver_reasons = _assess_semver(ops)
398 added, removed, modified, files_changed = _count_ops(ops)
399 total_symbols = added + removed + modified
400
401 msg_a = _first_line(commit_a.message)
402 msg_b = _first_line(commit_b.message)
403
404 if json_out:
405 print(json.dumps(_CompareJson(**make_envelope(elapsed), **{
406 "from": _CommitRef(commit_id=commit_a.commit_id, message=msg_a),
407 "to": _CommitRef(commit_id=commit_b.commit_id, message=msg_b),
408 "filters": _CompareFilters(kind=kind_filter, file=file_filter, language=language_filter),
409 "stat": _CompareStat(
410 files_changed=files_changed,
411 symbols_added=added,
412 symbols_removed=removed,
413 symbols_modified=modified,
414 semver_impact=semver_bump,
415 ),
416 "ops": list(_flatten_ops(ops)),
417 })))
418 return
419
420 # ── Human-readable ────────────────────────────────────────────────────────
421
422 print("\nSemantic comparison")
423 print(f' From: {commit_a.commit_id} "{sanitize_display(msg_a)}"')
424 print(f' To: {commit_b.commit_id} "{sanitize_display(msg_b)}"')
425
426 if stat_only:
427 print(f"\n Files changed: {files_changed}")
428 print(f" Symbols added: {added}")
429 print(f" Symbols removed: {removed}")
430 print(f" Symbols modified: {modified}")
431 print(f" Net change: {added - removed:+d} symbols")
432 print(f" SemVer impact: {semver_bump}")
433 if semver_reasons:
434 for r in semver_reasons[:5]:
435 print(f" → {r}")
436 return
437
438 if not ops:
439 print("\n (no semantic changes between these two commits)")
440 return
441
442 for op in ops:
443 if op["op"] == "patch":
444 fp = op["address"]
445 child_ops = op.get("child_ops", [])
446 if not child_ops:
447 continue
448 is_new = fp not in manifest_a
449 is_gone = fp not in manifest_b
450 suffix = " (new file)" if is_new else (" (removed)" if is_gone else "")
451 print(f"\n{sanitize_display(fp)}{suffix}")
452 for child in child_ops:
453 print(_format_child_op(child))
454 else:
455 fp = op["address"]
456 if op["op"] == "insert":
457 print(f"\n{sanitize_display(fp)} (new file)")
458 print(f" added {sanitize_display(fp)} (file)")
459 elif op["op"] == "delete":
460 print(f"\n{sanitize_display(fp)} (removed)")
461 print(f" removed {sanitize_display(fp)} (file)")
462 else:
463 print(f"\n{sanitize_display(fp)}")
464 print(f" modified {sanitize_display(fp)} (file)")
465
466 print(f"\n{total_symbols} symbol change(s) across {files_changed} file(s)")
467
468 if show_semver or semver_bump in ("MAJOR",):
469 print(f"SemVer impact: {semver_bump}")
470 for r in semver_reasons[:5]:
471 print(f" → {r}")
File History 11 commits
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be Merge branch 'fix/hub-user-read-update-path' into dev Human 11 days ago
sha256:b7be56ec091919a612cffe7f3c8b600209d5155517443fdac0e16954c8c7d81f Merge 'task/git-export-ignored-file-deletion' into 'dev' — … Human 13 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 17 days ago
sha256:50bb615c573aa4b928fca75b60f82ba2a659910066507cec6a95c412ae22ccb9 docs: add issue docs for push have-negotiation bug (#55) an… Sonnet 4.6 20 days ago
sha256:d90d175cded68aae1d4ffcf4858917854195d0cd8ce1fe73cee4dbc02541cb74 chore: trigger push to surface null-OID paths Sonnet 4.6 26 days ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8 chore: prebuild timing test Sonnet 4.6 36 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1 merge: pull staging/dev — advance to 0.2.0rc12 Sonnet 4.6 patch 37 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 49 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 52 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 58 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 58 days ago