gabriel / muse public
compare.py python
811 lines 28.7 KB
Raw
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