cgcardona / muse public
compare.py python
219 lines 7.9 KB
2535fc53 feat(code): supercharge code plugin with 9 new semantic commands Gabriel Cardona <gabriel@tellurstori.com> 1d ago
1 """muse compare — semantic comparison between any two historical snapshots.
2
3 ``muse diff`` compares the working tree to HEAD. ``muse compare`` compares
4 any two historical commits — a full semantic diff between a release tag and
5 the current HEAD, between the start and end of a sprint, between two branches.
6
7 Usage::
8
9 muse compare HEAD~10 HEAD
10 muse compare v1.0 v2.0
11 muse compare a3f2c9 cb4afa
12 muse compare main feature/auth --kind class
13
14 Output::
15
16 Semantic comparison
17 From: a3f2c9e1 "Add billing module"
18 To: cb4afaed "Merge: release v1.0"
19
20 src/billing.py
21 added compute_invoice_total (renamed from calculate_total)
22 modified Invoice.to_dict (signature changed)
23 moved validate_amount → src/validation.py
24
25 src/validation.py (new file)
26 added validate_amount (moved from src/billing.py)
27
28 api/server.go (new file)
29 added HandleRequest
30 added process
31
32 7 symbol changes across 3 files
33 """
34 from __future__ import annotations
35
36 import json
37 import logging
38 import pathlib
39 from typing import TypedDict
40
41 import typer
42
43 from muse.core.errors import ExitCode
44 from muse.core.repo import require_repo
45 from muse.core.store import get_commit_snapshot_manifest, resolve_commit_ref
46 from muse.domain import DomainOp
47 from muse.plugins.code._query import language_of, symbols_for_snapshot
48 from muse.plugins.code.symbol_diff import build_diff_ops
49
50 logger = logging.getLogger(__name__)
51
52 app = typer.Typer()
53
54
55 class _OpSummary(TypedDict):
56 op: str
57 address: str
58 detail: str
59
60
61 def _read_repo_id(root: pathlib.Path) -> str:
62 return str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
63
64
65 def _read_branch(root: pathlib.Path) -> str:
66 head_ref = (root / ".muse" / "HEAD").read_text().strip()
67 return head_ref.removeprefix("refs/heads/").strip()
68
69
70 def _format_child_op(op: DomainOp) -> str:
71 """Return a compact one-line description of a symbol-level op."""
72 addr = op["address"]
73 name = addr.split("::")[-1] if "::" in addr else addr
74 if op["op"] == "insert":
75 summary = op.get("content_summary", "")
76 moved = (
77 f" (moved from {summary.split('moved from')[-1].strip()})"
78 if "moved from" in summary else ""
79 )
80 return f" added {name}{moved}"
81 if op["op"] == "delete":
82 summary = op.get("content_summary", "")
83 moved = (
84 f" (moved to {summary.split('moved to')[-1].strip()})"
85 if "moved to" in summary else ""
86 )
87 return f" removed {name}{moved}"
88 if op["op"] == "replace":
89 ns: str = op.get("new_summary", "")
90 detail = f" ({ns})" if ns else ""
91 return f" modified {name}{detail}"
92 return f" changed {name}"
93
94
95 def _flatten_ops(ops: list[DomainOp]) -> list[_OpSummary]:
96 """Flatten all ops to a serialisable summary list."""
97 result: list[_OpSummary] = []
98 for op in ops:
99 if op["op"] == "patch":
100 for child in op["child_ops"]:
101 if child["op"] == "insert":
102 detail: str = child["content_summary"]
103 elif child["op"] == "delete":
104 detail = child["content_summary"]
105 elif child["op"] == "replace":
106 detail = child["new_summary"]
107 else:
108 detail = ""
109 result.append(_OpSummary(
110 op=child["op"],
111 address=child["address"],
112 detail=detail,
113 ))
114 elif op["op"] == "insert":
115 result.append(_OpSummary(op="insert", address=op["address"], detail=op["content_summary"]))
116 elif op["op"] == "delete":
117 result.append(_OpSummary(op="delete", address=op["address"], detail=op["content_summary"]))
118 elif op["op"] == "replace":
119 result.append(_OpSummary(op="replace", address=op["address"], detail=op["new_summary"]))
120 else:
121 result.append(_OpSummary(op=op["op"], address=op["address"], detail=""))
122 return result
123
124
125 @app.callback(invoke_without_command=True)
126 def compare(
127 ctx: typer.Context,
128 ref_a: str = typer.Argument(..., metavar="REF-A", help="Base commit (older)."),
129 ref_b: str = typer.Argument(..., metavar="REF-B", help="Target commit (newer)."),
130 kind_filter: str | None = typer.Option(
131 None, "--kind", "-k", metavar="KIND",
132 help="Restrict to symbols of this kind (function, class, method, …).",
133 ),
134 as_json: bool = typer.Option(False, "--json", help="Emit results as JSON."),
135 ) -> None:
136 """Deep semantic comparison between any two historical snapshots.
137
138 ``muse compare`` is the two-point historical version of ``muse diff``.
139 It reads both commits from the object store, parses AST symbol trees for
140 all semantic files, and produces a full symbol-level delta: which functions
141 were added, removed, renamed, moved, and modified between these two points.
142
143 Use it to understand the semantic scope of a release, a sprint, or a
144 branch divergence — at the function level, not the line level.
145 """
146 root = require_repo()
147 repo_id = _read_repo_id(root)
148 branch = _read_branch(root)
149
150 commit_a = resolve_commit_ref(root, repo_id, branch, ref_a)
151 if commit_a is None:
152 typer.echo(f"❌ Commit '{ref_a}' not found.", err=True)
153 raise typer.Exit(code=ExitCode.USER_ERROR)
154
155 commit_b = resolve_commit_ref(root, repo_id, branch, ref_b)
156 if commit_b is None:
157 typer.echo(f"❌ Commit '{ref_b}' not found.", err=True)
158 raise typer.Exit(code=ExitCode.USER_ERROR)
159
160 # get_commit_snapshot_manifest returns a flat dict[str, str] of path → sha256.
161 manifest_a: dict[str, str] = get_commit_snapshot_manifest(root, commit_a.commit_id) or {}
162 manifest_b: dict[str, str] = get_commit_snapshot_manifest(root, commit_b.commit_id) or {}
163
164 trees_a = symbols_for_snapshot(root, manifest_a, kind_filter=kind_filter)
165 trees_b = symbols_for_snapshot(root, manifest_b, kind_filter=kind_filter)
166
167 ops = build_diff_ops(manifest_a, manifest_b, trees_a, trees_b)
168
169 if as_json:
170 typer.echo(json.dumps(
171 {
172 "from": {"commit_id": commit_a.commit_id, "message": commit_a.message},
173 "to": {"commit_id": commit_b.commit_id, "message": commit_b.message},
174 "ops": [dict(s) for s in _flatten_ops(ops)],
175 },
176 indent=2,
177 ))
178 return
179
180 typer.echo("\nSemantic comparison")
181 typer.echo(f' From: {commit_a.commit_id[:8]} "{commit_a.message}"')
182 typer.echo(f' To: {commit_b.commit_id[:8]} "{commit_b.message}"')
183
184 if not ops:
185 typer.echo("\n (no semantic changes between these two commits)")
186 return
187
188 total_symbols = 0
189 files_changed: set[str] = set()
190
191 for op in ops:
192 if op["op"] == "patch":
193 fp = op["address"]
194 child_ops = op["child_ops"]
195 if not child_ops:
196 continue
197 files_changed.add(fp)
198 is_new = fp not in manifest_a
199 is_gone = fp not in manifest_b
200 suffix = " (new file)" if is_new else (" (removed)" if is_gone else "")
201 typer.echo(f"\n{fp}{suffix}")
202 for child in child_ops:
203 typer.echo(_format_child_op(child))
204 total_symbols += 1
205 else:
206 fp = op["address"]
207 files_changed.add(fp)
208 if op["op"] == "insert":
209 typer.echo(f"\n{fp} (new file)")
210 typer.echo(f" added {fp} (file)")
211 elif op["op"] == "delete":
212 typer.echo(f"\n{fp} (removed)")
213 typer.echo(f" removed {fp} (file)")
214 else:
215 typer.echo(f"\n{fp}")
216 typer.echo(f" modified {fp} (file)")
217 total_symbols += 1
218
219 typer.echo(f"\n{total_symbols} symbol change(s) across {len(files_changed)} file(s)")