symbol_log.py python
548 lines 20.2 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
1 """muse code symbol-log -- track a single semantic symbol through commit history.
2
3 This command is impossible in Git: Git's ``git log -p src/utils.py`` shows
4 every line that changed in a file; it has no concept of a *function*.
5 ``muse code symbol-log`` tracks the full lifecycle of a single named symbol --
6 when it was created, when its implementation changed, when it was renamed,
7 and when it was deleted -- across the entire commit DAG, including all
8 branches that were ever merged in.
9
10 For **knowtation vaults**, the symbol address is a note section::
11
12 muse code symbol-log "notes/memory.md::section:2:Background#0"
13
14 When the commit carries Phase-4.2 memory-event metadata (``--event-type``,
15 ``--agent-id``, ``--model-id``), each event in the output includes those
16 fields. This turns ``symbol-log`` into a **memory replay** — for a given note
17 section you can see what type of vault activity triggered each change (e.g.
18 ``consolidation``, ``agent_interaction``) and which agent model wrote it.
19
20 Usage::
21
22 muse code symbol-log "src/utils.py::calculate_total"
23 muse code symbol-log "src/models.py::User.save"
24 muse code symbol-log "notes/session-2026-05-13.md::section:1:Summary#0"
25
26 Output (code domain)::
27
28 Symbol: src/utils.py::calculate_total
29 ----------------------------------------------------------------------
30
31 * a3f2c9 2026-03-14 "Refactor: extract validation logic"
32 created function calculate_total
33
34 * cb4afa 2026-03-15 "Perf: optimise total calculation"
35 modified implementation changed
36
37 * 1d2e3f 2026-03-16 "Rename: calculate_total -> compute_total"
38 renamed calculate_total -> compute_total
39 (tracking continues as src/utils.py::compute_total)
40
41 * 4a5b6c 2026-03-17 "Move: refactor to helpers module"
42 moved src/utils.py::compute_total -> src/helpers.py::compute_total
43 (tracking continues at src/helpers.py::compute_total)
44
45 4 events (created: 1 modified: 1 renamed: 1 moved: 1)
46
47 Output (knowtation domain — memory replay)::
48
49 Symbol: notes/session.md::section:1:Summary#0
50 ──────────────────────────────────────────────────────────────────────
51
52 ● a3f2c9 2026-05-13 "consolidation pass: merge session notes"
53 created first section content written
54 event_type: consolidation
55 agent_id: knowtation-daemon
56 model_id: claude-opus-4-7
57
58 ● cb4afa 2026-05-14 "write: updated summary"
59 modified implementation changed
60 event_type: write
61 agent_id: aaronrene
62 model_id: claude-sonnet-4-6
63
64 2 events (created: 1, modified: 1)
65
66 Flags::
67
68 --from <ref>
69 Start walking from this commit instead of HEAD.
70 Accepts a full or abbreviated commit SHA or a branch name.
71
72 --max <n>
73 Cap the number of commits inspected (default: 500).
74 When the cap is reached a warning is shown; increase it with
75 --max to see the full history.
76
77 --json
78 Emit a structured JSON object::
79
80 {
81 "address": "<file>::<symbol>",
82 "start_ref": "HEAD",
83 "total_commits_scanned": 282,
84 "truncated": false,
85 "events": [
86 {
87 "event": "created",
88 "commit_id": "<sha256>",
89 "message": "...",
90 "committed_at": "2026-03-14T...",
91 "address": "<file>::<symbol>",
92 "detail": "...",
93 "new_address": null,
94 "event_type": "consolidation",
95 "agent_id": "knowtation-daemon",
96 "model_id": "claude-opus-4-7"
97 }
98 ]
99 }
100
101 ``event_type``, ``agent_id``, and ``model_id`` are ``null`` when the
102 commit does not carry Phase-4.2 memory metadata.
103 """
104
105 from __future__ import annotations
106
107 import argparse
108 import json
109 import logging
110 import pathlib
111 import sys
112 from typing import Literal
113
114 from muse.core.errors import ExitCode
115 from muse.core.repo import read_repo_id, require_repo
116 from muse.core.store import (
117 CommitRecord,
118 read_current_branch,
119 resolve_commit_ref,
120 )
121 from muse.domain import DomainOp
122 from muse.plugins.code._query import walk_commits_bfs
123 from muse.core.validation import clamp_int, sanitize_display
124
125
126
127 type _IntMap = dict[str, int]
128 type _SymbolLogDict = dict[str, str | None]
129 logger = logging.getLogger(__name__)
130
131 _EventKind = Literal["created", "modified", "renamed", "moved", "deleted", "signature"]
132
133 # ---------------------------------------------------------------------------
134 # Repository helpers
135 # ---------------------------------------------------------------------------
136
137
138
139 # ---------------------------------------------------------------------------
140 # Event extraction
141 # ---------------------------------------------------------------------------
142
143
144 def _flat_ops(ops: list[DomainOp]) -> list[DomainOp]:
145 """Flatten PatchOp children into a single list for address scanning."""
146 result: list[DomainOp] = []
147 for op in ops:
148 if op["op"] == "patch":
149 result.extend(op["child_ops"])
150 else:
151 result.append(op)
152 return result
153
154
155 class SymbolEvent:
156 """A single event in a symbol's lifecycle, with optional memory metadata.
157
158 When the commit carrying this event was recorded with Phase-4.2 memory
159 metadata (``--event-type``, ``--agent-id``, ``--model-id``), the
160 corresponding fields are populated. They are ``None`` for commits that
161 pre-date Phase 4.2 or that were not made by an agent using those flags.
162
163 Attributes:
164 kind: The lifecycle event kind (created / modified / renamed / …).
165 commit: The full CommitRecord that introduced the change.
166 address: The symbol address at the time of this event.
167 detail: Human-readable description of what changed.
168 new_address: Forwarding address after a rename or cross-file move.
169 event_type: Knowtation memory-event kind from ``commit.metadata["event_type"]``,
170 or ``None`` if not present.
171 agent_id: The agent identity from ``commit.agent_id``, or ``None`` if empty.
172 model_id: The model identity from ``commit.model_id``, or ``None`` if empty.
173 """
174
175 __slots__ = (
176 "kind", "commit", "address", "detail", "new_address",
177 "event_type", "agent_id", "model_id",
178 )
179
180 def __init__(
181 self,
182 kind: _EventKind,
183 commit: CommitRecord,
184 address: str,
185 detail: str,
186 new_address: str | None = None,
187 event_type: str | None = None,
188 agent_id: str | None = None,
189 model_id: str | None = None,
190 ) -> None:
191 self.kind = kind
192 self.commit = commit
193 self.address = address
194 self.detail = detail
195 self.new_address = new_address
196 self.event_type = event_type
197 self.agent_id = agent_id
198 self.model_id = model_id
199
200 def to_dict(self) -> _SymbolLogDict:
201 """Serialise to a dict suitable for JSON output.
202
203 Returns:
204 A flat dict containing all event fields. ``event_type``,
205 ``agent_id``, and ``model_id`` are always present; they are
206 ``null`` when the commit carries no memory metadata.
207 """
208 return {
209 "event": self.kind,
210 "commit_id": self.commit.commit_id,
211 "message": self.commit.message,
212 "committed_at": self.commit.committed_at.isoformat(),
213 "address": self.address,
214 "detail": self.detail,
215 "new_address": self.new_address,
216 "event_type": self.event_type,
217 "agent_id": self.agent_id,
218 "model_id": self.model_id,
219 }
220
221
222 def _memory_meta_from_commit(commit: CommitRecord) -> tuple[str | None, str | None, str | None]:
223 """Extract Phase-4.2 memory metadata from a CommitRecord.
224
225 Reads ``commit.metadata["event_type"]``, ``commit.agent_id``, and
226 ``commit.model_id``, normalising empty strings to ``None`` so callers
227 can use a simple truthiness check.
228
229 Args:
230 commit: The CommitRecord to extract from.
231
232 Returns:
233 Tuple of ``(event_type, agent_id, model_id)`` where each element is
234 either a non-empty string or ``None``.
235 """
236 event_type: str | None = commit.metadata.get("event_type") or None
237 agent_id: str | None = (commit.agent_id or None) if commit.agent_id else None
238 model_id: str | None = (commit.model_id or None) if commit.model_id else None
239 return event_type, agent_id, model_id
240
241
242 def _find_events_in_commit(
243 commit: CommitRecord,
244 address: str,
245 ) -> tuple[list[SymbolEvent], str]:
246 """Scan *commit*'s structured delta for events touching *address*.
247
248 For each matching op, the resulting :class:`SymbolEvent` is populated
249 with any Phase-4.2 memory metadata present on the commit
250 (``event_type``, ``agent_id``, ``model_id``). This makes the symbol-log
251 a memory replay when the repo was committed with ``--event-type`` etc.
252
253 Args:
254 commit: The CommitRecord to scan.
255 address: Current symbol address to look for in the delta.
256
257 Returns:
258 Tuple ``(events, next_address)`` where *next_address* is the address
259 to track in older commits (updated on rename events so history is
260 continuous).
261 """
262 events: list[SymbolEvent] = []
263 next_address = address
264
265 if commit.structured_delta is None:
266 return events, next_address
267
268 event_type, agent_id, model_id = _memory_meta_from_commit(commit)
269 all_ops = _flat_ops(commit.structured_delta["ops"])
270
271 for op in all_ops:
272 op_address = op["address"]
273
274 if op["op"] == "insert" and op_address == address:
275 events.append(SymbolEvent(
276 kind="created",
277 commit=commit,
278 address=address,
279 detail=op.get("content_summary", "created"),
280 event_type=event_type,
281 agent_id=agent_id,
282 model_id=model_id,
283 ))
284
285 elif op["op"] == "delete" and op_address == address:
286 detail = op.get("content_summary", "deleted")
287 if "moved to" in detail:
288 events.append(SymbolEvent(
289 kind="moved",
290 commit=commit,
291 address=address,
292 detail=detail,
293 new_address=None,
294 event_type=event_type,
295 agent_id=agent_id,
296 model_id=model_id,
297 ))
298 else:
299 events.append(SymbolEvent(
300 kind="deleted",
301 commit=commit,
302 address=address,
303 detail=detail,
304 event_type=event_type,
305 agent_id=agent_id,
306 model_id=model_id,
307 ))
308
309 elif op["op"] == "replace" and op_address == address:
310 new_summary: str = op.get("new_summary", "")
311 if new_summary.startswith("renamed to "):
312 new_name = new_summary.removeprefix("renamed to ").strip()
313 file_prefix = address.rsplit("::", 1)[0]
314 new_addr = f"{file_prefix}::{new_name}"
315 events.append(SymbolEvent(
316 kind="renamed",
317 commit=commit,
318 address=address,
319 detail=f"{address.rsplit('::', 1)[-1]} → {new_name}",
320 new_address=new_addr,
321 event_type=event_type,
322 agent_id=agent_id,
323 model_id=model_id,
324 ))
325 next_address = new_addr
326 elif new_summary.startswith("moved to "):
327 events.append(SymbolEvent(
328 kind="moved",
329 commit=commit,
330 address=address,
331 detail=new_summary,
332 new_address=None,
333 event_type=event_type,
334 agent_id=agent_id,
335 model_id=model_id,
336 ))
337 elif "signature" in new_summary:
338 events.append(SymbolEvent(
339 kind="signature",
340 commit=commit,
341 address=address,
342 detail=new_summary,
343 event_type=event_type,
344 agent_id=agent_id,
345 model_id=model_id,
346 ))
347 else:
348 events.append(SymbolEvent(
349 kind="modified",
350 commit=commit,
351 address=address,
352 detail=new_summary or "modified",
353 event_type=event_type,
354 agent_id=agent_id,
355 model_id=model_id,
356 ))
357
358 return events, next_address
359
360
361 # ---------------------------------------------------------------------------
362 # Output
363 # ---------------------------------------------------------------------------
364
365
366 def _print_human(
367 address: str,
368 events: list[SymbolEvent],
369 total_commits: int,
370 truncated: bool,
371 ) -> None:
372 """Render the event list in human-readable text form.
373
374 When any event carries memory metadata (``event_type``, ``agent_id``,
375 ``model_id``), those fields are printed as indented lines below the event
376 kind line so the output reads as a memory replay.
377
378 Args:
379 address: The original symbol address requested.
380 events: Collected events, newest-first.
381 total_commits: Number of commits scanned during the walk.
382 truncated: True if the walk was capped by ``--max``.
383 """
384 print(f"\nSymbol: {sanitize_display(address)}")
385 print("─" * 62)
386
387 if truncated:
388 print(
389 f"\n⚠️ History may be incomplete — scanned {total_commits:,} commits "
390 f"(use --max to increase the limit).",
391 )
392
393 if not events:
394 print(" (no events found — symbol may not exist in this range)")
395 return
396
397 # Collected newest-first; reverse for chronological (oldest-first) display.
398 chrono = list(reversed(events))
399
400 counts: _IntMap = {}
401 for ev in chrono:
402 counts[ev.kind] = counts.get(ev.kind, 0) + 1
403 date_str = ev.commit.committed_at.strftime("%Y-%m-%d")
404 short_id = ev.commit.commit_id[:8]
405 print(f'\n● {short_id} {date_str} "{sanitize_display(ev.commit.message)}"')
406 print(f" {ev.kind:<14} {sanitize_display(ev.detail)}")
407 if ev.new_address:
408 print(f" (tracking continues as {sanitize_display(ev.new_address)})")
409 # Memory-replay metadata: shown when the commit carries Phase-4.2 fields.
410 if ev.event_type:
411 print(f" event_type: {sanitize_display(ev.event_type)}")
412 if ev.agent_id:
413 print(f" agent_id: {sanitize_display(ev.agent_id)}")
414 if ev.model_id:
415 print(f" model_id: {sanitize_display(ev.model_id)}")
416
417 total = len(events)
418 summary_parts = [f"{k}: {v}" for k, v in sorted(counts.items())]
419 print(f"\n{total} event(s) ({', '.join(summary_parts)})")
420
421
422 # ---------------------------------------------------------------------------
423 # Argument parser registration
424 # ---------------------------------------------------------------------------
425
426
427 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
428 """Register the symbol-log subcommand."""
429 parser = subparsers.add_parser(
430 "symbol-log",
431 help="Track a single symbol through the entire commit history.",
432 description=__doc__,
433 formatter_class=argparse.RawDescriptionHelpFormatter,
434 )
435 parser.add_argument(
436 "address",
437 metavar="ADDRESS",
438 help=(
439 'Fully-qualified symbol address, e.g. "src/utils.py::calculate_total" '
440 'or "src/models.py::User.save". Must contain "::".'
441 ),
442 )
443 parser.add_argument(
444 "--from",
445 dest="from_ref",
446 default=None,
447 metavar="REF",
448 help="Start walking from this commit / branch (default: HEAD).",
449 )
450 parser.add_argument(
451 "--max",
452 dest="max_commits",
453 type=int,
454 default=500,
455 metavar="N",
456 help="Maximum number of commits to inspect (default: 500).",
457 )
458 parser.add_argument(
459 "--json",
460 dest="as_json",
461 action="store_true",
462 help="Emit the full event list as structured JSON.",
463 )
464 parser.set_defaults(func=run)
465
466
467 # ---------------------------------------------------------------------------
468 # Command entry point
469 # ---------------------------------------------------------------------------
470
471
472 def run(args: argparse.Namespace) -> None:
473 """Track a single symbol through the entire commit history.
474
475 ``muse code symbol-log`` is impossible in Git: Git tracks file lines, not
476 semantic symbols. This command follows a function, class, or method
477 across every commit — detecting creation, implementation changes,
478 renames, cross-file moves, and deletion.
479
480 Unlike a plain ``git log``, Muse's walk crosses merge commit boundaries:
481 events from feature branches that were merged in are included, not hidden
482 behind the merge commit.
483
484 ADDRESS must be a fully-qualified symbol address::
485
486 muse code symbol-log "src/utils.py::calculate_total"
487 muse code symbol-log "src/models.py::User.save"
488 muse code symbol-log "api/handlers.go::Server.HandleRequest"
489 """
490 address: str = args.address
491 from_ref: str | None = args.from_ref
492 max_commits: int = clamp_int(args.max_commits, 1, 100000, 'max_commits')
493 as_json: bool = args.as_json
494
495 # ── Input validation ──────────────────────────────────────────────────────
496
497 if "::" not in address:
498 print(
499 f"❌ '{address}' is not a valid symbol address.\n"
500 " Expected 'file_path::SymbolName', "
501 "e.g. 'src/utils.py::calculate_total'.",
502 file=sys.stderr,
503 )
504 raise SystemExit(ExitCode.USER_ERROR)
505
506 if max_commits < 1:
507 print("❌ --max must be at least 1.", file=sys.stderr)
508 raise SystemExit(ExitCode.USER_ERROR)
509
510 # ── Repo / commit resolution ──────────────────────────────────────────────
511
512 root = require_repo()
513 repo_id = read_repo_id(root)
514 branch = read_current_branch(root)
515
516 start_commit = resolve_commit_ref(root, repo_id, branch, from_ref)
517 if start_commit is None:
518 label = from_ref or "HEAD"
519 print(f"❌ Commit '{label}' not found.", file=sys.stderr)
520 raise SystemExit(ExitCode.USER_ERROR)
521
522 # ── DAG walk ──────────────────────────────────────────────────────────────
523
524 commits, truncated = walk_commits_bfs(root, start_commit.commit_id, max_commits)
525
526 # ── Event scan ────────────────────────────────────────────────────────────
527 # Walk commits newest-first, tracking address changes on rename events.
528
529 current_address = address
530 all_events: list[SymbolEvent] = []
531
532 for commit in commits:
533 evs, current_address = _find_events_in_commit(commit, current_address)
534 all_events.extend(evs)
535
536 # ── Output ────────────────────────────────────────────────────────────────
537
538 if as_json:
539 print(json.dumps({
540 "address": address,
541 "start_ref": from_ref or "HEAD",
542 "total_commits_scanned": len(commits),
543 "truncated": truncated,
544 "events": [e.to_dict() for e in reversed(all_events)],
545 }, indent=2))
546 return
547
548 _print_human(address, all_events, total_commits=len(commits), truncated=truncated)
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