cat.py
python
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