gabriel / muse public
lineage.py python
643 lines 21.9 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 142 days ago
1 """muse code lineage — full symbol provenance chain.
2
3 Traces the complete life of a symbol through the commit history:
4 created → renamed → moved → copied → modified → deleted, in chronological order.
5
6 Each transition is classified by comparing hashes across consecutive commits:
7
8 * **created** — first InsertOp for this address (no prior body_hash match)
9 * **copied_from** — InsertOp whose content_id matches a living symbol at a
10 different address (same body, new address)
11 * **renamed_from** — InsertOp + DeleteOp in same commit with matching content_id
12 (content preserved, address changed within same file)
13 * **moved_from** — InsertOp + DeleteOp in same commit with matching content_id
14 AND different file (cross-file move)
15 * **modified** — ReplaceOp at this address; sub-classified by summary heuristic:
16 ``signature_change`` (parameter/return-type change detected) or
17 ``full_rewrite`` (body changed, no signature marker)
18 * **deleted** — DeleteOp at this address
19
20 Usage::
21
22 muse code lineage "src/billing.py::compute_invoice_total"
23 muse code lineage "src/auth.py::validate_token" --commit HEAD~5
24 muse code lineage "src/core.py::hash_content" --json
25 muse code lineage "src/billing.py::process_order" --branch feat/payments
26 muse code lineage "src/billing.py::process_order" --since 2025-01-01
27 muse code lineage "src/billing.py::process_order" --filter modified
28 muse code lineage "src/billing.py::process_order" --stability
29 muse code lineage "src/billing.py::process_order" --count
30
31 Output::
32
33 Lineage: src/billing.py::compute_invoice_total
34 ──────────────────────────────────────────────────────────────
35
36 2026-02-01 a1b2c3d4 created "Initial billing module"
37 2026-02-10 e5f6a7b8 modified (impl_only) "Fix rounding"
38 2026-02-15 c9d0e1f2 renamed_from "Rename compute_total"
39 └─ src/billing.py::_compute_total
40 2026-03-10 f7a8b9c0 modified (full_rewrite) "Overhaul billing"
41
42 4 events — first seen 2026-02-01 · last seen 2026-03-10
43 Stability: 50% (2 modification(s) in 4 events)
44
45 Flags:
46
47 ``--commit, -c REF``
48 Walk history starting from this commit (inclusive) instead of HEAD.
49 Only commits reachable from REF are examined.
50
51 ``--branch BRANCH``
52 Walk only this branch's linear history instead of all object-store commits.
53
54 ``--since DATE``
55 Ignore commits before DATE (YYYY-MM-DD).
56
57 ``--until DATE``
58 Ignore commits after DATE (YYYY-MM-DD).
59
60 ``--filter KIND``
61 Show only events of this kind: created, modified, deleted, renamed_from,
62 moved_from, copied_from.
63
64 ``--stability``
65 Print a stability score: ratio of unmodified commits to total events.
66
67 ``--count``
68 Print only the number of events (scriptable).
69
70 ``--json``
71 Emit the full provenance chain as JSON.
72 """
73
74 from __future__ import annotations
75
76 import argparse
77 import datetime
78 import json
79 import logging
80 import pathlib
81 import sys
82 from typing import Literal, TypedDict
83
84 from muse.core._types import short_id
85 from muse.core.errors import ExitCode
86 from muse.core.repo import read_repo_id, require_repo
87 from muse.core.timing import start_timer
88 from muse.core.store import (
89 CommitRecord,
90 Metadata,
91 get_all_commits,
92 get_head_commit_id,
93 read_current_branch,
94 resolve_commit_ref,
95 walk_commits_between,
96 )
97 from muse.plugins.code._query import flat_symbol_ops
98 from muse.core.validation import sanitize_display
99
100 type _ContentLiveMap = dict[str, set[str]]
101 type _InsertMap = dict[str, "_InsertFields"]
102 type _DeleteMap = dict[str, "_DeleteFields"]
103 type _ReplaceMap = dict[str, "_ReplaceFields"]
104
105 logger = logging.getLogger(__name__)
106
107
108 class _LineageJson(TypedDict, total=False):
109 """JSON payload for ``muse code lineage`` output.
110
111 Fields
112 ------
113 address The symbol address analysed.
114 total Total number of events (after any filter).
115 events List of provenance event dicts (see ``_LineageEvent.to_dict``).
116 stability_pct Percentage of events that are *not* modifications (0–100).
117 modified_count Number of ``modified`` events in the (filtered) list.
118 filter The ``--filter`` kind applied, or ``None``.
119 since The ``--since`` date applied as ISO string, or ``None``.
120 until The ``--until`` date applied as ISO string, or ``None``.
121 exit_code Always 0 — all error paths raise SystemExit before any JSON.
122 duration_ms Wall-clock time for the full analysis in milliseconds.
123 """
124
125 address: str
126 total: int
127 events: list[Metadata]
128 stability_pct: int
129 modified_count: int
130 filter: str | None
131 since: str | None
132 until: str | None
133 exit_code: int
134 duration_ms: float
135
136 EventKind = Literal[
137 "created",
138 "renamed_from",
139 "moved_from",
140 "copied_from",
141 "modified",
142 "deleted",
143 ]
144
145 _ALL_EVENT_KINDS: frozenset[str] = frozenset({
146 "created", "renamed_from", "moved_from", "copied_from", "modified", "deleted",
147 })
148
149
150 # ---------------------------------------------------------------------------
151 # Typed op wrappers
152 # ---------------------------------------------------------------------------
153
154
155 class _InsertFields:
156 """Extracted fields from an InsertOp."""
157 __slots__ = ("address", "content_id")
158
159 def __init__(self, address: str, content_id: str) -> None:
160 self.address = address
161 self.content_id = content_id
162
163
164 class _DeleteFields:
165 __slots__ = ("address", "content_id")
166
167 def __init__(self, address: str, content_id: str) -> None:
168 self.address = address
169 self.content_id = content_id
170
171
172 class _ReplaceFields:
173 __slots__ = ("address", "old_content_id", "new_content_id", "old_summary", "new_summary")
174
175 def __init__(
176 self,
177 address: str,
178 old_content_id: str,
179 new_content_id: str,
180 old_summary: str,
181 new_summary: str,
182 ) -> None:
183 self.address = address
184 self.old_content_id = old_content_id
185 self.new_content_id = new_content_id
186 self.old_summary = old_summary
187 self.new_summary = new_summary
188
189
190 # ---------------------------------------------------------------------------
191 # Event type
192 # ---------------------------------------------------------------------------
193
194
195 class _LineageEvent:
196 """One classified provenance event for a symbol."""
197
198 __slots__ = (
199 "commit_id", "committed_at", "message", "kind",
200 "detail", "old_content_id", "new_content_id",
201 )
202
203 def __init__(
204 self,
205 commit_id: str,
206 committed_at: str,
207 message: str,
208 kind: EventKind,
209 detail: str = "",
210 old_content_id: str = "",
211 new_content_id: str = "",
212 ) -> None:
213 self.commit_id = commit_id
214 self.committed_at = committed_at
215 self.message = message
216 self.kind = kind
217 self.detail = detail
218 self.old_content_id = old_content_id
219 self.new_content_id = new_content_id
220
221 def to_dict(self) -> Metadata:
222 """Return a JSON-serialisable dict with full (untruncated) IDs."""
223 d: Metadata = {
224 "commit_id": self.commit_id,
225 "committed_at": self.committed_at,
226 "message": self.message,
227 "event": self.kind,
228 }
229 if self.detail:
230 d["detail"] = self.detail
231 if self.old_content_id:
232 d["old_content_id"] = self.old_content_id
233 if self.new_content_id:
234 d["new_content_id"] = self.new_content_id
235 return d
236
237
238 # ---------------------------------------------------------------------------
239 # Classification helpers
240 # ---------------------------------------------------------------------------
241
242
243 def _classify_replace(old_summary: str, new_summary: str) -> str:
244 """Classify a ReplaceOp by examining summary strings for change markers.
245
246 Returns
247 -------
248 ``"signature_change"``
249 Either summary contains the word "signature", indicating a parameter
250 list or return-type change.
251 ``"full_rewrite"``
252 Default — body changed but no signature marker detected.
253 """
254 if "signature" in old_summary or "signature" in new_summary:
255 return "signature_change"
256 return "full_rewrite"
257
258
259 def _stability(events: list[_LineageEvent]) -> tuple[int, int]:
260 """Return ``(modified_count, total_events)``."""
261 modified = sum(1 for e in events if e.kind == "modified")
262 return modified, len(events)
263
264
265 # ---------------------------------------------------------------------------
266 # Core analysis — accepts an explicit commit list (pure, testable)
267 # ---------------------------------------------------------------------------
268
269
270 def build_lineage(
271 address: str,
272 commits: list[CommitRecord],
273 ) -> list[_LineageEvent]:
274 """Walk *commits* oldest-first and build the provenance chain for *address*.
275
276 The caller controls which commits to pass — enabling branch-restricted walks,
277 date-bounded walks, and direct unit testing with synthetic ``CommitRecord``
278 objects.
279
280 Copy detection uses an incremental ``content_id → set[address]`` registry
281 updated from ``structured_delta`` ops as commits are processed. This is
282 O(total ops across all commits) — no blob re-parsing, no snapshot scans.
283
284 Args:
285 address: Full symbol address, e.g. ``"src/billing.py::compute_invoice_total"``.
286 commits: Commits to walk, oldest-first.
287
288 Returns:
289 List of :class:`_LineageEvent` objects in chronological order.
290 """
291 events: list[_LineageEvent] = []
292 address_live = False
293
294 # content_id → set of currently-live symbol addresses.
295 # Updated incrementally; never cleared — gives O(1) copy detection.
296 live_by_content_id: _ContentLiveMap = {}
297
298 for commit in commits:
299 if commit.structured_delta is None:
300 continue
301 ops = commit.structured_delta.get("ops", [])
302 committed_at = commit.committed_at.isoformat()
303 message = commit.message
304
305 inserts: _InsertMap = {}
306 deletes: _DeleteMap = {}
307 replaces: _ReplaceMap = {}
308
309 for op in flat_symbol_ops(ops):
310 addr = op["address"]
311 if op["op"] == "insert":
312 inserts[addr] = _InsertFields(
313 address=addr,
314 content_id=op["content_id"],
315 )
316 elif op["op"] == "delete":
317 deletes[addr] = _DeleteFields(
318 address=addr,
319 content_id=op["content_id"],
320 )
321 elif op["op"] == "replace":
322 replaces[addr] = _ReplaceFields(
323 address=addr,
324 old_content_id=op["old_content_id"],
325 new_content_id=op["new_content_id"],
326 old_summary=op["old_summary"],
327 new_summary=op["new_summary"],
328 )
329
330 if address in replaces:
331 rep = replaces[address]
332 detail = _classify_replace(rep.old_summary, rep.new_summary)
333 events.append(_LineageEvent(
334 commit_id=commit.commit_id,
335 committed_at=committed_at,
336 message=message,
337 kind="modified",
338 detail=detail,
339 old_content_id=rep.old_content_id,
340 new_content_id=rep.new_content_id,
341 ))
342 live_by_content_id.get(rep.old_content_id, set()).discard(address)
343 live_by_content_id.setdefault(rep.new_content_id, set()).add(address)
344
345 if address in inserts:
346 ins = inserts[address]
347 ins_cid = ins.content_id
348
349 # Rename / move: DeleteOp in same commit with matching content_id.
350 source_addr: str | None = None
351 for del_addr, del_op in deletes.items():
352 if del_addr != address and del_op.content_id == ins_cid:
353 source_addr = del_addr
354 break
355
356 if source_addr is not None:
357 del_file = source_addr.split("::")[0]
358 ins_file = address.split("::")[0]
359 ev_kind: EventKind = "moved_from" if del_file != ins_file else "renamed_from"
360 events.append(_LineageEvent(
361 commit_id=commit.commit_id,
362 committed_at=committed_at,
363 message=message,
364 kind=ev_kind,
365 detail=source_addr,
366 new_content_id=ins_cid,
367 ))
368 else:
369 # Copy detection: O(1) lookup in the incremental registry.
370 existing = live_by_content_id.get(ins_cid, set()) - {address}
371 if existing and not address_live:
372 copy_source: str | None = next(iter(sorted(existing)))
373 copy_kind: EventKind = "copied_from"
374 else:
375 copy_source = None
376 copy_kind = "created"
377 events.append(_LineageEvent(
378 commit_id=commit.commit_id,
379 committed_at=committed_at,
380 message=message,
381 kind=copy_kind,
382 detail=copy_source or "",
383 new_content_id=ins_cid,
384 ))
385
386 live_by_content_id.setdefault(ins_cid, set()).add(address)
387 address_live = True
388
389 if address in deletes:
390 del_f = deletes[address]
391 events.append(_LineageEvent(
392 commit_id=commit.commit_id,
393 committed_at=committed_at,
394 message=message,
395 kind="deleted",
396 old_content_id=del_f.content_id,
397 ))
398 live_by_content_id.get(del_f.content_id, set()).discard(address)
399 address_live = False
400
401 # Update registry for all other ops so copy detection stays accurate.
402 for addr, ins in inserts.items():
403 if addr != address:
404 live_by_content_id.setdefault(ins.content_id, set()).add(addr)
405 for addr, del_op in deletes.items():
406 if addr != address:
407 live_by_content_id.get(del_op.content_id, set()).discard(addr)
408
409 return events
410
411
412 # ---------------------------------------------------------------------------
413 # Commit collection helpers
414 # ---------------------------------------------------------------------------
415
416
417 def _gather_commits(
418 root: pathlib.Path,
419 repo_id: str,
420 branch: str,
421 ref: str | None,
422 branch_filter: str | None,
423 since: datetime.date | None,
424 until: datetime.date | None,
425 ) -> list[CommitRecord] | None:
426 """Resolve the commit list to walk, oldest-first.
427
428 Returns ``None`` if the requested ref or branch cannot be found.
429 """
430 commits: list[CommitRecord]
431
432 if branch_filter is not None:
433 tip = get_head_commit_id(root, branch_filter)
434 if tip is None:
435 return None
436 commits = list(reversed(walk_commits_between(root, tip)))
437 elif ref is not None:
438 commit = resolve_commit_ref(root, repo_id, branch, ref)
439 if commit is None:
440 return None
441 commits = list(reversed(walk_commits_between(root, commit.commit_id)))
442 else:
443 commits = sorted(get_all_commits(root), key=lambda c: c.committed_at)
444
445 if since is not None:
446 commits = [c for c in commits if c.committed_at.date() >= since]
447 if until is not None:
448 commits = [c for c in commits if c.committed_at.date() <= until]
449
450 return commits
451
452
453 # ---------------------------------------------------------------------------
454 # CLI registration
455 # ---------------------------------------------------------------------------
456
457
458 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
459 """Register the lineage subcommand."""
460 parser = subparsers.add_parser(
461 "lineage",
462 help="Show the full provenance chain of a symbol through commit history.",
463 description=__doc__,
464 formatter_class=argparse.RawDescriptionHelpFormatter,
465 )
466 parser.add_argument(
467 "address",
468 metavar="ADDRESS",
469 help='Symbol address, e.g. "src/billing.py::compute_invoice_total".',
470 )
471 parser.add_argument(
472 "--commit", "-c",
473 dest="ref",
474 default=None,
475 metavar="REF",
476 help="Walk only commits reachable from this ref (default: all commits).",
477 )
478 parser.add_argument(
479 "--branch", "-b",
480 dest="branch_filter",
481 default=None,
482 metavar="BRANCH",
483 help="Walk only this branch's linear history.",
484 )
485 parser.add_argument(
486 "--since",
487 dest="since",
488 default=None,
489 metavar="DATE",
490 help="Ignore commits before DATE (YYYY-MM-DD).",
491 )
492 parser.add_argument(
493 "--until",
494 dest="until",
495 default=None,
496 metavar="DATE",
497 help="Ignore commits after DATE (YYYY-MM-DD).",
498 )
499 parser.add_argument(
500 "--filter",
501 dest="kind_filter",
502 default=None,
503 metavar="KIND",
504 choices=sorted(_ALL_EVENT_KINDS),
505 help="Show only events of this kind (created, modified, deleted, …).",
506 )
507 parser.add_argument(
508 "--stability",
509 dest="show_stability",
510 action="store_true",
511 help="Print a stability score (ratio of modifications to total events).",
512 )
513 parser.add_argument(
514 "--count",
515 dest="count_only",
516 action="store_true",
517 help="Print only the total number of events (scriptable).",
518 )
519 parser.add_argument(
520 "--json", "-j",
521 dest="as_json",
522 action="store_true",
523 help="Emit results as JSON.",
524 )
525 parser.set_defaults(func=run)
526
527
528 # ---------------------------------------------------------------------------
529 # Command entry point
530 # ---------------------------------------------------------------------------
531
532
533 def run(args: argparse.Namespace) -> None:
534 """Show the full provenance chain of a symbol through commit history.
535
536 JSON envelope (``--json`` / ``-j``)
537 ------------------------------------
538 exit_code Always 0 — all error paths raise SystemExit before JSON.
539 duration_ms Wall-clock time for the full analysis (non-negative float).
540 filter The ``--filter`` kind applied, or ``None``.
541 since The ``--since`` date as ISO string, or ``None``.
542 until The ``--until`` date as ISO string, or ``None``.
543 """
544 elapsed = start_timer()
545 address: str = args.address
546 ref: str | None = args.ref
547 branch_filter: str | None = args.branch_filter
548 kind_filter: str | None = args.kind_filter
549 show_stability: bool = args.show_stability
550 count_only: bool = args.count_only
551 as_json: bool = args.as_json
552
553 if "::" not in address:
554 print(
555 "❌ ADDRESS must contain '::' — e.g. 'src/billing.py::compute_invoice_total'.",
556 file=sys.stderr,
557 )
558 raise SystemExit(ExitCode.USER_ERROR)
559
560 since: datetime.date | None = None
561 until: datetime.date | None = None
562 if args.since:
563 try:
564 since = datetime.date.fromisoformat(args.since)
565 except ValueError:
566 print(f"❌ --since: invalid date '{args.since}' (expected YYYY-MM-DD).", file=sys.stderr)
567 raise SystemExit(ExitCode.USER_ERROR)
568 if args.until:
569 try:
570 until = datetime.date.fromisoformat(args.until)
571 except ValueError:
572 print(f"❌ --until: invalid date '{args.until}' (expected YYYY-MM-DD).", file=sys.stderr)
573 raise SystemExit(ExitCode.USER_ERROR)
574
575 root = require_repo()
576 repo_id = read_repo_id(root)
577
578 branch = read_current_branch(root)
579
580 commits = _gather_commits(root, repo_id, branch, ref, branch_filter, since, until)
581
582 if commits is None:
583 target = branch_filter or ref or "HEAD"
584 print(f"❌ '{target}' not found.", file=sys.stderr)
585 raise SystemExit(ExitCode.USER_ERROR)
586
587 events = build_lineage(address, commits)
588
589 if kind_filter:
590 events = [e for e in events if e.kind == kind_filter]
591
592 modified_count, total_count = _stability(events)
593
594 if count_only and not as_json:
595 print(len(events))
596 return
597
598 if as_json:
599 pct = round((total_count - modified_count) / total_count * 100) if total_count else 100
600 print(json.dumps(_LineageJson(
601 address=address,
602 total=len(events),
603 events=[e.to_dict() for e in events],
604 stability_pct=pct,
605 modified_count=modified_count,
606 filter=kind_filter,
607 since=since.isoformat() if since else None,
608 until=until.isoformat() if until else None,
609 exit_code=0,
610 duration_ms=elapsed(),
611 )))
612 return
613
614 print(f"\nLineage: {sanitize_display(address)}")
615 print("─" * 62)
616
617 if not events:
618 print(
619 "\n (no events found — address may not exist in this repository's history,"
620 "\n or the structured_delta does not carry symbol-level ops)"
621 )
622 return
623
624 for ev in events:
625 date = ev.committed_at[:10]
626 cid = short_id(ev.commit_id)
627 kind_label: str = ev.kind
628 if ev.detail and ev.kind == "modified":
629 kind_label = f"modified ({ev.detail})"
630 msg_str = f' "{sanitize_display(ev.message)}"' if ev.message else ""
631 print(f" {date} {cid} {kind_label:<28}{msg_str}")
632 if ev.detail and ev.kind in ("renamed_from", "moved_from", "copied_from"):
633 print(f"{'':34}└─ {sanitize_display(ev.detail)}")
634
635 print()
636 first = events[0].committed_at[:10]
637 last = events[-1].committed_at[:10]
638 suffix = "" if first == last else f" · last seen {last}"
639 print(f" {len(events)} event(s) — first seen {first}{suffix}")
640
641 if show_stability and total_count:
642 pct = round((total_count - modified_count) / total_count * 100)
643 print(f" Stability: {pct}% ({modified_count} modification(s) in {total_count} events)")
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 142 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 145 days ago