cat.py python
668 lines 26.0 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 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`` the working tree is read from disk, so uncommitted edits are
24 immediately visible. With ``--at <ref>`` the specified committed snapshot is
25 used instead.
26
27 Exit codes
28 ----------
29 0 All requested symbols found and printed.
30 1 Address malformed, symbol not found, or file not tracked.
31 3 I/O error reading from the object store or disk.
32 """
33
34 from __future__ import annotations
35
36 import argparse
37 import json
38 import pathlib
39 import sys
40 import time
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 read_repo_id, require_repo
46 from muse.core.store import (
47 Manifest,
48 get_commit_snapshot_manifest,
49 get_head_snapshot_manifest,
50 read_current_branch,
51 resolve_commit_ref,
52 )
53 from muse.core.validation import clamp_int, sanitize_display
54 from muse.plugins.code.ast_parser import SymbolRecord, SymbolTree, adapter_for_path
55
56
57 type _FileCache = dict[str, tuple[bytes, "SymbolTree"]]
58 # ── Types ─────────────────────────────────────────────────────────────────────
59
60
61 class CatResult(TypedDict):
62 """One resolved symbol returned in --json mode."""
63
64 address: str
65 file_path: str
66 symbol: str
67 kind: str
68 lineno: int
69 end_lineno: int
70 source: str
71 source_ref: str # "working tree" | "commit <sha> on <branch>"
72
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
91 class _FileError(Exception):
92 """Raised by :func:`_get_file_bytes` instead of ``SystemExit`` so callers
93 can map the failure to a precise ``error_code`` in JSON output."""
94
95 def __init__(self, message: str, code: str, hint: str = "") -> None:
96 super().__init__(message)
97 self.code = code
98 self.hint = hint
99
100
101 # ── Helpers ───────────────────────────────────────────────────────────────────
102
103
104
105 def _get_file_bytes(
106 root: pathlib.Path,
107 file_path: str,
108 manifest: Manifest,
109 source_is_workdir: bool,
110 ) -> bytes:
111 """Return raw bytes for *file_path*, reading from disk or the object store.
112
113 When *source_is_workdir* is True we read from disk first (so uncommitted
114 edits are visible) and fall back to the object store only if the file has
115 been deleted. When False (historical commit) we always use the store.
116
117 Raises :class:`_FileError` on all failure paths so that callers can decide
118 how to surface the error (stderr + SystemExit for text mode; JSON errors
119 list for ``--json`` mode). This avoids swallowing the precise error code
120 in a bare ``except SystemExit`` handler.
121
122 Security
123 --------
124 Workdir reads include a symlink guard (symlinks are rejected) and a path
125 containment check (resolved path must be inside the repo root) to prevent
126 symlink-based directory traversal attacks where a tracked file is actually
127 a symlink pointing outside the repository.
128 """
129 if file_path not in manifest:
130 raise _FileError(
131 f"file not tracked in snapshot: {file_path}",
132 code="FILE_NOT_TRACKED",
133 hint="run `muse code add .` to stage all files, then `muse commit`",
134 )
135
136 if source_is_workdir:
137 disk = root / file_path
138 # Symlink guard: refuse to follow symlinks, which could point outside
139 # the repository and expose arbitrary files on the host filesystem.
140 if disk.is_symlink():
141 raise _FileError(
142 f"refusing to read symlink: {file_path}",
143 code="SYMLINK_REJECTED",
144 hint="dereference the symlink and commit the real file instead",
145 )
146 # Path containment: resolved path must stay inside the repo root.
147 try:
148 disk.resolve().relative_to(root.resolve())
149 except ValueError:
150 raise _FileError(
151 f"path escapes repository root: {file_path}",
152 code="PATH_TRAVERSAL",
153 hint="file paths must be relative to the repository root",
154 )
155 try:
156 return disk.read_bytes()
157 except OSError:
158 pass # deleted in working tree — fall through to object store
159
160 raw = read_object(root, manifest[file_path])
161 if raw is None:
162 raise _FileError(
163 f"blob not found in object store: {manifest[file_path][:12]}",
164 code="BLOB_NOT_FOUND",
165 hint="the object store may be corrupted; try `muse gc` to diagnose",
166 )
167 return raw
168
169
170 def _resolve_symbol(
171 tree: SymbolTree,
172 symbol_ref: str,
173 file_path: str,
174 ) -> tuple[SymbolRecord | None, str]:
175 """Resolve *symbol_ref* against *tree*.
176
177 Returns ``(record, "")`` on success or ``(None, error_message)`` on failure
178 so callers can decide how to surface the error (stderr vs JSON errors list).
179
180 Resolution order
181 ----------------
182 1. Exact ``qualified_name`` match (e.g. ``Invoice.compute_total``).
183 2. Bare ``name`` match when unambiguous (e.g. ``compute_total``).
184 3. Failure — returns ``None`` with a descriptive error message that lists
185 available symbols (capped at :data:`_MAX_AVAIL_SHOWN`).
186 """
187 # Exact qualified_name match first.
188 match: SymbolRecord | None = next(
189 (rec for rec in tree.values() if rec["qualified_name"] == symbol_ref),
190 None,
191 )
192 if match is not None:
193 return match, ""
194
195 # Fall back to bare name (unambiguous only).
196 candidates = [rec for rec in tree.values() if rec["name"] == symbol_ref]
197 if len(candidates) == 1:
198 return candidates[0], ""
199 if len(candidates) > 1:
200 opts = ", ".join(rec["qualified_name"] for rec in candidates)
201 return None, (
202 f"❌ Ambiguous symbol '{sanitize_display(symbol_ref)}' in "
203 f"{sanitize_display(file_path)}. Qualify it:\n {opts}"
204 )
205
206 # Not found — show a capped sample of available symbols.
207 available = sorted(
208 rec["qualified_name"]
209 for rec in tree.values()
210 if rec["kind"] != "import"
211 )
212 return None, (
213 f"❌ Symbol '{sanitize_display(symbol_ref)}' not found in "
214 f"{sanitize_display(file_path)}.\n"
215 f" Available ({len(available)} total): "
216 + ", ".join(available)
217 )
218
219
220 def _extract_source(raw: bytes, lineno: int, end_lineno: int, context: int = 0) -> str:
221 """Slice the source lines for a symbol, with optional surrounding context."""
222 text = raw.decode("utf-8", errors="replace")
223 lines = text.splitlines()
224 start = max(0, lineno - 1 - context)
225 end = min(len(lines), end_lineno + context)
226 return "\n".join(lines[start:end])
227
228
229 def _format_line_numbers(source: str, start_lineno: int, context: int = 0) -> str:
230 """Prefix each line with its 1-based line number."""
231 first = max(1, start_lineno - context)
232 lines = source.splitlines()
233 width = len(str(first + len(lines) - 1))
234 return "\n".join(f"{first + i:{width}d} {line}" for i, line in enumerate(lines))
235
236
237 # ── CLI registration ──────────────────────────────────────────────────────────
238
239
240 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
241 """Register the cat subcommand on *subparsers*."""
242 parser = subparsers.add_parser(
243 "cat",
244 help="Print the source code of one or more symbols.",
245 description=__doc__,
246 formatter_class=argparse.RawDescriptionHelpFormatter,
247 )
248 parser.add_argument(
249 "addresses",
250 nargs="*",
251 metavar="address",
252 help=(
253 "One or more symbol addresses: 'file.py::ClassName.method'. "
254 "When --all is given, treat each argument as a file path instead. "
255 "May be omitted when --file is provided."
256 ),
257 )
258 parser.add_argument(
259 "--at", default=None, metavar="REF",
260 help=(
261 "Commit ref (SHA prefix, branch, tag, HEAD~N) to read from. "
262 "Defaults to the working tree (disk content, uncommitted edits visible)."
263 ),
264 )
265 parser.add_argument(
266 "--all", "-a", action="store_true", dest="all_symbols",
267 help="Print every symbol in each file. Arguments are treated as file paths.",
268 )
269 parser.add_argument(
270 "--kind", "-k", default=None, metavar="KIND", dest="kind_filter",
271 help="With --all: restrict to symbols of this kind (function, class, method, …).",
272 )
273 parser.add_argument(
274 "--line-numbers", "-n", action="store_true", dest="line_numbers",
275 help="Prefix each output line with its 1-based line number.",
276 )
277 parser.add_argument(
278 "--context", "-C", default=0, type=int, metavar="N", dest="context",
279 help="Include N lines of context before and after each symbol (default: 0).",
280 )
281 parser.add_argument(
282 "--format", "-f",
283 default="text",
284 dest="fmt",
285 choices=("text", "json"),
286 help="Output format: text (default) or json.",
287 )
288 parser.add_argument(
289 "--json",
290 action="store_const",
291 const="json",
292 dest="fmt",
293 help="Shorthand for --format json.",
294 )
295 parser.add_argument(
296 "--file",
297 default=None,
298 metavar="PATH",
299 dest="file_alias",
300 help=(
301 "Convenience alias: treat PATH as a file and print all its symbols. "
302 "Equivalent to: muse code cat PATH --all. "
303 "Canonical form: muse code symbols --file PATH"
304 ),
305 )
306 parser.set_defaults(func=run)
307
308
309 # ── Main logic ────────────────────────────────────────────────────────────────
310
311
312 def run(args: argparse.Namespace) -> None:
313 """Print the source code of one or more symbols.
314
315 Resolves each address (``file.py::Symbol``) against the working tree or a
316 historical commit (``--at REF``), extracts the exact source lines from the
317 content-addressed object store or disk, and prints them.
318
319 Performance
320 -----------
321 File bytes and parsed symbol trees are cached per ``file_path`` within a
322 single invocation. Batch lookups of 50 symbols from the same file perform
323 one read + one parse, not 50.
324
325 Security
326 --------
327 Workdir reads reject symlinks and enforce path containment within the
328 repository root to prevent directory traversal attacks. All user-supplied
329 strings in error output are passed through :func:`~muse.core.validation.sanitize_display`.
330
331 JSON output
332 -----------
333 ``--json`` (or ``--format json``) emits a single object::
334
335 {
336 "source_ref": "working tree" | "commit <sha> on <branch>",
337 "results": [CatResult, ...],
338 "errors": [CatError, ...],
339 "elapsed_seconds": float
340 }
341
342 Exits 0 when all addresses resolved; 1 when any error is present.
343 """
344 t0 = time.monotonic()
345
346 addresses: list[str] = args.addresses
347 at: str | None = args.at
348 all_symbols: bool = args.all_symbols
349 kind_filter: str | None = args.kind_filter
350
351 # --file PATH is a convenience alias for `muse code cat PATH --all`.
352 # Agents and users reaching for --file (by analogy with muse code symbols)
353 # get the same result without an argparse error.
354 if args.file_alias is not None:
355 addresses = [args.file_alias] + addresses
356 all_symbols = True
357
358 if not addresses:
359 msg = "no address given — usage: muse code cat FILE::Symbol (or --file FILE)"
360 if fmt == "json":
361 print(json.dumps({"error": msg, "exit_code": ExitCode.USER_ERROR}))
362 else:
363 print(f"❌ {msg}", file=sys.stderr)
364 raise SystemExit(ExitCode.USER_ERROR)
365 line_numbers: bool = args.line_numbers
366 context: int = clamp_int(args.context, 0, 500, 'context')
367 fmt: str = args.fmt
368 as_json = fmt == "json"
369
370 root = require_repo()
371 repo_id = read_repo_id(root)
372 branch = read_current_branch(root)
373
374 # ── Resolve the manifest and source label ─────────────────────────────────
375 source_is_workdir = at is None
376 manifest: Manifest
377 source_ref: str
378
379 if source_is_workdir:
380 manifest = get_head_snapshot_manifest(root, repo_id, branch) or {}
381 source_ref = "working tree"
382 else:
383 resolved = resolve_commit_ref(root, repo_id, branch, at)
384 if resolved is None:
385 msg = f"Ref not found: {sanitize_display(at or '')}"
386 if as_json:
387 print(json.dumps({"error": msg, "exit_code": ExitCode.USER_ERROR}))
388 else:
389 print(f"❌ {msg}", file=sys.stderr)
390 raise SystemExit(ExitCode.USER_ERROR)
391 manifest = get_commit_snapshot_manifest(root, resolved.commit_id) or {}
392 source_ref = f"commit {resolved.commit_id[:8]} on {branch}"
393
394 # ── Per-invocation file cache: path → (raw bytes, symbol tree) ────────────
395 # Avoids re-reading and re-parsing the same file for each address in a
396 # batch request (e.g. 50 addresses all referencing billing.py).
397 _file_cache: _FileCache = {}
398 _file_failed: set[str] = set() # paths that errored; skip on repeat
399
400 def _cached_file(file_path: str) -> tuple[bytes, SymbolTree] | _FileError:
401 """Return cached (raw, tree) for *file_path*, or a _FileError."""
402 if file_path in _file_cache:
403 return _file_cache[file_path]
404 if file_path in _file_failed:
405 return _FileError(
406 f"file not tracked in snapshot: {file_path}",
407 code="FILE_NOT_TRACKED",
408 hint="run `muse code add .` to stage all files, then `muse commit`",
409 )
410 try:
411 raw = _get_file_bytes(root, file_path, manifest, source_is_workdir)
412 except _FileError as exc:
413 _file_failed.add(file_path)
414 return exc
415 adapter = adapter_for_path(file_path)
416 tree = adapter.parse_symbols(raw, file_path)
417 _file_cache[file_path] = (raw, tree)
418 return (raw, tree)
419
420 # ── Dispatch: --all mode (file paths) vs address mode ────────────────────
421 results: list[CatResult] = []
422 errors: list[CatError] = []
423 warnings: list[dict[str, object]] = []
424 any_ok = False
425
426 if all_symbols:
427 for file_path in addresses:
428 cached = _cached_file(file_path)
429 if isinstance(cached, _FileError):
430 errors.append({
431 "address": file_path,
432 "error": str(cached),
433 "error_code": cached.code,
434 "hint": cached.hint,
435 })
436 if not as_json:
437 print(f"❌ {sanitize_display(str(cached))}", file=sys.stderr)
438 continue
439
440 raw, tree = cached
441 syms = [
442 rec for rec in tree.values()
443 if rec["kind"] != "import"
444 and (kind_filter is None or rec["kind"] == kind_filter)
445 ]
446 syms.sort(key=lambda r: r["lineno"])
447
448 for rec in syms:
449 lineno = rec["lineno"]
450 end_lineno = rec["end_lineno"]
451 src = _extract_source(raw, lineno, end_lineno, context)
452 if line_numbers:
453 src = _format_line_numbers(src, lineno, context)
454 result: CatResult = {
455 "address": f"{file_path}::{rec['qualified_name']}",
456 "file_path": file_path,
457 "symbol": rec["qualified_name"],
458 "kind": rec["kind"],
459 "lineno": lineno,
460 "end_lineno": end_lineno,
461 "source": src,
462 "source_ref": source_ref,
463 }
464 results.append(result)
465 any_ok = True
466 else:
467 for address in addresses:
468 if "::" not in address:
469 hint = (
470 f"To read a symbol: muse code cat \"{address}::SymbolName\"\n"
471 f"To list symbols: muse code symbols --file {address}\n"
472 f"To read the file: use the Read tool or your editor"
473 )
474 errors.append({
475 "address": address,
476 "error": f"{address!r} is a file path, not a symbol address — add '::SymbolName'",
477 "error_code": "INVALID_ADDRESS",
478 "hint": hint,
479 })
480 if not as_json:
481 print(
482 f"❌ {sanitize_display(address)!r} is a file path, not a symbol address.\n"
483 f" To read a symbol: muse code cat \"{address}::SymbolName\"\n"
484 f" To list symbols: muse code symbols --file {address}\n"
485 f" To read the file: use the Read tool or your editor",
486 file=sys.stderr,
487 )
488 raise SystemExit(ExitCode.USER_ERROR)
489 continue
490
491 file_path, _, symbol_ref = address.partition("::")
492
493 cached = _cached_file(file_path)
494 if isinstance(cached, _FileError):
495 errors.append({
496 "address": address,
497 "error": str(cached),
498 "error_code": cached.code,
499 "hint": cached.hint,
500 })
501 if not as_json:
502 print(f"❌ {sanitize_display(str(cached))}", file=sys.stderr)
503 raise SystemExit(ExitCode.USER_ERROR)
504 continue
505
506 raw, tree = cached
507
508 if not tree:
509 errors.append({
510 "address": address,
511 "error": f"no symbols found in {file_path}",
512 "error_code": "SYMBOL_PARSE_EMPTY",
513 "hint": (
514 "symbol cache miss — run `muse code add .` to rebuild the index, "
515 "or check that the file contains parseable Python/JS/TS"
516 ),
517 })
518 if not as_json:
519 print(
520 f"❌ no symbols found in {sanitize_display(file_path)}",
521 file=sys.stderr,
522 )
523 raise SystemExit(ExitCode.USER_ERROR)
524 continue
525
526 # Filter out import pseudo-symbols before matching.
527 code_tree: SymbolTree = {
528 addr: rec for addr, rec in tree.items() if rec["kind"] != "import"
529 }
530
531 found, err_msg = _resolve_symbol(code_tree, symbol_ref, file_path)
532 if found is None:
533 # Global fallback: search all other tracked files for the symbol.
534 # Covers the common agent mistake of specifying the wrong file —
535 # e.g. `file.py::symbol` when the symbol is actually in `other.py`.
536 fb_matches: list[tuple[str, SymbolRecord, bytes]] = []
537 for tracked_path in manifest:
538 if tracked_path == file_path:
539 continue
540 fb_cached = _cached_file(tracked_path)
541 if isinstance(fb_cached, _FileError):
542 continue
543 fb_raw, fb_tree = fb_cached
544 fb_code_tree: SymbolTree = {
545 a: r for a, r in fb_tree.items() if r["kind"] != "import"
546 }
547 fb_found, _ = _resolve_symbol(fb_code_tree, symbol_ref, tracked_path)
548 if fb_found is not None:
549 fb_matches.append((tracked_path, fb_found, fb_raw))
550
551 if len(fb_matches) == 1:
552 # Unambiguous — cat from the actual file, note the redirect.
553 actual_path, actual_rec, actual_raw = fb_matches[0]
554 fb_lineno = actual_rec["lineno"]
555 fb_end_lineno = actual_rec["end_lineno"]
556 fb_src = _extract_source(actual_raw, fb_lineno, fb_end_lineno, context)
557 if line_numbers:
558 fb_src = _format_line_numbers(fb_src, fb_lineno, context)
559 result = {
560 "address": f"{actual_path}::{actual_rec['qualified_name']}",
561 "file_path": actual_path,
562 "symbol": actual_rec["qualified_name"],
563 "kind": actual_rec["kind"],
564 "lineno": fb_lineno,
565 "end_lineno": fb_end_lineno,
566 "source": fb_src,
567 "source_ref": source_ref,
568 }
569 results.append(result)
570 any_ok = True
571 if not as_json:
572 note = (
573 f"# note: '{sanitize_display(symbol_ref)}' not in "
574 f"{sanitize_display(file_path)} — found in "
575 f"{sanitize_display(actual_path)}"
576 )
577 print(note)
578 continue
579
580 if len(fb_matches) > 1:
581 locs_inline = ", ".join(
582 f"{p}::{r['qualified_name']}" for p, r, _ in fb_matches
583 )
584 locs_lines = "\n ".join(
585 f"{p}::{r['qualified_name']}" for p, r, _ in fb_matches
586 )
587 if not as_json:
588 print(
589 f"⚠️ '{sanitize_display(symbol_ref)}' not in "
590 f"{sanitize_display(file_path)} — found in multiple files:\n"
591 f" {locs_lines}\n"
592 f" Specify the file explicitly.",
593 file=sys.stderr,
594 )
595 else:
596 warnings.append({
597 "address": address,
598 "warning": f"symbol not found in {file_path}; ambiguous across: {locs_inline}",
599 "warning_code": "SYMBOL_AMBIGUOUS",
600 "candidates": [
601 f"{p}::{r['qualified_name']}" for p, r, _ in fb_matches
602 ],
603 "hint": "specify the file explicitly",
604 })
605 continue
606
607 # Truly not found anywhere.
608 errors.append({
609 "address": address,
610 "error": f"symbol not found: {symbol_ref}",
611 "error_code": "SYMBOL_NOT_FOUND",
612 "hint": (
613 f"run `muse code symbols --file {file_path}` to list available symbols, "
614 f"or `muse code grep \"{symbol_ref}\"` to search the full snapshot"
615 ),
616 })
617 if not as_json:
618 print(err_msg, file=sys.stderr)
619 raise SystemExit(ExitCode.USER_ERROR)
620 continue
621
622 lineno = found["lineno"]
623 end_lineno = found["end_lineno"]
624 src = _extract_source(raw, lineno, end_lineno, context)
625 if line_numbers:
626 src = _format_line_numbers(src, lineno, context)
627 result = {
628 "address": address,
629 "file_path": file_path,
630 "symbol": found["qualified_name"],
631 "kind": found["kind"],
632 "lineno": lineno,
633 "end_lineno": end_lineno,
634 "source": src,
635 "source_ref": source_ref,
636 }
637 results.append(result)
638 any_ok = True
639
640 # ── Output ────────────────────────────────────────────────────────────────
641 elapsed = round(time.monotonic() - t0, 4)
642
643 if as_json:
644 out: dict[str, object] = {
645 "source_ref": source_ref,
646 "results": results,
647 "errors": errors,
648 "elapsed_seconds": elapsed,
649 }
650 if warnings:
651 out["warnings"] = warnings
652 print(json.dumps(out, indent=2))
653 raise SystemExit(0 if not errors else ExitCode.USER_ERROR)
654
655 for result in results:
656 header = (
657 f"# {result['file_path']}::{result['symbol']}"
658 f" [{result['kind']}]"
659 f" L{result['lineno']}–{result['end_lineno']}"
660 f" ({result['source_ref']})"
661 )
662 print(header)
663 print(result["source"])
664 if len(results) > 1:
665 print() # blank separator between multiple symbols
666
667 if errors and not as_json:
668 raise SystemExit(ExitCode.USER_ERROR)
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