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