gabriel / muse public
find_symbol.py python
638 lines 21.3 KB
Raw
sha256:1ddad36d76d3a8d323f9b3664169cb184b7a38b39208214a2ae504154260826f fix: show full cryptographic IDs in all human-readable CLI output Sonnet 4.6 patch 35 days ago
1 """muse code find-symbol — cross-commit, cross-branch symbol search.
2
3 Closes two architectural gaps that ``muse code query`` cannot address:
4
5 1. **Temporal search**: ``muse code query hash=a3f2c9`` queries *one* snapshot.
6 ``muse code find-symbol --hash a3f2c9`` searches *every commit ever recorded*,
7 finding the exact moment a function body first entered the repository.
8
9 2. **Cross-branch presence**: if two branches independently introduced the
10 same function body, ``muse code find-symbol --hash a3f2c9 --all-branches``
11 finds both.
12
13 How it works
14 ------------
15 Every ``CommitRecord`` carries a ``structured_delta`` — the typed ``DomainOp``
16 tree produced at commit time. ``InsertOp`` entries in that delta record
17 exactly which symbols were *added* in each commit, including their
18 ``content_id``, ``body_hash``, and ``name`` (embedded in the address and
19 ``content_summary``).
20
21 ``muse code find-symbol`` walks all commits in the object store (or a single
22 branch's linear history with ``--branch``), ordered oldest-first, and scans
23 their ``InsertOp`` entries for symbols matching the given predicates. This
24 gives true cross-branch, temporally-ordered results with no full-snapshot
25 re-parse (except when ``--hash`` is given, where the shared ``SymbolCache``
26 ensures each blob is parsed at most once regardless of how many commits
27 reference it).
28
29 With ``--all-branches``, it also checks the current HEAD snapshot of every
30 branch tip to show where the symbol lives right now.
31
32 Usage::
33
34 muse code find-symbol --hash a3f2c9
35 muse code find-symbol --name validate_amount
36 muse code find-symbol --name "validate*"
37 muse code find-symbol --hash a3f2c9 --all-branches
38 muse code find-symbol --kind function --name compute
39 muse code find-symbol --name process --file src/core/processor.py
40 muse code find-symbol --name "render*" --branch feat/ui --first
41 muse code find-symbol --kind class --since 2025-01-01 --count
42 muse code find-symbol --name checkout --last --json
43 muse code find-symbol --name "parse*" --limit 20
44
45 Flags:
46
47 ``--hash HASH``
48 Match symbols whose ``content_id`` starts with this prefix (≥ 4 chars).
49
50 ``--name NAME``
51 Match symbols whose name exactly equals NAME (case-insensitive).
52 Append ``*`` for prefix matching.
53
54 ``--kind KIND``
55 Restrict to a specific symbol kind (function, class, method, …).
56
57 ``--file PATH``
58 Restrict to symbols defined in this exact file path.
59
60 ``--branch BRANCH``
61 Walk only this branch's linear history instead of all object-store commits.
62
63 ``--since DATE``
64 Ignore commits before DATE (YYYY-MM-DD).
65
66 ``--until DATE``
67 Ignore commits after DATE (YYYY-MM-DD).
68
69 ``--limit N``
70 Stop after N results.
71
72 ``--first``
73 Show only the first appearance of each unique symbol address.
74
75 ``--last``
76 Show only the most recent appearance of each unique symbol address.
77
78 ``--count``
79 Print only the total count of matching appearances.
80
81 ``--all-branches``
82 Also report which branch tips currently contain matching symbols.
83
84 ``--json``
85 Emit results as JSON.
86 """
87
88 from __future__ import annotations
89
90 import argparse
91 import datetime
92 import json
93 import logging
94 import pathlib
95 import sys
96
97 from muse.core._types import Manifest, Metadata
98 from muse.core.errors import ExitCode
99 from muse.core.repo import read_repo_id, require_repo
100 from muse.core.store import (
101 CommitRecord,
102 get_all_commits,
103 get_commit_snapshot_manifest,
104 get_head_commit_id,
105 walk_commits_between,
106 )
107 from muse.core.symbol_cache import SymbolCache, load_symbol_cache
108 from muse.domain import DomainOp
109 from muse.plugins.code._query import symbols_for_snapshot
110 from muse.core.validation import clamp_int, sanitize_display
111
112 logger = logging.getLogger(__name__)
113
114 _MIN_HASH_PREFIX = 4
115
116 type _AppearanceMap = dict[str, "_Appearance"]
117
118
119
120 # ---------------------------------------------------------------------------
121 # Branch listing
122 # ---------------------------------------------------------------------------
123
124
125 def _list_branches(root: pathlib.Path) -> list[str]:
126 """Return all branch names recorded in ``.muse/refs/heads/``."""
127 heads_dir = root / ".muse" / "refs" / "heads"
128 if not heads_dir.exists():
129 return []
130 return sorted(p.name for p in heads_dir.iterdir() if p.is_file())
131
132
133 # ---------------------------------------------------------------------------
134 # Op flattening
135 # ---------------------------------------------------------------------------
136
137
138 def _flat_insert_ops(ops: list[DomainOp]) -> list[DomainOp]:
139 """Return all InsertOp leaves, including children of PatchOps."""
140 result: list[DomainOp] = []
141 for op in ops:
142 if op["op"] == "patch":
143 for child in op["child_ops"]:
144 if child["op"] == "insert":
145 result.append(child)
146 elif op["op"] == "insert":
147 result.append(op)
148 return result
149
150
151 # ---------------------------------------------------------------------------
152 # Name matching
153 # ---------------------------------------------------------------------------
154
155
156 def _name_matches(name: str, pattern: str) -> bool:
157 """Case-insensitive exact or prefix (trailing ``*``) match."""
158 p = pattern.lower()
159 n = name.lower()
160 return n.startswith(p[:-1]) if p.endswith("*") else n == p
161
162
163 # ---------------------------------------------------------------------------
164 # Content-id lookup via shared SymbolCache
165 # ---------------------------------------------------------------------------
166
167
168 def _content_id_for_address(
169 root: pathlib.Path,
170 manifest: Manifest,
171 address: str,
172 cache: SymbolCache,
173 ) -> str | None:
174 """Return the ``content_id`` for *address* using the shared SymbolCache.
175
176 Uses ``file_filter`` so only the relevant file blob is parsed/cached,
177 not the entire snapshot.
178 """
179 if "::" not in address:
180 return None
181 file_path = address.split("::")[0]
182 sym_map = symbols_for_snapshot(root, manifest, file_filter=file_path, cache=cache)
183 tree = sym_map.get(file_path)
184 if tree is None:
185 return None
186 rec = tree.get(address)
187 return rec["content_id"] if rec is not None else None
188
189
190 # ---------------------------------------------------------------------------
191 # Result types
192 # ---------------------------------------------------------------------------
193
194
195 class _Appearance:
196 """One occurrence of a matching symbol across commit history."""
197
198 __slots__ = ("content_id", "address", "commit", "name", "kind")
199
200 def __init__(
201 self,
202 content_id: str,
203 address: str,
204 commit: CommitRecord,
205 name: str,
206 kind: str,
207 ) -> None:
208 self.content_id = content_id
209 self.address = address
210 self.commit = commit
211 self.name = name
212 self.kind = kind
213
214 def to_dict(self) -> Metadata:
215 return {
216 "content_id": self.content_id,
217 "address": self.address,
218 "name": self.name,
219 "kind": self.kind,
220 "commit_id": self.commit.commit_id,
221 "commit_message": self.commit.message,
222 "committed_at": self.commit.committed_at.isoformat(),
223 "branch": self.commit.branch,
224 }
225
226
227 class _BranchPresence:
228 """Whether a matching symbol currently lives in a branch's HEAD."""
229
230 __slots__ = ("branch", "address", "content_id")
231
232 def __init__(self, branch: str, address: str, content_id: str) -> None:
233 self.branch = branch
234 self.address = address
235 self.content_id = content_id
236
237 def to_dict(self) -> Metadata:
238 return {
239 "branch": self.branch,
240 "address": self.address,
241 "content_id": self.content_id,
242 }
243
244
245 # ---------------------------------------------------------------------------
246 # Core search
247 # ---------------------------------------------------------------------------
248
249
250 def _gather_commits(
251 root: pathlib.Path,
252 branch: str | None,
253 ) -> list[CommitRecord]:
254 """Return commits to search, oldest-first.
255
256 When *branch* is given, walks only that branch's linear history.
257 Otherwise returns every commit in the object store.
258 """
259 if branch is not None:
260 tip = get_head_commit_id(root, branch)
261 if tip is None:
262 return []
263 return list(reversed(walk_commits_between(root, tip)))
264 return sorted(get_all_commits(root), key=lambda c: c.committed_at)
265
266
267 def _search_all_commits(
268 root: pathlib.Path,
269 hash_prefix: str | None,
270 name_pattern: str | None,
271 kind_filter: str | None,
272 file_filter: str | None,
273 since: datetime.date | None,
274 until: datetime.date | None,
275 first_only: bool,
276 last_only: bool,
277 limit: int | None,
278 branch: str | None,
279 cache: SymbolCache,
280 ) -> list[_Appearance]:
281 """Walk CommitRecords oldest-first, collecting InsertOp matches.
282
283 The shared ``SymbolCache`` ensures each source blob is parsed at most
284 once across the entire walk — critical for ``--hash`` searches where
285 many commits may reference the same file blob.
286 """
287 commits = _gather_commits(root, branch)
288 if not commits:
289 return []
290
291 appearances: list[_Appearance] = []
292 seen_addresses: set[str] = set()
293 last_by_address: _AppearanceMap = {}
294
295 for commit in commits:
296 if since is not None and commit.committed_at.date() < since:
297 continue
298 if until is not None and commit.committed_at.date() > until:
299 continue
300 if commit.structured_delta is None:
301 continue
302
303 insert_ops = _flat_insert_ops(commit.structured_delta["ops"])
304 manifest: Manifest | None = None # lazy-load only when hash_prefix set
305
306 for op in insert_ops:
307 address = op["address"]
308 if "::" not in address:
309 continue # file-level op, not a symbol
310
311 sym_file = address.split("::")[0]
312 if file_filter and sym_file != file_filter:
313 continue
314
315 content_summary: str = op["content_summary"] if op["op"] == "insert" else ""
316 parts = content_summary.strip().split(None, 1)
317 sym_kind = parts[0] if parts else ""
318 sym_name = parts[1].split()[0] if len(parts) > 1 else address.split("::")[-1]
319
320 if name_pattern and not _name_matches(sym_name, name_pattern):
321 continue
322 if kind_filter and sym_kind.lower() != kind_filter.lower():
323 continue
324
325 content_id = ""
326 if hash_prefix:
327 if manifest is None:
328 manifest = get_commit_snapshot_manifest(root, commit.commit_id)
329 if manifest is None:
330 continue
331 content_id = _content_id_for_address(root, manifest, address, cache) or ""
332 if not content_id.startswith(hash_prefix.lower()):
333 continue
334
335 if first_only and address in seen_addresses:
336 continue
337 seen_addresses.add(address)
338
339 ap = _Appearance(
340 content_id=content_id,
341 address=address,
342 commit=commit,
343 name=sym_name,
344 kind=sym_kind,
345 )
346
347 if last_only:
348 last_by_address[address] = ap
349 else:
350 appearances.append(ap)
351 if limit is not None and len(appearances) >= limit:
352 return appearances
353
354 if last_only:
355 result = list(last_by_address.values())
356 return result[-limit:] if limit is not None else result
357
358 return appearances
359
360
361 # ---------------------------------------------------------------------------
362 # Branch presence check
363 # ---------------------------------------------------------------------------
364
365
366 def _branch_presence(
367 root: pathlib.Path,
368 hash_prefix: str | None,
369 name_pattern: str | None,
370 kind_filter: str | None,
371 file_filter: str | None,
372 cache: SymbolCache,
373 ) -> list[_BranchPresence]:
374 """Check every branch HEAD snapshot for matching symbols."""
375 results: list[_BranchPresence] = []
376 for branch in _list_branches(root):
377 commit_id = get_head_commit_id(root, branch)
378 if commit_id is None:
379 continue
380 manifest = get_commit_snapshot_manifest(root, commit_id)
381 if manifest is None:
382 continue
383
384 sym_map = symbols_for_snapshot(
385 root,
386 manifest,
387 kind_filter=kind_filter,
388 file_filter=file_filter,
389 cache=cache,
390 )
391 for _file_path, tree in sym_map.items():
392 for address, rec in tree.items():
393 if name_pattern and not _name_matches(rec["name"], name_pattern):
394 continue
395 if hash_prefix and not rec["content_id"].startswith(hash_prefix.lower()):
396 continue
397 results.append(_BranchPresence(branch, address, rec["content_id"]))
398 return results
399
400
401 # ---------------------------------------------------------------------------
402 # Command registration
403 # ---------------------------------------------------------------------------
404
405
406 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
407 """Register the find-symbol subcommand."""
408 parser = subparsers.add_parser(
409 "find-symbol",
410 help="Search across ALL commits (every branch) for a symbol.",
411 description=__doc__,
412 formatter_class=argparse.RawDescriptionHelpFormatter,
413 )
414 parser.add_argument(
415 "--hash", default=None, metavar="HASH", dest="hash_prefix",
416 help=f"Find symbols whose content_id starts with this prefix (≥ {_MIN_HASH_PREFIX} chars).",
417 )
418 parser.add_argument(
419 "--name", "-n", default=None, metavar="NAME", dest="name_pattern",
420 help="Find symbols with this name (exact, case-insensitive). Append * for prefix.",
421 )
422 parser.add_argument(
423 "--kind", "-k", default=None, metavar="KIND", dest="kind_filter",
424 help="Restrict to symbols of this kind (function, class, method, …).",
425 )
426 parser.add_argument(
427 "--file", "-f", default=None, metavar="PATH", dest="file_filter",
428 help="Restrict to symbols defined in this file path.",
429 )
430 parser.add_argument(
431 "--branch", "-b", default=None, metavar="BRANCH", dest="branch",
432 help="Search only this branch's linear history instead of all object-store commits.",
433 )
434 parser.add_argument(
435 "--since", default=None, metavar="DATE", dest="since",
436 help="Ignore commits before DATE (YYYY-MM-DD).",
437 )
438 parser.add_argument(
439 "--until", default=None, metavar="DATE", dest="until",
440 help="Ignore commits after DATE (YYYY-MM-DD).",
441 )
442 parser.add_argument(
443 "--limit", type=int, default=None, metavar="N", dest="limit",
444 help="Stop after N appearances.",
445 )
446 parser.add_argument(
447 "--first", action="store_true", dest="first_only",
448 help="Show only the first appearance of each unique symbol address.",
449 )
450 parser.add_argument(
451 "--last", action="store_true", dest="last_only",
452 help="Show only the most recent appearance of each unique symbol address.",
453 )
454 parser.add_argument(
455 "--count", action="store_true", dest="count_only",
456 help="Print only the total count of matching appearances.",
457 )
458 parser.add_argument(
459 "--all-branches", action="store_true", dest="all_branches",
460 help="Also report which branch tips currently contain matching symbols.",
461 )
462 parser.add_argument(
463 "--json", action="store_true", dest="as_json",
464 help="Emit results as JSON.",
465 )
466 parser.set_defaults(func=run)
467
468
469 def run(args: argparse.Namespace) -> None:
470 """Search across ALL commits (every branch) for a symbol.
471
472 \\b
473 At least one of ``--hash``, ``--name``, or ``--kind`` is required.
474 ``--first`` and ``--last`` are mutually exclusive.
475
476 \\b
477 Examples::
478
479 muse code find-symbol --hash a3f2c9
480 muse code find-symbol --name validate_amount --kind function
481 muse code find-symbol --name "compute*" --first
482 muse code find-symbol --hash a3f2c9 --all-branches
483 muse code find-symbol --name process --file src/core/processor.py
484 muse code find-symbol --kind class --since 2025-01-01 --count
485 muse code find-symbol --name checkout --last --json
486 muse code find-symbol --name "render*" --branch feat/ui --limit 5
487 """
488 hash_prefix: str | None = args.hash_prefix
489 name_pattern: str | None = args.name_pattern
490 kind_filter: str | None = args.kind_filter
491 file_filter: str | None = args.file_filter
492 branch: str | None = args.branch
493 all_branches: bool = args.all_branches
494 first_only: bool = args.first_only
495 last_only: bool = args.last_only
496 count_only: bool = args.count_only
497 as_json: bool = args.as_json
498 limit: int | None = (clamp_int(args.limit, 1, 100_000, 'limit') if args.limit is not None else None)
499
500 root = require_repo()
501 read_repo_id(root)
502
503 if not hash_prefix and not name_pattern and not kind_filter:
504 print("❌ At least one of --hash, --name, or --kind is required.", file=sys.stderr)
505 raise SystemExit(ExitCode.USER_ERROR)
506
507 if first_only and last_only:
508 print("❌ --first and --last are mutually exclusive.", file=sys.stderr)
509 raise SystemExit(ExitCode.USER_ERROR)
510
511 if hash_prefix and len(hash_prefix) < _MIN_HASH_PREFIX:
512 print(
513 f"❌ --hash prefix must be at least {_MIN_HASH_PREFIX} characters "
514 "to avoid matching everything.",
515 file=sys.stderr,
516 )
517 raise SystemExit(ExitCode.USER_ERROR)
518
519 since: datetime.date | None = None
520 until: datetime.date | None = None
521 if args.since:
522 try:
523 since = datetime.date.fromisoformat(args.since)
524 except ValueError:
525 print(
526 f"❌ --since: invalid date '{args.since}' (expected YYYY-MM-DD).",
527 file=sys.stderr,
528 )
529 raise SystemExit(ExitCode.USER_ERROR)
530 if args.until:
531 try:
532 until = datetime.date.fromisoformat(args.until)
533 except ValueError:
534 print(
535 f"❌ --until: invalid date '{args.until}' (expected YYYY-MM-DD).",
536 file=sys.stderr,
537 )
538 raise SystemExit(ExitCode.USER_ERROR)
539
540 cache = load_symbol_cache(root)
541
542 appearances = _search_all_commits(
543 root,
544 hash_prefix=hash_prefix,
545 name_pattern=name_pattern,
546 kind_filter=kind_filter,
547 file_filter=file_filter,
548 since=since,
549 until=until,
550 first_only=first_only,
551 last_only=last_only,
552 limit=limit,
553 branch=branch,
554 cache=cache,
555 )
556
557 branch_hits: list[_BranchPresence] = []
558 if all_branches:
559 branch_hits = _branch_presence(
560 root,
561 hash_prefix=hash_prefix,
562 name_pattern=name_pattern,
563 kind_filter=kind_filter,
564 file_filter=file_filter,
565 cache=cache,
566 )
567
568 cache.save()
569
570 if count_only and not as_json:
571 print(len(appearances))
572 if all_branches:
573 print(f"branch_presence: {len(branch_hits)}")
574 return
575
576 if as_json:
577 print(json.dumps(
578 {
579 "query": {
580 "hash": hash_prefix,
581 "name": name_pattern,
582 "kind": kind_filter,
583 "file": file_filter,
584 "branch": branch,
585 "since": args.since,
586 "until": args.until,
587 "first_only": first_only,
588 "last_only": last_only,
589 "limit": limit,
590 },
591 "total": len(appearances),
592 "results": [a.to_dict() for a in appearances],
593 "branch_presence": [b.to_dict() for b in branch_hits] if all_branches else None,
594 },
595 indent=2,
596 ))
597 return
598
599 print(f"\nfind-symbol — {len(appearances)} match(es) across history")
600
601 query_parts: list[str] = []
602 if hash_prefix:
603 query_parts.append(f"hash prefix={hash_prefix}")
604 if name_pattern:
605 query_parts.append(f"name={name_pattern}")
606 if kind_filter:
607 query_parts.append(f"kind={kind_filter}")
608 if file_filter:
609 query_parts.append(f"file={file_filter}")
610 if branch:
611 query_parts.append(f"branch={branch}")
612 if since:
613 query_parts.append(f"since={since}")
614 if until:
615 query_parts.append(f"until={until}")
616 print(f"Query: {', '.join(query_parts)}")
617 print("─" * 62)
618
619 if not appearances:
620 print(" (no matching symbols found in commit history)")
621 else:
622 for ap in appearances:
623 date_str = ap.commit.committed_at.strftime("%Y-%m-%d")
624 short_id = ap.commit.commit_id[:8]
625 branch_label = f" [{ap.commit.branch}]" if ap.commit.branch else ""
626 print(f"\n {sanitize_display(ap.address)}")
627 print(f" {short_id} {date_str} \"{sanitize_display(ap.commit.message)}\"{branch_label}")
628 if ap.content_id:
629 print(f" content_id: {ap.content_id[:16]}..")
630
631 if all_branches:
632 print(f"\nBranch presence ({len(branch_hits)} hit(s)):")
633 print("─" * 62)
634 if not branch_hits:
635 print(" (symbol not found in any branch HEAD)")
636 else:
637 for bh in branch_hits:
638 print(f" [{sanitize_display(bh.branch)}] {sanitize_display(bh.address)} {bh.content_id[:16]}..")
File History 1 commit
sha256:1ddad36d76d3a8d323f9b3664169cb184b7a38b39208214a2ae504154260826f fix: show full cryptographic IDs in all human-readable CLI output Sonnet 4.6 patch 35 days ago