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