blame.py python
513 lines 19.5 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
1 """muse code blame -- symbol-level attribution.
2
3 ``git blame`` attributes every *line* to a commit -- a 300-line class gives
4 you 300 attribution entries. ``muse code blame`` attributes the *symbol* as a
5 semantic unit: one answer per function, class, or method, regardless of how
6 many lines it occupies.
7
8 Rename tracking
9 ---------------
10 ``muse code blame`` follows renames automatically, in both directions:
11
12 * Blaming the **current** name (post-rename) walks backward through the
13 rename event, then continues tracking the symbol's earlier history under
14 its old name.
15 * Blaming the **original** name (pre-rename) finds both the creation event
16 and the rename event, then follows the symbol forward under its new name.
17
18 Early-exit optimisation
19 -----------------------
20 The scan stops as soon as a ``"created"`` event is found for the symbol.
21 At that point the full lineage is known; continuing would yield no new
22 events. For long-lived symbols this can reduce scan work dramatically.
23
24 Security model
25 --------------
26 - The ``ADDRESS`` argument is validated to reject null bytes and ANSI/control
27 characters before any processing occurs.
28 - All user-controlled values (address, commit references, author strings,
29 commit messages, change details) are sanitized via ``sanitize_display()``
30 before appearing in human-readable output.
31 - The ``from_ref`` argument is sanitized in error messages.
32 - JSON output carries raw stored values (no terminal sanitization applied) —
33 agents receive the exact data as committed.
34 - All error messages go to **stderr**; **stdout** carries only data.
35
36 Agent UX
37 --------
38 Pass ``--json`` for a stable machine-readable object. All fields are always
39 present. Filter by ``--kind`` and/or ``--author`` to narrow output further.
40
41 Usage::
42
43 muse code blame "src/billing.py::compute_invoice_total"
44 muse code blame "api/server.go::Server.HandleRequest"
45 muse code blame "src/models.py::User.save" --all
46 muse code blame "src/billing.py::run" --from feat/my-branch
47 muse code blame "src/billing.py::run" --json
48 muse code blame "src/billing.py::run" --kind created --kind renamed
49 muse code blame "src/billing.py::run" --author alice
50
51 JSON schema (``--json``)::
52
53 {
54 "address": "src/billing.py::compute_invoice_total",
55 "start_ref": "main",
56 "total_commits_scanned": 42,
57 "truncated": false,
58 "events": [
59 {
60 "event": "created",
61 "commit_id": "<64-char hex>",
62 "author": "alice",
63 "message": "initial implementation",
64 "committed_at": "2026-01-15T12:00:00+00:00",
65 "address": "src/billing.py::compute_invoice_total",
66 "detail": "created",
67 "new_address": null
68 }
69 ]
70 }
71
72 Exit codes
73 ----------
74 - 0 — success
75 - 1 — invalid arguments (bad address, bad --kind, bad ref)
76 - 2 — not inside a Muse repository
77 - 4 — commit reference not found
78 """
79 from __future__ import annotations
80
81 import argparse
82 import json
83 import logging
84 import sys
85 from dataclasses import dataclass
86 from typing import TYPE_CHECKING, Literal, TypedDict
87
88 from muse.core.errors import ExitCode
89 from muse.core.repo import read_repo_id, require_repo
90 from muse.core.store import CommitRecord, read_current_branch, resolve_commit_ref
91 from muse.domain import DomainOp
92 from muse.plugins.code._query import walk_commits_bfs
93 from muse.core.validation import clamp_int, sanitize_display, sanitize_provenance
94
95 if TYPE_CHECKING:
96 import pathlib
97
98 logger = logging.getLogger(__name__)
99
100 _DEFAULT_MAX = 500
101
102 SymbolEventKind = Literal[
103 "created", "modified", "renamed", "moved", "deleted", "signature"
104 ]
105
106 _ALL_KINDS: frozenset[str] = frozenset(
107 ("created", "modified", "renamed", "moved", "deleted", "signature")
108 )
109
110
111 # ---------------------------------------------------------------------------
112 # JSON TypedDicts — stable machine-readable output schemas
113 # ---------------------------------------------------------------------------
114
115
116 class _BlameEventJson(TypedDict):
117 """One event in the blame history of a symbol."""
118
119 event: SymbolEventKind
120 commit_id: str
121 author: str
122 message: str
123 committed_at: str
124 address: str
125 detail: str
126 new_address: str | None
127
128
129 class _BlameResultJson(TypedDict):
130 """Top-level JSON output of ``muse code blame --json``."""
131
132 address: str
133 start_ref: str
134 total_commits_scanned: int
135 truncated: bool
136 events: list[_BlameEventJson]
137
138
139 # ---------------------------------------------------------------------------
140 # Internal dataclass
141 # ---------------------------------------------------------------------------
142
143
144 @dataclass
145 class _BlameEvent:
146 """One attributed change event for a symbol across the commit history."""
147
148 kind: SymbolEventKind
149 commit: CommitRecord
150 address: str
151 detail: str
152 new_address: str | None = None
153
154 def to_dict(self) -> _BlameEventJson:
155 """Serialise to the stable ``_BlameEventJson`` schema."""
156 return _BlameEventJson(
157 event=self.kind,
158 commit_id=self.commit.commit_id,
159 author=self.commit.author,
160 message=self.commit.message,
161 committed_at=self.commit.committed_at.isoformat(),
162 address=self.address,
163 detail=self.detail,
164 new_address=self.new_address,
165 )
166
167
168 # ---------------------------------------------------------------------------
169 # Core event-extraction logic
170 # ---------------------------------------------------------------------------
171
172
173 def _flat_ops(ops: list[DomainOp]) -> list[DomainOp]:
174 """Flatten patch ops so that every leaf ``DomainOp`` is at the top level."""
175 result: list[DomainOp] = []
176 for op in ops:
177 if op["op"] == "patch":
178 result.extend(op["child_ops"])
179 else:
180 result.append(op)
181 return result
182
183
184 def _events_in_commit(
185 commit: CommitRecord,
186 address: str,
187 file_prefix: str,
188 bare_name: str,
189 ) -> tuple[list[_BlameEvent], str]:
190 """Scan *commit* for events touching *address*.
191
192 Returns ``(events, next_address)`` where ``next_address`` is the symbol
193 name to search for in older commits (changes only when a rename is detected
194 while walking newest-first).
195
196 Args:
197 commit: The commit record to scan.
198 address: The full symbol address currently being tracked (e.g.
199 ``"src/billing.py::compute_invoice_total"``).
200 file_prefix: The file part of *address* (``"src/billing.py"``).
201 Pre-split by the caller to avoid redundant splits.
202 bare_name: The symbol name part of *address* (``"compute_invoice_total"``).
203 Pre-split by the caller.
204
205 Rename semantics (walking newest-first)
206 ----------------------------------------
207 Renames are stored as ``{op: "replace", address: OLD_NAME,
208 new_summary: "renamed to NEW_NAME"}``.
209
210 * **Direct match** (``op.address == address``): we found an event for the
211 symbol we are currently tracking. If it is a rename, older commits had
212 the symbol under ``address`` (the old name) — ``next_address`` is
213 unchanged.
214
215 * **Reverse rename** (``op.address == some_old_name``,
216 ``new_summary == "renamed to <our bare name>"``): the symbol we are
217 tracking was previously named ``some_old_name``. Switch ``next_address``
218 to ``some_old_name`` so we pick up its earlier history.
219 """
220 events: list[_BlameEvent] = []
221 next_address = address
222
223 if commit.structured_delta is None:
224 return events, next_address
225
226 for op in _flat_ops(commit.structured_delta["ops"]):
227 op_address: str = op["address"]
228
229 if op_address == address:
230 # ── Direct match ─────────────────────────────────────────────────
231 if op["op"] == "insert":
232 events.append(_BlameEvent(
233 "created", commit, address,
234 op.get("content_summary", "created"),
235 ))
236 elif op["op"] == "delete":
237 detail: str = op.get("content_summary", "deleted")
238 kind: SymbolEventKind = "moved" if "moved to" in detail else "deleted"
239 events.append(_BlameEvent(kind, commit, address, detail))
240 elif op["op"] == "replace":
241 ns: str = op.get("new_summary", "")
242 if ns.startswith("renamed to "):
243 new_name = ns.removeprefix("renamed to ").strip()
244 new_addr = f"{file_prefix}::{new_name}"
245 events.append(_BlameEvent(
246 "renamed", commit, address,
247 f"renamed to {new_name}", new_addr,
248 ))
249 # Walking backward: older commits had the symbol as
250 # *address* (the old name). Do NOT update next_address.
251 elif ns.startswith("moved to "):
252 events.append(_BlameEvent("moved", commit, address, ns))
253 elif "signature" in ns:
254 events.append(_BlameEvent(
255 "signature", commit, address, ns or "signature changed",
256 ))
257 else:
258 events.append(_BlameEvent(
259 "modified", commit, address, ns or "modified",
260 ))
261
262 elif op["op"] == "replace":
263 # ── Reverse rename detection ──────────────────────────────────────
264 # Was some other symbol (op_address) renamed TO the symbol we are
265 # currently tracking (address)? e.g.:
266 # op = {address: "billing.py::compute_total",
267 # new_summary: "renamed to compute_invoice_total"}
268 # and address = "billing.py::compute_invoice_total"
269 ns_other: str = op.get("new_summary", "")
270 if ns_other.startswith("renamed to ") and "::" in op_address:
271 renamed_to = ns_other.removeprefix("renamed to ").strip()
272 op_file = op_address.rsplit("::", 1)[0]
273 if op_file == file_prefix and renamed_to == bare_name:
274 # Found: op_address (old name) was renamed to address.
275 old_name = op_address.rsplit("::", 1)[-1]
276 events.append(_BlameEvent(
277 "renamed", commit, op_address,
278 f"renamed from {old_name} to {bare_name}",
279 address,
280 ))
281 # Walking backward: switch to the old name.
282 next_address = op_address
283
284 return events, next_address
285
286
287 # ---------------------------------------------------------------------------
288 # Argument parser registration
289 # ---------------------------------------------------------------------------
290
291
292 def register(
293 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
294 ) -> None:
295 """Register the ``blame`` subcommand.
296
297 All error messages are routed to stderr. ``--json`` emits a stable
298 ``_BlameResultJson`` object to stdout.
299 """
300 parser = subparsers.add_parser(
301 "blame",
302 help="Show which commit last touched a specific symbol.",
303 description=__doc__,
304 formatter_class=argparse.RawDescriptionHelpFormatter,
305 )
306 parser.add_argument(
307 "address", metavar="ADDRESS",
308 help='Symbol address, e.g. "src/billing.py::compute_invoice_total".',
309 )
310 parser.add_argument(
311 "--from", default=None, metavar="REF", dest="from_ref",
312 help="Start walking from this commit / branch (default: HEAD).",
313 )
314 parser.add_argument(
315 "--all", "-a", action="store_true", dest="show_all",
316 help="Show the full change history, not just the three most recent events.",
317 )
318 parser.add_argument(
319 "--max", default=_DEFAULT_MAX, type=int, metavar="N", dest="max_commits",
320 help=f"Maximum commits to scan (default: {_DEFAULT_MAX}).",
321 )
322 parser.add_argument(
323 "--kind", action="append", dest="kinds", default=None,
324 metavar="KIND",
325 help=(
326 "Filter output to events of this kind. Accepted values: "
327 "created, modified, renamed, moved, deleted, signature. "
328 "Repeat to include multiple kinds."
329 ),
330 )
331 parser.add_argument(
332 "--author", default=None, metavar="PATTERN", dest="author_filter",
333 help=(
334 "Filter output to events from commits whose author contains "
335 "PATTERN (case-insensitive substring match)."
336 ),
337 )
338 parser.add_argument(
339 "--json", action="store_true", dest="as_json",
340 help="Emit attribution as structured JSON.",
341 )
342 parser.set_defaults(func=run)
343
344
345 # ---------------------------------------------------------------------------
346 # Command entry point
347 # ---------------------------------------------------------------------------
348
349
350 def run(args: argparse.Namespace) -> None:
351 """Show which commit last touched a specific symbol.
352
353 ``muse code blame`` attributes the symbol as a semantic unit — one answer
354 per function, class, or method, regardless of line count.
355
356 Renames are tracked automatically. Blaming a symbol that was renamed from
357 an earlier name will follow the rename backward and continue tracking the
358 symbol's history under the old name.
359
360 Uses a BFS walk that follows both parents of merge commits, so events on
361 merged feature branches are never missed.
362
363 Scanning stops as soon as a ``"created"`` event is found — at that point
364 the full lineage is established and further commits cannot add new events.
365
366 Patterns are searched against the full commit DAG up to ``--max`` commits.
367 """
368 address: str = args.address
369 from_ref: str | None = args.from_ref
370 show_all: bool = args.show_all
371 max_commits: int = clamp_int(args.max_commits, 1, 100_000, "max_commits")
372 as_json: bool = args.as_json
373 kinds_raw: list[str] | None = args.kinds
374 author_filter: str | None = args.author_filter
375
376 # ── Validation ────────────────────────────────────────────────────────────
377
378 # Reject control characters and null bytes in the address before any use.
379 sanitised_addr = sanitize_provenance(address)
380 if sanitised_addr != address or "\x00" in address:
381 print(
382 "❌ ADDRESS contains control characters or null bytes.",
383 file=sys.stderr,
384 )
385 raise SystemExit(ExitCode.USER_ERROR)
386
387 if "::" not in address:
388 print(
389 f"❌ Invalid address {address!r} — expected 'file.py::SymbolName'.",
390 file=sys.stderr,
391 )
392 raise SystemExit(ExitCode.USER_ERROR)
393
394 # Validate --kind values.
395 kind_filter: frozenset[str] | None = None
396 if kinds_raw:
397 invalid = [k for k in kinds_raw if k not in _ALL_KINDS]
398 if invalid:
399 print(
400 f"❌ Unknown --kind value(s): {', '.join(invalid)}. "
401 f"Accepted: {', '.join(sorted(_ALL_KINDS))}.",
402 file=sys.stderr,
403 )
404 raise SystemExit(ExitCode.USER_ERROR)
405 kind_filter = frozenset(kinds_raw)
406
407 # ── Repo / commit resolution ──────────────────────────────────────────────
408
409 root = require_repo()
410 repo_id = read_repo_id(root)
411 branch = read_current_branch(root)
412
413 start_commit = resolve_commit_ref(root, repo_id, branch, from_ref)
414 if start_commit is None:
415 ref_display = sanitize_display(from_ref or "HEAD")
416 print(f"❌ Commit {ref_display!r} not found.", file=sys.stderr)
417 raise SystemExit(ExitCode.NOT_FOUND)
418
419 # ── BFS walk ──────────────────────────────────────────────────────────────
420
421 commits, truncated = walk_commits_bfs(root, start_commit.commit_id, max_commits)
422
423 # ── Event collection (rename-aware, early-exit on "created") ─────────────
424
425 current_address = address
426 all_events: list[_BlameEvent] = []
427 for commit in commits:
428 # Pre-split current_address once per iteration (address may change on
429 # rename detection — splitting here avoids double-split inside the
430 # inner function).
431 if "::" not in current_address:
432 break
433 cur_file, cur_bare = current_address.rsplit("::", 1)
434 evs, current_address = _events_in_commit(
435 commit, current_address, cur_file, cur_bare
436 )
437 all_events.extend(evs)
438 # Early exit: once the symbol's creation is found, full lineage is
439 # established — no older commit can contribute new events.
440 if any(ev.kind == "created" for ev in evs):
441 break
442
443 # ── Apply filters ─────────────────────────────────────────────────────────
444
445 filtered: list[_BlameEvent] = all_events
446 if kind_filter is not None:
447 filtered = [ev for ev in filtered if ev.kind in kind_filter]
448 if author_filter is not None:
449 needle = author_filter.lower()
450 filtered = [
451 ev for ev in filtered
452 if needle in (ev.commit.author or "").lower()
453 ]
454
455 # ── Output ────────────────────────────────────────────────────────────────
456
457 start_ref = sanitize_display(from_ref or branch)
458
459 if as_json:
460 result = _BlameResultJson(
461 address=address,
462 start_ref=from_ref or branch,
463 total_commits_scanned=len(commits),
464 truncated=truncated,
465 events=[e.to_dict() for e in reversed(filtered)],
466 )
467 print(json.dumps(result, indent=2))
468 return
469
470 print(f"\n{sanitize_display(address)}")
471 print("─" * 62)
472
473 if truncated:
474 print(
475 f" ⚠️ History may be incomplete — scanned {len(commits)} commits "
476 f"(--max {max_commits}).",
477 )
478
479 if not filtered:
480 if all_events:
481 print(" (no events match the active filters)")
482 else:
483 print(
484 " (no events found — symbol may not exist or have no recorded history)"
485 )
486 return
487
488 events_to_show = filtered if show_all else filtered[:3]
489
490 _LABELS = ("last touched:", "previous: ", "before that: ")
491
492 for idx, ev in enumerate(events_to_show):
493 if idx < len(_LABELS):
494 label = _LABELS[idx]
495 else:
496 label = f"#{idx + 1}:".ljust(13)
497 date_str = ev.commit.committed_at.strftime("%Y-%m-%d")
498 short_id = sanitize_display(ev.commit.commit_id[:8])
499 print(f"{label} {short_id} {date_str}")
500 print(f" author: {sanitize_display(ev.commit.author or 'unknown')}")
501 print(f' message: "{sanitize_display(ev.commit.message)}"')
502 print(f" change: {sanitize_display(ev.detail)}")
503 if ev.new_address:
504 print(f" → tracking continues as {sanitize_display(ev.new_address)}")
505 print("")
506
507 if not show_all and len(filtered) > 3:
508 remaining = len(filtered) - 3
509 print(
510 f" … {remaining} older event(s) — pass --all to see the full history."
511 )
512
513 _ = start_ref # used for JSON; keep linter happy
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 32 days ago