compare.py
python
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
26 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 | from __future__ import annotations |
| 47 | |
| 48 | import argparse |
| 49 | import json |
| 50 | import logging |
| 51 | import pathlib |
| 52 | import sys |
| 53 | from typing import TypedDict |
| 54 | |
| 55 | from muse.core._types import Manifest |
| 56 | from muse.core.errors import ExitCode |
| 57 | from muse.core.repo import read_repo_id, require_repo |
| 58 | from muse.core.store import get_commit_snapshot_manifest, read_current_branch, resolve_commit_ref |
| 59 | from muse.core.symbol_cache import load_symbol_cache |
| 60 | from muse.domain import DomainOp |
| 61 | from muse.plugins.code._query import _SUFFIX_LANG, language_of, symbols_for_snapshot |
| 62 | from muse.plugins.code.symbol_diff import build_diff_ops |
| 63 | from muse.core.validation import sanitize_display |
| 64 | from muse.plugins.registry import read_domain |
| 65 | |
| 66 | logger = logging.getLogger(__name__) |
| 67 | |
| 68 | # Language normalisation (case-insensitive --language matching). |
| 69 | _LANG_CANONICAL: Manifest = {lang.lower(): lang for lang in set(_SUFFIX_LANG.values())} |
| 70 | |
| 71 | |
| 72 | def _normalise_language(lang: str) -> str: |
| 73 | return _LANG_CANONICAL.get(lang.strip().lower(), lang.strip()) |
| 74 | |
| 75 | |
| 76 | class _OpSummary(TypedDict): |
| 77 | op: str |
| 78 | address: str |
| 79 | detail: str |
| 80 | |
| 81 | |
| 82 | class _CommitRef(TypedDict): |
| 83 | commit_id: str |
| 84 | message: str |
| 85 | |
| 86 | |
| 87 | class _CompareFilters(TypedDict): |
| 88 | kind: str | None |
| 89 | file: str | None |
| 90 | language: str | None |
| 91 | |
| 92 | |
| 93 | class _CompareStat(TypedDict): |
| 94 | files_changed: int |
| 95 | symbols_added: int |
| 96 | symbols_removed: int |
| 97 | symbols_modified: int |
| 98 | semver_impact: str |
| 99 | |
| 100 | |
| 101 | #: JSON payload for ``muse code compare`` — functional form because ``from`` is a keyword. |
| 102 | _CompareJson = TypedDict("_CompareJson", { |
| 103 | "from": _CommitRef, |
| 104 | "to": _CommitRef, |
| 105 | "filters": _CompareFilters, |
| 106 | "stat": _CompareStat, |
| 107 | "ops": list[_OpSummary], |
| 108 | }) |
| 109 | |
| 110 | |
| 111 | |
| 112 | # --------------------------------------------------------------------------- |
| 113 | # Knowtation-specific types and helpers (Phase 2.6) |
| 114 | # --------------------------------------------------------------------------- |
| 115 | |
| 116 | |
| 117 | class _KnowtationFrontmatterDiff(TypedDict, total=False): |
| 118 | """Per-note frontmatter delta summary for the Knowtation JSON shape.""" |
| 119 | tags_added: list[str] |
| 120 | tags_removed: list[str] |
| 121 | entity_added: list[str] |
| 122 | entity_removed: list[str] |
| 123 | scalar_changes: dict[str, object] |
| 124 | |
| 125 | |
| 126 | class _KnowtationSectionsDiff(TypedDict, total=False): |
| 127 | """Per-note section delta summary for the Knowtation JSON shape.""" |
| 128 | added: list[str] |
| 129 | removed: list[str] |
| 130 | modified: list[str] |
| 131 | moved: list[str] |
| 132 | |
| 133 | |
| 134 | class _KnowtationNoteOp(TypedDict, total=False): |
| 135 | """A single note-level change in the Knowtation compare JSON output.""" |
| 136 | op: str # "insert" | "delete" | "modify" |
| 137 | path: str |
| 138 | title: str |
| 139 | frontmatter: _KnowtationFrontmatterDiff |
| 140 | sections: _KnowtationSectionsDiff |
| 141 | body_lines_changed: int |
| 142 | |
| 143 | |
| 144 | class _KnowtationStat(TypedDict): |
| 145 | """Aggregate statistics for a Knowtation vault comparison.""" |
| 146 | notes_added: int |
| 147 | notes_removed: int |
| 148 | notes_modified: int |
| 149 | frontmatter_changes: int |
| 150 | section_changes: int |
| 151 | tag_changes: int |
| 152 | |
| 153 | |
| 154 | #: Functional TypedDict for Knowtation ``muse code compare --json`` output. |
| 155 | _KnowtationCompareJson = TypedDict("_KnowtationCompareJson", { |
| 156 | "from": _CommitRef, |
| 157 | "to": _CommitRef, |
| 158 | "domain": str, |
| 159 | "stat": _KnowtationStat, |
| 160 | "ops": list[_KnowtationNoteOp], |
| 161 | }) |
| 162 | |
| 163 | |
| 164 | def _note_title(content: bytes) -> str: |
| 165 | """Extract the title from note bytes (frontmatter > h1 > filename).""" |
| 166 | try: |
| 167 | from muse.plugins.knowtation.parser import parse_frontmatter |
| 168 | fm = parse_frontmatter(content) |
| 169 | if fm and fm.title: |
| 170 | return fm.title |
| 171 | except Exception: |
| 172 | pass |
| 173 | # Fall back to first H1 heading. |
| 174 | try: |
| 175 | text = content.decode("utf-8", errors="replace") |
| 176 | for line in text.splitlines(): |
| 177 | stripped = line.strip() |
| 178 | if stripped.startswith("# "): |
| 179 | return stripped[2:].strip() |
| 180 | except Exception: |
| 181 | pass |
| 182 | return "" |
| 183 | |
| 184 | |
| 185 | def _summarise_note_delta( |
| 186 | delta: "object", |
| 187 | ) -> tuple[_KnowtationFrontmatterDiff, _KnowtationSectionsDiff, int]: |
| 188 | """Extract a human-readable summary from a ``StructuredDelta``. |
| 189 | |
| 190 | Returns (frontmatter_diff, sections_diff, body_lines_changed). |
| 191 | """ |
| 192 | from muse.domain import DeleteOp, InsertOp, MoveOp, ReplaceOp |
| 193 | |
| 194 | fm_diff: _KnowtationFrontmatterDiff = {} |
| 195 | sec_diff: _KnowtationSectionsDiff = {} |
| 196 | body_lines = 0 |
| 197 | |
| 198 | if not isinstance(delta, dict): |
| 199 | return fm_diff, sec_diff, body_lines |
| 200 | |
| 201 | ops: list[dict] = delta.get("ops") or [] |
| 202 | |
| 203 | tags_added: list[str] = [] |
| 204 | tags_removed: list[str] = [] |
| 205 | entity_added: list[str] = [] |
| 206 | entity_removed: list[str] = [] |
| 207 | scalar_changes: dict[str, object] = {} |
| 208 | sec_added: list[str] = [] |
| 209 | sec_removed: list[str] = [] |
| 210 | sec_modified: list[str] = [] |
| 211 | sec_moved: list[str] = [] |
| 212 | |
| 213 | for op in ops: |
| 214 | op_type: str = op.get("op", "") |
| 215 | id_val: str = op.get("address", "") |
| 216 | |
| 217 | # Frontmatter set ops — id like "tags::alpha" or "entity::person" |
| 218 | if id_val.startswith("tags::"): |
| 219 | tag = id_val[len("tags::"):] |
| 220 | if op_type == "insert": |
| 221 | tags_added.append(tag) |
| 222 | elif op_type == "delete": |
| 223 | tags_removed.append(tag) |
| 224 | elif id_val.startswith("entity::"): |
| 225 | ent = id_val[len("entity::"):] |
| 226 | if op_type == "insert": |
| 227 | entity_added.append(ent) |
| 228 | elif op_type == "delete": |
| 229 | entity_removed.append(ent) |
| 230 | # Scalar frontmatter replace — id like "fm::project" |
| 231 | elif id_val.startswith("fm::") and op_type == "replace": |
| 232 | field = id_val[len("fm::"):] |
| 233 | scalar_changes[field] = op.get("new_content", "") |
| 234 | # Body line ops — address like "section:1:Background#0::line:3" |
| 235 | # Must be checked before the plain section: prefix. |
| 236 | elif "::line:" in id_val: |
| 237 | if op_type in ("insert", "delete", "replace"): |
| 238 | body_lines += 1 |
| 239 | # Section ops — address like "section:1:Background#0" |
| 240 | elif id_val.startswith("section:"): |
| 241 | section_name = id_val |
| 242 | if op_type == "insert": |
| 243 | sec_added.append(section_name) |
| 244 | elif op_type == "delete": |
| 245 | sec_removed.append(section_name) |
| 246 | elif op_type == "move": |
| 247 | sec_moved.append(section_name) |
| 248 | |
| 249 | if tags_added: |
| 250 | fm_diff["tags_added"] = sorted(tags_added) |
| 251 | if tags_removed: |
| 252 | fm_diff["tags_removed"] = sorted(tags_removed) |
| 253 | if entity_added: |
| 254 | fm_diff["entity_added"] = sorted(entity_added) |
| 255 | if entity_removed: |
| 256 | fm_diff["entity_removed"] = sorted(entity_removed) |
| 257 | if scalar_changes: |
| 258 | fm_diff["scalar_changes"] = scalar_changes |
| 259 | |
| 260 | if sec_added: |
| 261 | sec_diff["added"] = sec_added |
| 262 | if sec_removed: |
| 263 | sec_diff["removed"] = sec_removed |
| 264 | if sec_modified: |
| 265 | sec_diff["modified"] = sec_modified |
| 266 | if sec_moved: |
| 267 | sec_diff["moved"] = sec_moved |
| 268 | |
| 269 | return fm_diff, sec_diff, body_lines |
| 270 | |
| 271 | |
| 272 | def _run_knowtation_compare( |
| 273 | root: pathlib.Path, |
| 274 | commit_a: "object", |
| 275 | commit_b: "object", |
| 276 | manifest_a: Manifest, |
| 277 | manifest_b: Manifest, |
| 278 | as_json: bool, |
| 279 | stat_only: bool, |
| 280 | ) -> None: |
| 281 | """Knowtation-specific compare path for ``muse code compare``. |
| 282 | |
| 283 | Reads note content from the object store for modified notes, calls |
| 284 | ``diff_notes`` on each pair, and emits a vault-aware JSON/text comparison. |
| 285 | |
| 286 | Args: |
| 287 | root: Repository root. |
| 288 | commit_a: Resolved CommitRecord for the base (older) commit. |
| 289 | commit_b: Resolved CommitRecord for the target (newer) commit. |
| 290 | manifest_a: Content-hash manifest for the base commit. |
| 291 | manifest_b: Content-hash manifest for the target commit. |
| 292 | as_json: When True, emit JSON; otherwise emit human-readable text. |
| 293 | stat_only: When True, emit only aggregate statistics. |
| 294 | """ |
| 295 | from muse.core.object_store import read_object |
| 296 | from muse.plugins.knowtation.differ import diff_notes |
| 297 | from muse.plugins.knowtation._query import is_note |
| 298 | |
| 299 | all_paths: set[str] = set(manifest_a) | set(manifest_b) |
| 300 | note_paths: set[str] = {p for p in all_paths if is_note(p)} |
| 301 | |
| 302 | ops: list[_KnowtationNoteOp] = [] |
| 303 | notes_added = notes_removed = notes_modified = 0 |
| 304 | frontmatter_changes = section_changes = tag_changes = 0 |
| 305 | |
| 306 | for path in sorted(note_paths): |
| 307 | hash_a = manifest_a.get(path) |
| 308 | hash_b = manifest_b.get(path) |
| 309 | |
| 310 | if hash_a is None and hash_b is not None: |
| 311 | # Added in B |
| 312 | notes_added += 1 |
| 313 | content_b = read_object(root, hash_b) or b"" |
| 314 | op: _KnowtationNoteOp = { |
| 315 | "op": "insert", |
| 316 | "path": path, |
| 317 | "title": _note_title(content_b), |
| 318 | } |
| 319 | ops.append(op) |
| 320 | |
| 321 | elif hash_a is not None and hash_b is None: |
| 322 | # Removed in B |
| 323 | notes_removed += 1 |
| 324 | content_a = read_object(root, hash_a) or b"" |
| 325 | op = { |
| 326 | "op": "delete", |
| 327 | "path": path, |
| 328 | "title": _note_title(content_a), |
| 329 | } |
| 330 | ops.append(op) |
| 331 | |
| 332 | elif hash_a != hash_b and hash_a is not None and hash_b is not None: |
| 333 | # Modified |
| 334 | notes_modified += 1 |
| 335 | content_a = read_object(root, hash_a) or b"" |
| 336 | content_b = read_object(root, hash_b) or b"" |
| 337 | |
| 338 | try: |
| 339 | delta = diff_notes(content_a, content_b) |
| 340 | except Exception as exc: |
| 341 | logger.debug("diff_notes failed for %s: %s", path, exc) |
| 342 | op = {"op": "modify", "path": path, "title": _note_title(content_b)} |
| 343 | ops.append(op) |
| 344 | continue |
| 345 | |
| 346 | fm_diff, sec_diff, body_lines = _summarise_note_delta(delta) |
| 347 | |
| 348 | # Accumulate aggregate stats. |
| 349 | n_tag_changes = len(fm_diff.get("tags_added", [])) + len(fm_diff.get("tags_removed", [])) |
| 350 | n_fm_changes = n_tag_changes + len(fm_diff.get("entity_added", [])) + \ |
| 351 | len(fm_diff.get("entity_removed", [])) + len(fm_diff.get("scalar_changes", {})) |
| 352 | n_sec_changes = len(sec_diff.get("added", [])) + len(sec_diff.get("removed", [])) + \ |
| 353 | len(sec_diff.get("modified", [])) + len(sec_diff.get("moved", [])) |
| 354 | frontmatter_changes += n_fm_changes |
| 355 | section_changes += n_sec_changes |
| 356 | tag_changes += n_tag_changes |
| 357 | |
| 358 | note_op: _KnowtationNoteOp = { |
| 359 | "op": "modify", |
| 360 | "path": path, |
| 361 | "title": _note_title(content_b), |
| 362 | "body_lines_changed": body_lines, |
| 363 | } |
| 364 | if fm_diff: |
| 365 | note_op["frontmatter"] = fm_diff |
| 366 | if sec_diff: |
| 367 | note_op["sections"] = sec_diff |
| 368 | ops.append(note_op) |
| 369 | |
| 370 | stat: _KnowtationStat = { |
| 371 | "notes_added": notes_added, |
| 372 | "notes_removed": notes_removed, |
| 373 | "notes_modified": notes_modified, |
| 374 | "frontmatter_changes": frontmatter_changes, |
| 375 | "section_changes": section_changes, |
| 376 | "tag_changes": tag_changes, |
| 377 | } |
| 378 | |
| 379 | msg_a = _first_line(commit_a.message) |
| 380 | msg_b = _first_line(commit_b.message) |
| 381 | |
| 382 | if as_json: |
| 383 | out = _KnowtationCompareJson(**{ |
| 384 | "from": _CommitRef(commit_id=commit_a.commit_id, message=msg_a), |
| 385 | "to": _CommitRef(commit_id=commit_b.commit_id, message=msg_b), |
| 386 | "domain": "knowtation", |
| 387 | "stat": stat, |
| 388 | "ops": ops, |
| 389 | }) |
| 390 | print(json.dumps(out, indent=2)) |
| 391 | return |
| 392 | |
| 393 | # ── Human-readable vault comparison ───────────────────────────────────── |
| 394 | total_notes = notes_added + notes_removed + notes_modified |
| 395 | print("\nVault comparison (domain: knowtation)") |
| 396 | print(f' From: {commit_a.commit_id[:8]} "{sanitize_display(msg_a)}"') |
| 397 | print(f' To: {commit_b.commit_id[:8]} "{sanitize_display(msg_b)}"') |
| 398 | |
| 399 | if stat_only: |
| 400 | print(f"\n Notes added: {notes_added}") |
| 401 | print(f" Notes removed: {notes_removed}") |
| 402 | print(f" Notes modified: {notes_modified}") |
| 403 | print(f" Frontmatter changes: {frontmatter_changes}") |
| 404 | print(f" Section changes: {section_changes}") |
| 405 | print(f" Tag changes: {tag_changes}") |
| 406 | return |
| 407 | |
| 408 | if not ops: |
| 409 | print("\n (no note changes between these two commits)") |
| 410 | return |
| 411 | |
| 412 | for note_op in ops: |
| 413 | op_label = note_op["op"] |
| 414 | note_path = note_op["path"] |
| 415 | note_title = note_op.get("title", "") |
| 416 | title_str = f' "{sanitize_display(note_title)}"' if note_title else "" |
| 417 | |
| 418 | if op_label == "insert": |
| 419 | print(f"\n added {sanitize_display(note_path)}{title_str}") |
| 420 | elif op_label == "delete": |
| 421 | print(f"\n removed {sanitize_display(note_path)}{title_str}") |
| 422 | else: |
| 423 | print(f"\n modified {sanitize_display(note_path)}{title_str}") |
| 424 | fm = note_op.get("frontmatter", {}) |
| 425 | sec = note_op.get("sections", {}) |
| 426 | body = note_op.get("body_lines_changed", 0) |
| 427 | if fm.get("tags_added"): |
| 428 | print(f" tags+: {', '.join(fm['tags_added'])}") |
| 429 | if fm.get("tags_removed"): |
| 430 | print(f" tags-: {', '.join(fm['tags_removed'])}") |
| 431 | if sec.get("added"): |
| 432 | print(f" §+: {', '.join(sec['added'])}") |
| 433 | if sec.get("removed"): |
| 434 | print(f" §-: {', '.join(sec['removed'])}") |
| 435 | if body: |
| 436 | print(f" ~{body} line(s) changed") |
| 437 | |
| 438 | print(f"\n{total_notes} note change(s) across {notes_added + notes_removed + notes_modified} note(s)") |
| 439 | |
| 440 | |
| 441 | def _first_line(message: str) -> str: |
| 442 | """Return the first non-empty line of a commit message.""" |
| 443 | for line in message.splitlines(): |
| 444 | stripped = line.strip() |
| 445 | if stripped: |
| 446 | return stripped |
| 447 | return message.strip() |
| 448 | |
| 449 | |
| 450 | def _is_public_name(address: str) -> bool: |
| 451 | """Return True if the symbol is a public, non-import API symbol. |
| 452 | |
| 453 | Excludes: |
| 454 | - Import pseudo-symbols (``::import::*``) — dependency management, not API. |
| 455 | - Private names starting with ``_``. |
| 456 | """ |
| 457 | if "::import::" in address: |
| 458 | return False |
| 459 | name = address.split("::")[-1] if "::" in address else address |
| 460 | return not name.startswith("_") |
| 461 | |
| 462 | |
| 463 | def _assess_semver(ops: list[DomainOp]) -> tuple[str, list[str]]: |
| 464 | """Return (bump_level, reasons) from the diff ops. |
| 465 | |
| 466 | Rules applied to public symbols only (names not starting with ``_``): |
| 467 | |
| 468 | MAJOR — symbol removed, or signature / rename / move change. |
| 469 | MINOR — symbol added (no MAJOR triggers). |
| 470 | PATCH — only implementation changes. |
| 471 | NONE — empty diff. |
| 472 | """ |
| 473 | reasons: list[str] = [] |
| 474 | has_major = False |
| 475 | has_minor = False |
| 476 | has_patch = False |
| 477 | |
| 478 | for op in ops: |
| 479 | if op["op"] == "patch": |
| 480 | for child in op["child_ops"]: |
| 481 | addr: str = child["address"] |
| 482 | if not _is_public_name(addr): |
| 483 | continue |
| 484 | if child["op"] == "delete": |
| 485 | has_major = True |
| 486 | reasons.append(f"removed: {addr}") |
| 487 | elif child["op"] == "insert": |
| 488 | has_minor = True |
| 489 | elif child["op"] == "replace": |
| 490 | ns: str = child["new_summary"] |
| 491 | if any(kw in ns for kw in ("signature", "renamed", "moved")): |
| 492 | has_major = True |
| 493 | reasons.append(f"breaking: {addr} — {ns}") |
| 494 | else: |
| 495 | has_patch = True |
| 496 | elif op["op"] == "delete": |
| 497 | addr2: str = op["address"] |
| 498 | if _is_public_name(addr2): |
| 499 | has_major = True |
| 500 | reasons.append(f"file removed: {addr2}") |
| 501 | elif op["op"] == "insert": |
| 502 | if _is_public_name(op["address"]): |
| 503 | has_minor = True |
| 504 | |
| 505 | if has_major: |
| 506 | return "MAJOR", reasons |
| 507 | if has_minor: |
| 508 | return "MINOR", [] |
| 509 | if has_patch: |
| 510 | return "PATCH", [] |
| 511 | return "NONE", [] |
| 512 | |
| 513 | |
| 514 | def _count_ops(ops: list[DomainOp]) -> tuple[int, int, int, int]: |
| 515 | """Return (added, removed, modified, files_changed) from diff ops.""" |
| 516 | added = removed = modified = 0 |
| 517 | files: set[str] = set() |
| 518 | for op in ops: |
| 519 | if op["op"] == "patch": |
| 520 | if op["child_ops"]: |
| 521 | files.add(op["address"]) |
| 522 | for child in op["child_ops"]: |
| 523 | if child["op"] == "insert": |
| 524 | added += 1 |
| 525 | elif child["op"] == "delete": |
| 526 | removed += 1 |
| 527 | elif child["op"] == "replace": |
| 528 | modified += 1 |
| 529 | elif op["op"] == "insert": |
| 530 | files.add(op["address"]) |
| 531 | added += 1 |
| 532 | elif op["op"] == "delete": |
| 533 | files.add(op["address"]) |
| 534 | removed += 1 |
| 535 | elif op["op"] == "replace": |
| 536 | files.add(op["address"]) |
| 537 | modified += 1 |
| 538 | return added, removed, modified, len(files) |
| 539 | |
| 540 | |
| 541 | def _format_child_op(op: DomainOp) -> str: |
| 542 | """Return a compact one-line description of a symbol-level op.""" |
| 543 | addr: str = op["address"] |
| 544 | name = addr.split("::")[-1] if "::" in addr else addr |
| 545 | if op["op"] == "insert": |
| 546 | summary: str = op["content_summary"] |
| 547 | moved = ( |
| 548 | f" (moved from {summary.split('moved from')[-1].strip()})" |
| 549 | if "moved from" in summary else "" |
| 550 | ) |
| 551 | return f" added {name}{moved}" |
| 552 | if op["op"] == "delete": |
| 553 | summary2: str = op["content_summary"] |
| 554 | moved2 = ( |
| 555 | f" (moved to {summary2.split('moved to')[-1].strip()})" |
| 556 | if "moved to" in summary2 else "" |
| 557 | ) |
| 558 | return f" removed {name}{moved2}" |
| 559 | if op["op"] == "replace": |
| 560 | ns: str = op["new_summary"] |
| 561 | detail = f" ({ns})" if ns else "" |
| 562 | return f" modified {name}{detail}" |
| 563 | return f" changed {name}" |
| 564 | |
| 565 | |
| 566 | def _flatten_ops(ops: list[DomainOp]) -> list[_OpSummary]: |
| 567 | """Flatten all ops to a serialisable summary list.""" |
| 568 | result: list[_OpSummary] = [] |
| 569 | for op in ops: |
| 570 | if op["op"] == "patch": |
| 571 | for child in op["child_ops"]: |
| 572 | if child["op"] == "insert": |
| 573 | detail: str = child["content_summary"] |
| 574 | elif child["op"] == "delete": |
| 575 | detail = child["content_summary"] |
| 576 | elif child["op"] == "replace": |
| 577 | detail = child["new_summary"] |
| 578 | else: |
| 579 | detail = "" |
| 580 | result.append(_OpSummary( |
| 581 | op=child["op"], |
| 582 | address=child["address"], |
| 583 | detail=detail, |
| 584 | )) |
| 585 | elif op["op"] == "insert": |
| 586 | result.append(_OpSummary(op="insert", address=op["address"], detail=op["content_summary"])) |
| 587 | elif op["op"] == "delete": |
| 588 | result.append(_OpSummary(op="delete", address=op["address"], detail=op["content_summary"])) |
| 589 | elif op["op"] == "replace": |
| 590 | result.append(_OpSummary(op="replace", address=op["address"], detail=op["new_summary"])) |
| 591 | else: |
| 592 | result.append(_OpSummary(op=op["op"], address=op["address"], detail="")) |
| 593 | return result |
| 594 | |
| 595 | |
| 596 | def _filter_manifest_by_language( |
| 597 | manifest: Manifest, language: str |
| 598 | ) -> Manifest: |
| 599 | return {p: h for p, h in manifest.items() if language_of(p) == language} |
| 600 | |
| 601 | |
| 602 | def _filter_ops_by_file(ops: list[DomainOp], file_filter: str) -> list[DomainOp]: |
| 603 | """Keep only ops whose address is or ends with *file_filter*.""" |
| 604 | return [ |
| 605 | op for op in ops |
| 606 | if op.get("address", "") == file_filter |
| 607 | or op.get("address", "").endswith("/" + file_filter) |
| 608 | ] |
| 609 | |
| 610 | |
| 611 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 612 | """Register the compare subcommand.""" |
| 613 | parser = subparsers.add_parser( |
| 614 | "compare", |
| 615 | help="Deep semantic comparison between any two historical snapshots.", |
| 616 | description=__doc__, |
| 617 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 618 | ) |
| 619 | parser.add_argument("ref_a", metavar="REF-A", help="Base commit (older).") |
| 620 | parser.add_argument("ref_b", metavar="REF-B", help="Target commit (newer).") |
| 621 | parser.add_argument( |
| 622 | "--kind", "-k", default=None, metavar="KIND", dest="kind_filter", |
| 623 | help="Restrict to symbols of this kind (function, class, method, …).", |
| 624 | ) |
| 625 | parser.add_argument( |
| 626 | "--file", "-f", default=None, metavar="FILE", dest="file_filter", |
| 627 | help="Only show changes in this file (accepts a path suffix, e.g. 'billing.py').", |
| 628 | ) |
| 629 | parser.add_argument( |
| 630 | "--language", "-l", default=None, metavar="LANG", dest="language_filter", |
| 631 | help="Only show changes in files of this language (case-insensitive).", |
| 632 | ) |
| 633 | parser.add_argument( |
| 634 | "--stat", |
| 635 | action="store_true", |
| 636 | help="Print a summary (counts + SemVer impact) instead of the full listing.", |
| 637 | ) |
| 638 | parser.add_argument( |
| 639 | "--semver", |
| 640 | action="store_true", |
| 641 | help=( |
| 642 | "Append a MAJOR / MINOR / PATCH recommendation based on public API changes: " |
| 643 | "symbol removed → MAJOR, symbol added → MINOR, impl-only → PATCH." |
| 644 | ), |
| 645 | ) |
| 646 | parser.add_argument( |
| 647 | "--json", action="store_true", dest="as_json", |
| 648 | help="Emit results as JSON.", |
| 649 | ) |
| 650 | parser.set_defaults(func=run) |
| 651 | |
| 652 | |
| 653 | def run(args: argparse.Namespace) -> None: |
| 654 | """Deep semantic comparison between any two historical snapshots. |
| 655 | |
| 656 | ``muse code compare`` reads both commits from the object store, parses AST |
| 657 | symbol trees for all semantic files, and produces a full symbol-level |
| 658 | delta: which functions were added, removed, renamed, moved, and modified |
| 659 | between these two points. |
| 660 | |
| 661 | Use it to understand the semantic scope of a release, a sprint, or a |
| 662 | branch divergence — at the function level, not the line level. |
| 663 | """ |
| 664 | ref_a: str = args.ref_a |
| 665 | ref_b: str = args.ref_b |
| 666 | kind_filter: str | None = args.kind_filter |
| 667 | file_filter: str | None = args.file_filter |
| 668 | language_filter: str | None = args.language_filter |
| 669 | stat_only: bool = args.stat |
| 670 | show_semver: bool = args.semver |
| 671 | as_json: bool = args.as_json |
| 672 | |
| 673 | if language_filter is not None: |
| 674 | language_filter = _normalise_language(language_filter) |
| 675 | if kind_filter is not None: |
| 676 | kind_filter = kind_filter.strip().lower() |
| 677 | |
| 678 | root = require_repo() |
| 679 | repo_id = read_repo_id(root) |
| 680 | branch = read_current_branch(root) |
| 681 | |
| 682 | commit_a = resolve_commit_ref(root, repo_id, branch, ref_a) |
| 683 | if commit_a is None: |
| 684 | print(f"❌ Commit '{ref_a}' not found.", file=sys.stderr) |
| 685 | raise SystemExit(ExitCode.USER_ERROR) |
| 686 | |
| 687 | commit_b = resolve_commit_ref(root, repo_id, branch, ref_b) |
| 688 | if commit_b is None: |
| 689 | print(f"❌ Commit '{ref_b}' not found.", file=sys.stderr) |
| 690 | raise SystemExit(ExitCode.USER_ERROR) |
| 691 | |
| 692 | manifest_a: Manifest = get_commit_snapshot_manifest(root, commit_a.commit_id) or {} |
| 693 | manifest_b: Manifest = get_commit_snapshot_manifest(root, commit_b.commit_id) or {} |
| 694 | |
| 695 | # Knowtation domain: use vault-aware note comparison instead of symbol diff. |
| 696 | try: |
| 697 | domain = read_domain(root) |
| 698 | except Exception: |
| 699 | domain = "code" |
| 700 | |
| 701 | if domain == "knowtation": |
| 702 | _run_knowtation_compare( |
| 703 | root=root, |
| 704 | commit_a=commit_a, |
| 705 | commit_b=commit_b, |
| 706 | manifest_a=manifest_a, |
| 707 | manifest_b=manifest_b, |
| 708 | as_json=as_json, |
| 709 | stat_only=stat_only, |
| 710 | ) |
| 711 | return |
| 712 | |
| 713 | # Apply language filter at the manifest level so cross-file move detection |
| 714 | # in build_diff_ops only considers the requested language. |
| 715 | if language_filter is not None: |
| 716 | manifest_a = _filter_manifest_by_language(manifest_a, language_filter) |
| 717 | manifest_b = _filter_manifest_by_language(manifest_b, language_filter) |
| 718 | |
| 719 | # Load the symbol cache once and share it across both snapshot loads. |
| 720 | shared_cache = load_symbol_cache(root) |
| 721 | |
| 722 | trees_a = symbols_for_snapshot( |
| 723 | root, manifest_a, kind_filter=kind_filter, cache=shared_cache |
| 724 | ) |
| 725 | trees_b = symbols_for_snapshot( |
| 726 | root, manifest_b, kind_filter=kind_filter, cache=shared_cache |
| 727 | ) |
| 728 | |
| 729 | ops = build_diff_ops(manifest_a, manifest_b, trees_a, trees_b) |
| 730 | |
| 731 | # Post-filter by --file (applied after build_diff_ops so cross-file move |
| 732 | # detection still runs on the full op set). |
| 733 | if file_filter is not None: |
| 734 | ops = _filter_ops_by_file(ops, file_filter) |
| 735 | |
| 736 | semver_bump, semver_reasons = _assess_semver(ops) |
| 737 | added, removed, modified, files_changed = _count_ops(ops) |
| 738 | total_symbols = added + removed + modified |
| 739 | |
| 740 | msg_a = _first_line(commit_a.message) |
| 741 | msg_b = _first_line(commit_b.message) |
| 742 | |
| 743 | if as_json: |
| 744 | out = _CompareJson(**{ |
| 745 | "from": _CommitRef(commit_id=commit_a.commit_id, message=msg_a), |
| 746 | "to": _CommitRef(commit_id=commit_b.commit_id, message=msg_b), |
| 747 | "filters": _CompareFilters(kind=kind_filter, file=file_filter, language=language_filter), |
| 748 | "stat": _CompareStat( |
| 749 | files_changed=files_changed, |
| 750 | symbols_added=added, |
| 751 | symbols_removed=removed, |
| 752 | symbols_modified=modified, |
| 753 | semver_impact=semver_bump, |
| 754 | ), |
| 755 | "ops": list(_flatten_ops(ops)), |
| 756 | }) |
| 757 | print(json.dumps(out, indent=2)) |
| 758 | return |
| 759 | |
| 760 | # ── Human-readable ──────────────────────────────────────────────────────── |
| 761 | |
| 762 | print("\nSemantic comparison") |
| 763 | print(f' From: {commit_a.commit_id[:8]} "{sanitize_display(msg_a)}"') |
| 764 | print(f' To: {commit_b.commit_id[:8]} "{sanitize_display(msg_b)}"') |
| 765 | |
| 766 | if stat_only: |
| 767 | print(f"\n Files changed: {files_changed}") |
| 768 | print(f" Symbols added: {added}") |
| 769 | print(f" Symbols removed: {removed}") |
| 770 | print(f" Symbols modified: {modified}") |
| 771 | print(f" Net change: {added - removed:+d} symbols") |
| 772 | print(f" SemVer impact: {semver_bump}") |
| 773 | if semver_reasons: |
| 774 | for r in semver_reasons[:5]: |
| 775 | print(f" → {r}") |
| 776 | return |
| 777 | |
| 778 | if not ops: |
| 779 | print("\n (no semantic changes between these two commits)") |
| 780 | return |
| 781 | |
| 782 | for op in ops: |
| 783 | if op["op"] == "patch": |
| 784 | fp = op["address"] |
| 785 | child_ops = op.get("child_ops", []) |
| 786 | if not child_ops: |
| 787 | continue |
| 788 | is_new = fp not in manifest_a |
| 789 | is_gone = fp not in manifest_b |
| 790 | suffix = " (new file)" if is_new else (" (removed)" if is_gone else "") |
| 791 | print(f"\n{sanitize_display(fp)}{suffix}") |
| 792 | for child in child_ops: |
| 793 | print(_format_child_op(child)) |
| 794 | else: |
| 795 | fp = op["address"] |
| 796 | if op["op"] == "insert": |
| 797 | print(f"\n{sanitize_display(fp)} (new file)") |
| 798 | print(f" added {sanitize_display(fp)} (file)") |
| 799 | elif op["op"] == "delete": |
| 800 | print(f"\n{sanitize_display(fp)} (removed)") |
| 801 | print(f" removed {sanitize_display(fp)} (file)") |
| 802 | else: |
| 803 | print(f"\n{sanitize_display(fp)}") |
| 804 | print(f" modified {sanitize_display(fp)} (file)") |
| 805 | |
| 806 | print(f"\n{total_symbols} symbol change(s) across {files_changed} file(s)") |
| 807 | |
| 808 | if show_semver or semver_bump in ("MAJOR",): |
| 809 | print(f"SemVer impact: {semver_bump}") |
| 810 | for r in semver_reasons[:5]: |
| 811 | print(f" → {r}") |
File History
5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e
Merge branch 'dev' into main
Human
29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce
Merge branch 'dev' into main
Human
48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa
feat: Muse — version control for the agent era
Human
101 days ago