gabriel / muse public
cat.py python
759 lines 30.2 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 days ago
1 """muse code cat — print the source of one or more symbols from HEAD or any commit.
2
3 Address format::
4
5 muse code cat cache.py::LRUCache.get
6 muse code cat cache.py::LRUCache.get --at abc123
7 muse code cat cache.py::LRUCache.get --at v0.1.4
8
9 # Multiple symbols in one call (useful for agents):
10 muse code cat cache.py::LRUCache.get cache.py::LRUCache.set
11
12 # All symbols in a file:
13 muse code cat cache.py --all
14
15 # Structured output for downstream processing:
16 muse code cat cache.py::LRUCache.get --json
17 muse code cat cache.py::LRUCache.get --format json
18
19 The ``::`` separator is the same format used throughout Muse's symbol graph.
20 The right-hand side is matched against the symbol's ``qualified_name`` first,
21 then ``name`` (allowing short references like ``get`` when unambiguous).
22
23 Without ``--at``, a file is readable if it is in the HEAD snapshot or the
24 stage index (``muse code add`` has been called). Uncommitted working-tree
25 edits are visible because disk is preferred over the object store for tracked
26 files. Untracked files — on disk but not in the snapshot or stage index —
27 are rejected with ``FILE_NOT_TRACKED``. With ``--at <ref>`` the specified
28 committed snapshot is used instead.
29
30 Exit codes
31 ----------
32 0 All requested symbols found and printed.
33 1 Address malformed, symbol not found, or file not tracked.
34 3 I/O error reading from the object store or disk.
35 """
36
37 import argparse
38 import json
39 import pathlib
40 import sys
41 from typing import TypedDict
42
43 from muse.core.errors import ExitCode
44 from muse.core.object_store import read_object
45 from muse.core.repo import require_repo
46 from muse.core.types import Manifest
47 from muse.core.refs import read_current_branch
48 from muse.core.commits import resolve_commit_ref
49 from muse.core.snapshots import (
50 get_commit_snapshot_manifest,
51 get_head_snapshot_manifest,
52 )
53 from muse.core.validation import clamp_int, sanitize_display
54 from muse.core.envelope import EnvelopeJson, make_envelope
55 from muse.core.timing import start_timer
56 from muse.plugins.code.ast_parser import SymbolRecord, SymbolTree, adapter_for_path
57 from muse.plugins.code.stage import read_stage
58
59 type _FileCache = dict[str, tuple[bytes, "SymbolTree"]]
60 # ── Types ─────────────────────────────────────────────────────────────────────
61
62 class CatResult(TypedDict):
63 """One resolved symbol returned in --json mode."""
64
65 address: str
66 path: str
67 symbol: str
68 kind: str
69 lineno: int
70 end_lineno: int
71 source: str
72 source_ref: str # "working tree" | "commit <sha> on <branch>"
73
74 class CatError(TypedDict, total=False):
75 """A failed lookup returned in --json mode.
76
77 Fields
78 ------
79 address The address that was requested.
80 error Human-readable description of the failure.
81 error_code Machine-parseable failure category (always present).
82 hint Actionable recovery instruction for the caller.
83 """
84
85 address: str
86 error: str
87 error_code: str
88 hint: str
89
90 class _CatWarningDict(TypedDict, total=False):
91 address: str
92 warning: str
93 warning_code: str
94 candidates: list[str]
95 hint: str
96
97 class _CatOutputJson(EnvelopeJson, total=False):
98 """Top-level JSON envelope emitted by ``muse code cat --json``.
99
100 Fields
101 ------
102 source_ref "working tree" or "commit <sha> on <branch>".
103 results Resolved :class:`CatResult` entries.
104 errors Failed lookups as :class:`CatError` entries.
105 total_symbols Pre-filter symbol count; only present with ``--all``.
106 truncated True when ``--limit`` capped the results; only with ``--all``.
107 """
108
109 source_ref: str
110 results: list[CatResult]
111 errors: list[CatError]
112 total_symbols: int
113 truncated: bool
114
115 class _FileError(Exception):
116 """Raised by :func:`_get_file_bytes` instead of ``SystemExit`` so callers
117 can map the failure to a precise ``error_code`` in JSON output."""
118
119 def __init__(self, message: str, code: str, hint: str = "") -> None:
120 super().__init__(message)
121 self.code = code
122 self.hint = hint
123
124 # ── Helpers ───────────────────────────────────────────────────────────────────
125
126 def _get_file_bytes(
127 root: pathlib.Path,
128 file_path: str,
129 manifest: Manifest,
130 source_is_workdir: bool,
131 ) -> bytes:
132 """Return raw bytes for *file_path* from the object store or working tree.
133
134 A file is "tracked" if it appears in the HEAD snapshot manifest OR in the
135 stage index (staged-but-not-committed). Files that exist only on disk with
136 no tracking record are rejected with FILE_NOT_TRACKED — this prevents
137 silent symbol reads from arbitrary workspace files Muse knows nothing about.
138
139 When *source_is_workdir* is True and the file is tracked, disk is preferred
140 so uncommitted edits are visible. Falling back to the object store only
141 when the file has been deleted from disk. When False (historical commit)
142 the object store is always used.
143
144 Raises :class:`_FileError` on all failure paths so callers can decide how
145 to surface the error (stderr + SystemExit for text mode; JSON errors list
146 for ``--json`` mode).
147
148 Security
149 --------
150 Workdir reads include a symlink guard and path containment check to prevent
151 symlink-based directory traversal attacks.
152 """
153 if source_is_workdir:
154 disk = root / file_path
155 if disk.is_symlink():
156 raise _FileError(
157 f"refusing to read symlink: {file_path}",
158 code="SYMLINK_REJECTED",
159 hint="dereference the symlink and commit the real file instead",
160 )
161 try:
162 disk.resolve().relative_to(root.resolve())
163 except ValueError:
164 raise _FileError(
165 f"path escapes repository root: {file_path}",
166 code="PATH_TRAVERSAL",
167 hint="file paths must be relative to the repository root",
168 )
169
170 stage = read_stage(root)
171 in_manifest = file_path in manifest
172 stage_entry = stage.get(file_path)
173 in_stage = stage_entry is not None and stage_entry["mode"] != "D"
174
175 if not in_manifest and not in_stage:
176 raise _FileError(
177 f"file not tracked: {file_path}",
178 code="FILE_NOT_TRACKED",
179 hint="use 'muse code add <file>' to track it",
180 )
181
182 try:
183 return disk.read_bytes()
184 except (FileNotFoundError, OSError):
185 pass # deleted from disk — fall through to object store
186
187 if in_stage and stage_entry is not None:
188 raw = read_object(root, stage_entry["object_id"])
189 if raw is not None:
190 return raw
191 if in_manifest:
192 raw = read_object(root, manifest[file_path])
193 if raw is not None:
194 return raw
195 raise _FileError(
196 f"blob not found in object store for: {file_path}",
197 code="BLOB_NOT_FOUND",
198 hint="the object store may be corrupted; try `muse gc` to diagnose",
199 )
200
201 if file_path not in manifest:
202 raise _FileError(
203 f"file not tracked: {file_path}",
204 code="FILE_NOT_TRACKED",
205 hint="use 'muse code add <file>' to track it",
206 )
207
208 raw = read_object(root, manifest[file_path])
209 if raw is None:
210 raise _FileError(
211 f"blob not found in object store: {manifest[file_path]}",
212 code="BLOB_NOT_FOUND",
213 hint="the object store may be corrupted; try `muse gc` to diagnose",
214 )
215 return raw
216
217 def _resolve_symbol(
218 tree: SymbolTree,
219 symbol_ref: str,
220 file_path: str,
221 ) -> tuple[SymbolRecord | None, str]:
222 """Resolve *symbol_ref* against *tree*.
223
224 Returns ``(record, "")`` on success or ``(None, error_message)`` on failure
225 so callers can decide how to surface the error (stderr vs JSON errors list).
226
227 Resolution order
228 ----------------
229 1. Exact ``qualified_name`` match (e.g. ``Invoice.compute_total``).
230 2. Bare ``name`` match when unambiguous (e.g. ``compute_total``).
231 3. Failure — returns ``None`` with a descriptive error message that lists
232 available symbols (capped at :data:`_MAX_AVAIL_SHOWN`).
233 """
234 # Exact qualified_name match first.
235 match: SymbolRecord | None = next(
236 (rec for rec in tree.values() if rec["qualified_name"] == symbol_ref),
237 None,
238 )
239 if match is not None:
240 return match, ""
241
242 # Fall back to bare name (unambiguous only).
243 candidates = [rec for rec in tree.values() if rec["name"] == symbol_ref]
244 if len(candidates) == 1:
245 return candidates[0], ""
246 if len(candidates) > 1:
247 opts = ", ".join(rec["qualified_name"] for rec in candidates)
248 return None, (
249 f"❌ Ambiguous symbol '{sanitize_display(symbol_ref)}' in "
250 f"{sanitize_display(file_path)}. Qualify it:\n {opts}"
251 )
252
253 # Not found — show a capped sample of available symbols.
254 available = sorted(
255 rec["qualified_name"]
256 for rec in tree.values()
257 if rec["kind"] != "import"
258 )
259 return None, (
260 f"❌ Symbol '{sanitize_display(symbol_ref)}' not found in "
261 f"{sanitize_display(file_path)}.\n"
262 f" Available ({len(available)} total): {', '.join(available)}"
263 )
264
265 def _extract_source(raw: bytes, lineno: int, end_lineno: int, context: int = 0) -> str:
266 """Slice the source lines for a symbol, with optional surrounding context."""
267 text = raw.decode("utf-8", errors="replace")
268 lines = text.splitlines()
269 start = max(0, lineno - 1 - context)
270 end = min(len(lines), end_lineno + context)
271 return "\n".join(lines[start:end])
272
273 def _format_line_numbers(source: str, start_lineno: int, context: int = 0) -> str:
274 """Prefix each line with its 1-based line number."""
275 first = max(1, start_lineno - context)
276 lines = source.splitlines()
277 width = len(str(first + len(lines) - 1))
278 return "\n".join(f"{first + i:{width}d} {line}" for i, line in enumerate(lines))
279
280 # ── CLI registration ──────────────────────────────────────────────────────────
281
282 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
283 """Register the cat subcommand on *subparsers*."""
284 parser = subparsers.add_parser(
285 "cat",
286 help="Print the source code of one or more symbols.",
287 description=__doc__,
288 formatter_class=argparse.RawDescriptionHelpFormatter,
289 )
290 parser.add_argument(
291 "addresses",
292 nargs="*",
293 metavar="address",
294 help=(
295 "One or more symbol addresses: 'file.py::ClassName.method'. "
296 "When --all is given, treat each argument as a file path instead. "
297 "May be omitted when --file is provided."
298 ),
299 )
300 parser.add_argument(
301 "--at", default=None, metavar="REF",
302 help=(
303 "Commit ref (SHA prefix, branch, tag, HEAD~N) to read from. "
304 "Defaults to the working tree (disk content, uncommitted edits visible). "
305 "Mutually exclusive with --staged."
306 ),
307 )
308 parser.add_argument(
309 "--staged", action="store_true", default=False,
310 help=(
311 "Read the staged version of each file — the content that would be "
312 "committed if you ran 'muse commit' now. Ignores working-tree edits "
313 "made after the last 'muse code add'. Mirrors 'git show :path'."
314 ),
315 )
316 parser.add_argument(
317 "--all", "-a", action="store_true", dest="all_symbols",
318 help="Print every symbol in each file. Arguments are treated as file paths.",
319 )
320 parser.add_argument(
321 "--kind", "-k", default=None, metavar="KIND", dest="kind_filter",
322 help="With --all: restrict to symbols of this kind (function, class, method, …).",
323 )
324 parser.add_argument(
325 "--line-numbers", action="store_true", dest="line_numbers",
326 help="Prefix each output line with its 1-based line number.",
327 )
328 parser.add_argument(
329 "--context", "-C", default=0, type=int, metavar="N", dest="context",
330 help="Include N lines of context before and after each symbol (default: 0).",
331 )
332 parser.add_argument(
333 "--json", "-j",
334 action="store_true",
335 dest="json_out",
336 help="Emit JSON output to stdout.",
337 )
338 parser.add_argument(
339 "--file",
340 default=None,
341 metavar="PATH",
342 dest="file_alias",
343 help=(
344 "Convenience alias: treat PATH as a file and print all its symbols. "
345 "Equivalent to: muse code cat PATH --all. "
346 "Canonical form: muse code symbols --file PATH"
347 ),
348 )
349 parser.add_argument(
350 "--limit",
351 default=None,
352 type=int,
353 metavar="N",
354 dest="limit",
355 help=(
356 "With --all: cap results at N symbols. JSON output includes "
357 "'truncated: true' and 'total_symbols' when the cap applies."
358 ),
359 )
360 parser.set_defaults(func=run)
361
362 # ── Main logic ────────────────────────────────────────────────────────────────
363
364 def run(args: argparse.Namespace) -> None:
365 """Print the source code of one or more symbols by address.
366
367 Resolves each ``file.py::Symbol`` address against the working tree or a
368 historical commit (``--at REF``), extracts exact source lines from the
369 content-addressed object store or disk, and prints them. Use ``--all`` to
370 dump every symbol in a file; file bytes and symbol trees are cached per-file
371 so a 50-symbol batch costs one read, not 50.
372
373 Agent quickstart
374 ----------------
375 ::
376
377 muse code cat "cache.py::LRUCache.get" --json
378 muse code cat "cache.py::LRUCache.get" --at v0.1.4 --json
379 muse code cat "cache.py" --all --json
380
381 JSON fields
382 -----------
383 source_ref ``"working tree"`` or ``"commit <sha> on <branch>"``.
384 results List of resolved symbol objects, each with: ``address``,
385 ``name``, ``kind``, ``file``, ``source`` (full source text),
386 ``start_line``, ``end_line``.
387 errors List of error objects for unresolved addresses, each with:
388 ``address`` and ``message``.
389 exit_code 0 = all resolved; 1 = any error present.
390
391 Exit codes
392 ----------
393 0 All requested symbols found and printed.
394 1 Any address malformed, symbol not found, or file not tracked.
395 2 Not inside a Muse repository.
396 3 I/O error reading from the object store or disk.
397 """
398 elapsed = start_timer()
399
400 addresses: list[str] = args.addresses
401 at: str | None = args.at
402 staged: bool = getattr(args, "staged", False)
403 all_symbols: bool = args.all_symbols
404 kind_filter: str | None = args.kind_filter
405 line_numbers: bool = args.line_numbers
406 context: int = clamp_int(args.context, 0, 500, 'context')
407 json_out: bool = args.json_out
408 limit: int | None = getattr(args, "limit", None)
409
410 if staged and at is not None:
411 msg = "--staged and --at are mutually exclusive"
412 if json_out:
413 print(json.dumps({"error": msg, "exit_code": ExitCode.USER_ERROR}))
414 else:
415 print(f"❌ {msg}", file=sys.stderr)
416 raise SystemExit(ExitCode.USER_ERROR)
417
418 # --file PATH is a convenience alias for `muse code cat PATH --all`.
419 # Agents and users reaching for --file (by analogy with muse code symbols)
420 # get the same result without an argparse error.
421 if args.file_alias is not None:
422 addresses = [args.file_alias] + addresses
423 all_symbols = True
424
425 if not addresses:
426 msg = "no address given — usage: muse code cat FILE::Symbol (or --file FILE)"
427 if json_out:
428 print(json.dumps({"error": msg, "exit_code": ExitCode.USER_ERROR}))
429 else:
430 print(f"❌ {msg}", file=sys.stderr)
431 raise SystemExit(ExitCode.USER_ERROR)
432
433 root = require_repo()
434 branch = read_current_branch(root)
435
436 # ── Resolve the manifest and source label ─────────────────────────────────
437 source_is_workdir = at is None and not staged
438 manifest: Manifest
439 source_ref: str
440
441 if staged:
442 head_manifest = get_head_snapshot_manifest(root, branch) or {}
443 _stage = read_stage(root)
444 staged_manifest: dict[str, str] = dict(head_manifest)
445 for _path, _entry in _stage.items():
446 if _path.startswith(".muse/"):
447 continue
448 if _entry["mode"] == "D":
449 staged_manifest.pop(_path, None)
450 else:
451 staged_manifest[_path] = _entry["object_id"]
452 manifest = staged_manifest
453 source_ref = "staged"
454 elif source_is_workdir:
455 manifest = get_head_snapshot_manifest(root, branch) or {}
456 source_ref = "working tree"
457 else:
458 resolved = resolve_commit_ref(root, branch, at)
459 if resolved is None:
460 msg = f"Ref not found: {sanitize_display(at or '')}"
461 if json_out:
462 print(json.dumps({"error": msg, "exit_code": ExitCode.USER_ERROR}))
463 else:
464 print(f"❌ {msg}", file=sys.stderr)
465 raise SystemExit(ExitCode.USER_ERROR)
466 manifest = get_commit_snapshot_manifest(root, resolved.commit_id) or {}
467 source_ref = f"commit {resolved.commit_id} on {branch}"
468
469 # ── Per-invocation file cache: path → (raw bytes, symbol tree) ────────────
470 # Avoids re-reading and re-parsing the same file for each address in a
471 # batch request (e.g. 50 addresses all referencing billing.py).
472 _file_cache: _FileCache = {}
473 _file_failed: set[str] = set() # paths that errored; skip on repeat
474
475 def _cached_file(file_path: str) -> tuple[bytes, SymbolTree] | _FileError:
476 """Return cached (raw, tree) for *file_path*, or a _FileError."""
477 if file_path in _file_cache:
478 return _file_cache[file_path]
479 if file_path in _file_failed:
480 return _FileError(
481 f"file not tracked in snapshot: {file_path}",
482 code="FILE_NOT_TRACKED",
483 hint="run `muse code add .` to stage all files, then `muse commit`",
484 )
485 try:
486 raw = _get_file_bytes(root, file_path, manifest, source_is_workdir)
487 except _FileError as exc:
488 _file_failed.add(file_path)
489 return exc
490 adapter = adapter_for_path(file_path)
491 tree = adapter.parse_symbols(raw, file_path)
492 _file_cache[file_path] = (raw, tree)
493 return (raw, tree)
494
495 # ── Dispatch: --all mode (file paths) vs address mode ────────────────────
496 results: list[CatResult] = []
497 errors: list[CatError] = []
498 warnings: list[_CatWarningDict] = []
499 any_ok = False
500
501 total_symbols_all = 0 # pre-filter symbol count across all files (--all mode)
502
503 if all_symbols:
504 for file_path in addresses:
505 cached = _cached_file(file_path)
506 if isinstance(cached, _FileError):
507 errors.append({
508 "address": file_path,
509 "error": str(cached),
510 "error_code": cached.code,
511 "hint": cached.hint,
512 })
513 if not json_out:
514 print(f"❌ {sanitize_display(str(cached))}", file=sys.stderr)
515 continue
516
517 raw, tree = cached
518 # Count all non-import symbols before applying kind filter.
519 all_syms = [rec for rec in tree.values() if rec["kind"] != "import"]
520 total_symbols_all += len(all_syms)
521 syms = [
522 rec for rec in all_syms
523 if kind_filter is None or rec["kind"] == kind_filter
524 ]
525 syms.sort(key=lambda r: r["lineno"])
526
527 for rec in syms:
528 lineno = rec["lineno"]
529 end_lineno = rec["end_lineno"]
530 src = _extract_source(raw, lineno, end_lineno, context)
531 if line_numbers:
532 src = _format_line_numbers(src, lineno, context)
533 result: CatResult = {
534 "address": f"{file_path}::{rec['qualified_name']}",
535 "path": file_path,
536 "symbol": rec["qualified_name"],
537 "kind": rec["kind"],
538 "lineno": lineno,
539 "end_lineno": end_lineno,
540 "source": src,
541 "source_ref": source_ref,
542 }
543 results.append(result)
544 any_ok = True
545 else:
546 for address in addresses:
547 if "::" not in address:
548 hint = (
549 f"To read a symbol: muse code cat \"{address}::SymbolName\"\n"
550 f"To list symbols: muse code symbols --file {address}\n"
551 f"To read the file: use the Read tool or your editor"
552 )
553 errors.append({
554 "address": address,
555 "error": f"{address!r} is a file path, not a symbol address — add '::SymbolName'",
556 "error_code": "INVALID_ADDRESS",
557 "hint": hint,
558 })
559 if not json_out:
560 print(
561 f"❌ {sanitize_display(address)!r} is a file path, not a symbol address.\n"
562 f" To read a symbol: muse code cat \"{address}::SymbolName\"\n"
563 f" To list symbols: muse code symbols --file {address}\n"
564 f" To read the file: use the Read tool or your editor",
565 file=sys.stderr,
566 )
567 raise SystemExit(ExitCode.USER_ERROR)
568 continue
569
570 file_path, _, symbol_ref = address.partition("::")
571
572 cached = _cached_file(file_path)
573 if isinstance(cached, _FileError):
574 errors.append({
575 "address": address,
576 "error": str(cached),
577 "error_code": cached.code,
578 "hint": cached.hint,
579 })
580 if not json_out:
581 print(f"❌ {sanitize_display(str(cached))}", file=sys.stderr)
582 raise SystemExit(ExitCode.USER_ERROR)
583 continue
584
585 raw, tree = cached
586
587 if not tree:
588 errors.append({
589 "address": address,
590 "error": f"no symbols found in {file_path}",
591 "error_code": "SYMBOL_PARSE_EMPTY",
592 "hint": (
593 "symbol cache miss — run `muse code add .` to rebuild the index, "
594 "or check that the file contains parseable Python/JS/TS"
595 ),
596 })
597 if not json_out:
598 print(
599 f"❌ no symbols found in {sanitize_display(file_path)}",
600 file=sys.stderr,
601 )
602 raise SystemExit(ExitCode.USER_ERROR)
603 continue
604
605 # Filter out import pseudo-symbols before matching.
606 code_tree: SymbolTree = {
607 addr: rec for addr, rec in tree.items() if rec["kind"] != "import"
608 }
609
610 found, err_msg = _resolve_symbol(code_tree, symbol_ref, file_path)
611 if found is None:
612 # Global fallback: search all other tracked files for the symbol.
613 # Covers the common agent mistake of specifying the wrong file —
614 # e.g. `file.py::symbol` when the symbol is actually in `other.py`.
615 fb_matches: list[tuple[str, SymbolRecord, bytes]] = []
616 for tracked_path in manifest:
617 if tracked_path == file_path:
618 continue
619 fb_cached = _cached_file(tracked_path)
620 if isinstance(fb_cached, _FileError):
621 continue
622 fb_raw, fb_tree = fb_cached
623 fb_code_tree: SymbolTree = {
624 a: r for a, r in fb_tree.items() if r["kind"] != "import"
625 }
626 fb_found, _ = _resolve_symbol(fb_code_tree, symbol_ref, tracked_path)
627 if fb_found is not None:
628 fb_matches.append((tracked_path, fb_found, fb_raw))
629
630 if len(fb_matches) == 1:
631 # Unambiguous — cat from the actual file, note the redirect.
632 actual_path, actual_rec, actual_raw = fb_matches[0]
633 fb_lineno = actual_rec["lineno"]
634 fb_end_lineno = actual_rec["end_lineno"]
635 fb_src = _extract_source(actual_raw, fb_lineno, fb_end_lineno, context)
636 if line_numbers:
637 fb_src = _format_line_numbers(fb_src, fb_lineno, context)
638 result = {
639 "address": f"{actual_path}::{actual_rec['qualified_name']}",
640 "path": actual_path,
641 "symbol": actual_rec["qualified_name"],
642 "kind": actual_rec["kind"],
643 "lineno": fb_lineno,
644 "end_lineno": fb_end_lineno,
645 "source": fb_src,
646 "source_ref": source_ref,
647 "redirected_from": address, # original requested address
648 }
649 results.append(result)
650 any_ok = True
651 if not json_out:
652 note = (
653 f"# note: '{sanitize_display(symbol_ref)}' not in "
654 f"{sanitize_display(file_path)} — found in "
655 f"{sanitize_display(actual_path)}"
656 )
657 print(note)
658 continue
659
660 if len(fb_matches) > 1:
661 locs_inline = ", ".join(
662 f"{p}::{r['qualified_name']}" for p, r, _ in fb_matches
663 )
664 locs_lines = "\n ".join(
665 f"{p}::{r['qualified_name']}" for p, r, _ in fb_matches
666 )
667 if not json_out:
668 print(
669 f"⚠️ '{sanitize_display(symbol_ref)}' not in "
670 f"{sanitize_display(file_path)} — found in multiple files:\n"
671 f" {locs_lines}\n"
672 f" Specify the file explicitly.",
673 file=sys.stderr,
674 )
675 else:
676 warnings.append({
677 "address": address,
678 "warning": f"symbol not found in {file_path}; ambiguous across: {locs_inline}",
679 "warning_code": "SYMBOL_AMBIGUOUS",
680 "candidates": [
681 f"{p}::{r['qualified_name']}" for p, r, _ in fb_matches
682 ],
683 "hint": "specify the file explicitly",
684 })
685 continue
686
687 # Truly not found anywhere.
688 errors.append({
689 "address": address,
690 "error": f"symbol not found: {symbol_ref}",
691 "error_code": "SYMBOL_NOT_FOUND",
692 "hint": (
693 f"run `muse code symbols --file {file_path}` to list available symbols, "
694 f"or `muse code grep \"{symbol_ref}\"` to search the full snapshot"
695 ),
696 })
697 if not json_out:
698 print(err_msg, file=sys.stderr)
699 raise SystemExit(ExitCode.USER_ERROR)
700 continue
701
702 lineno = found["lineno"]
703 end_lineno = found["end_lineno"]
704 src = _extract_source(raw, lineno, end_lineno, context)
705 if line_numbers:
706 src = _format_line_numbers(src, lineno, context)
707 result = {
708 "address": address,
709 "path": file_path,
710 "symbol": found["qualified_name"],
711 "kind": found["kind"],
712 "lineno": lineno,
713 "end_lineno": end_lineno,
714 "source": src,
715 "source_ref": source_ref,
716 }
717 results.append(result)
718 any_ok = True
719
720 # ── Apply --limit (--all mode only; silently ignored in address mode) ────────
721 truncated = False
722 if all_symbols and limit is not None:
723 if limit < len(results):
724 results = results[:limit]
725 truncated = True
726
727 # ── Output ────────────────────────────────────────────────────────────────
728
729 if json_out:
730 _exit_code = 0 if not errors else ExitCode.USER_ERROR
731 out = _CatOutputJson(
732 **make_envelope(elapsed, exit_code=_exit_code, warnings=warnings or []),
733 source_ref=source_ref,
734 results=results,
735 errors=errors,
736 )
737 if all_symbols:
738 out["total_symbols"] = total_symbols_all
739 out["truncated"] = truncated
740 print(json.dumps(out))
741 raise SystemExit(_exit_code)
742
743 for result in results:
744 header = (
745 f"# {result['path']}::{result['symbol']}"
746 f" [{result['kind']}]"
747 f" L{result['lineno']}–{result['end_lineno']}"
748 f" ({result['source_ref']})"
749 )
750 print(header)
751 print(result["source"])
752 if len(results) > 1:
753 print() # blank separator between multiple symbols
754
755 if truncated:
756 print(f"# … truncated at {limit} symbol(s). Use --limit N or omit to see all.")
757
758 if errors and not json_out:
759 raise SystemExit(ExitCode.USER_ERROR)
File History 4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 23 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 29 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 29 days ago