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