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