gabriel / muse public
grep.py python
386 lines 14.9 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 123 days ago
1 """muse code grep -- semantic symbol search across the symbol graph.
2
3 Unlike ``git grep`` which searches raw text lines, ``muse code grep`` searches
4 the *typed symbol graph* -- only returning actual symbol declarations with
5 their kind, file, line number, and stable content hash.
6
7 No false positives from comments, string literals, or call sites. Every
8 result is a real symbol that exists in the repository.
9
10 By default, PATTERN is matched case-insensitively against the bare symbol
11 name. When PATTERN contains a ``.`` or ``::`` it is also matched against the
12 fully-qualified name, so ``Invoice.validate`` finds only that specific method
13 rather than every symbol named ``validate``.
14
15 Usage::
16
17 muse code grep "validate" # symbols whose name contains "validate"
18 muse code grep "Invoice.validate" # exact qualified-name match
19 muse code grep "^handle" --regex # names matching regex "^handle"
20 muse code grep "Invoice" --kind class # only class symbols
21 muse code grep "compute" --language go # only Go symbols (case-insensitive)
22 muse code grep "total" --file billing # scope to one file (fast)
23 muse code grep "total" --commit HEAD~5 # search a historical snapshot
24 muse code grep "validate" --count # just the total count
25 muse code grep "validate" --json # machine-readable output for agents
26
27 Output::
28
29 muse/billing.py::validate_amount fn line 8
30 muse/auth.py::validate_token fn line 14
31 muse/auth.py::Validator class line 22
32 muse/auth.py::Validator.validate method line 28
33
34 4 match(es) across 2 file(s)
35
36 Security note: patterns are capped at 512 characters to prevent ReDoS.
37 Invalid regex syntax is caught and reported as exit 1 rather than crashing.
38 """
39
40 import argparse
41 import json
42 import logging
43 import pathlib
44 import re
45 import sys
46 from typing import TypedDict
47
48 from muse.core.types import short_id
49 from muse.core.envelope import EnvelopeJson, make_envelope
50 from muse.core.errors import ExitCode
51 from muse.core.repo import read_repo_id, require_repo
52 from muse.core.timing import start_timer
53 from muse.core.store import get_commit_snapshot_manifest, read_current_branch, resolve_commit_ref
54 from muse.plugins.code._query import language_of, normalise_language, symbols_for_snapshot
55 from muse.plugins.code.ast_parser import SymbolRecord
56 from muse.core.validation import sanitize_display
57 from muse.core.store import Manifest
58
59 type _IconMap = dict[str, str]
60 logger = logging.getLogger(__name__)
61
62 class _GrepResultEntry(TypedDict):
63 address: str
64 kind: str
65 name: str
66 qualified_name: str
67 path: str
68 lineno: int
69 language: str
70 content_id: str
71
72 # Guard against ReDoS: reject patterns longer than this before compiling.
73 _MAX_PATTERN_LEN: int = 512
74
75 _KIND_ICON: _IconMap = {
76 "function": "fn",
77 "async_function": "fn~",
78 "class": "class",
79 "method": "method",
80 "async_method": "method~",
81 "variable": "var",
82 "import": "import",
83 }
84
85 # ---------------------------------------------------------------------------
86 # Typed output shape
87 # ---------------------------------------------------------------------------
88
89 class _GrepOutputJson(EnvelopeJson):
90 """JSON output for ``muse code grep --json``.
91
92 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
93
94 Fields
95 ------
96 source_ref ``"working-tree"`` when the search reflects uncommitted edits,
97 or the abbreviated commit ID (12 hex chars) that was searched.
98 working_tree True when the search reflects the current working tree rather
99 than a committed snapshot.
100 pattern The pattern string exactly as supplied by the caller.
101 total_matches Total number of symbol declarations matched.
102 results List of match dicts — each has address, kind, name,
103 qualified_name, path, lineno, language, and content_id.
104 """
105
106 source_ref: str
107 working_tree: bool
108 pattern: str
109 total_matches: int
110 results: list[_GrepResultEntry]
111
112 # ---------------------------------------------------------------------------
113 # Repository helpers
114 # ---------------------------------------------------------------------------
115
116 # ---------------------------------------------------------------------------
117 # File-filter helpers (same as symbols.py)
118 # ---------------------------------------------------------------------------
119
120 def _file_matches(file_path: str, file_filter: str) -> bool:
121 """True if *file_path* equals or ends with ``/<file_filter>``."""
122 if file_path == file_filter:
123 return True
124 normalized = file_filter.replace("\\", "/")
125 return file_path.endswith(f"/{normalized}")
126
127 def _resolve_file_filter(
128 file_filter: str,
129 manifest: Manifest,
130 ) -> str | None:
131 """Resolve a partial path suffix to the exact manifest key.
132
133 Exits non-zero on ambiguity; returns ``None`` when there is no match
134 (caller handles the empty result).
135 """
136 matching = [p for p in sorted(manifest) if _file_matches(p, file_filter)]
137 if len(matching) == 1:
138 return matching[0]
139 if len(matching) > 1:
140 print(
141 f"❌ '{file_filter}' is ambiguous — matches {len(matching)} files. "
142 "Use a more specific path:",
143 file=sys.stderr,
144 )
145 for m in matching[:10]:
146 print(f" {m}", file=sys.stderr)
147 if len(matching) > 10:
148 print(f" … and {len(matching) - 10} more", file=sys.stderr)
149 raise SystemExit(ExitCode.USER_ERROR)
150 return None # no match — caller handles empty result
151
152 # ---------------------------------------------------------------------------
153 # Argument parser registration
154 # ---------------------------------------------------------------------------
155
156 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
157 """Register the grep subcommand."""
158 parser = subparsers.add_parser(
159 "grep",
160 help="Search the symbol graph by name — not file text.",
161 description=__doc__,
162 formatter_class=argparse.RawDescriptionHelpFormatter,
163 )
164 parser.add_argument(
165 "pattern", metavar="PATTERN",
166 help="Name pattern to search for.",
167 )
168 parser.add_argument(
169 "--regex", "-e", action="store_true", dest="use_regex",
170 help="Treat PATTERN as a regular expression (default: substring match).",
171 )
172 parser.add_argument(
173 "--kind", "-k", default=None, metavar="KIND", dest="kind_filter",
174 help="Restrict to symbols of this kind (function, class, method, …).",
175 )
176 parser.add_argument(
177 "--language", "-l", default=None, metavar="LANG", dest="language_filter",
178 help="Restrict to symbols from files of this language (case-insensitive).",
179 )
180 parser.add_argument(
181 "--file", "-f", default=None, metavar="PATH", dest="file_filter",
182 help=(
183 "Scope to a single file. Accepts an exact path or a unique suffix "
184 "(e.g. 'billing.py' matches 'src/billing.py'). Up to 24x faster."
185 ),
186 )
187 parser.add_argument(
188 "--commit", "-c", default=None, metavar="REF", dest="ref",
189 help="Search a historical commit instead of the working tree.",
190 )
191 parser.add_argument(
192 "--hashes", action="store_true", dest="show_hashes",
193 help="Include content hashes in output.",
194 )
195
196 output_group = parser.add_mutually_exclusive_group()
197 output_group.add_argument(
198 "--count", action="store_true", dest="count_only",
199 help="Print only the total match count.",
200 )
201 output_group.add_argument(
202 "--json", "-j", action="store_true", dest="json_out",
203 help="Emit results as structured JSON.",
204 )
205 output_group.add_argument(
206 "--files", action="store_true", dest="files_only",
207 help=(
208 "Print only the unique file paths that contain at least one match, "
209 "one per line, sorted. Mirrors ``grep -l`` / ``rg -l``. "
210 "Trivially pipeable without JSON parsing."
211 ),
212 )
213
214 parser.set_defaults(func=run, files_only=False)
215
216 # ---------------------------------------------------------------------------
217 # Command entry point
218 # ---------------------------------------------------------------------------
219
220 def run(args: argparse.Namespace) -> None:
221 """Search the symbol graph by name — not file text.
222
223 Searches the typed, content-addressed symbol graph. Every result is a
224 real symbol declaration — no false positives from comments, string
225 literals, or call sites. Use ``--file`` to scope to one file (much
226 faster); ``--regex`` for full Python regex syntax.
227
228 Agent quickstart
229 ----------------
230 ::
231
232 muse code grep "validate" --json
233 muse code grep "Invoice.validate" --json
234 muse code grep "compute.*total" --regex --json
235 muse code grep "validate" --file src/billing.py --json
236
237 JSON fields
238 -----------
239 source_ref Commit ref or ``"working tree"`` searched.
240 working_tree ``true`` if searching uncommitted state.
241 pattern Pattern used.
242 total_matches Number of matching symbol declarations.
243 results List of match objects: ``address``, ``kind``, ``file``,
244 ``line``, ``language``.
245
246 Exit codes
247 ----------
248 0 Search complete (zero matches is still success).
249 1 Invalid regex or invalid arguments.
250 2 Not inside a Muse repository.
251 """
252 elapsed = start_timer()
253 pattern: str = args.pattern
254 use_regex: bool = args.use_regex
255 kind_filter: str | None = args.kind_filter
256 language_filter: str | None = args.language_filter
257 file_filter: str | None = args.file_filter
258 ref: str | None = args.ref
259 show_hashes: bool = args.show_hashes
260 count_only: bool = args.count_only
261 json_out: bool = args.json_out
262 files_only: bool = getattr(args, "files_only", False)
263
264 # ── Input validation ──────────────────────────────────────────────────────
265
266 if len(pattern) > _MAX_PATTERN_LEN:
267 print(
268 f"❌ Pattern too long ({len(pattern)} chars) — maximum is {_MAX_PATTERN_LEN}.",
269 file=sys.stderr,
270 )
271 raise SystemExit(ExitCode.USER_ERROR)
272
273 if language_filter is not None:
274 language_filter = normalise_language(language_filter)
275
276 # When pattern contains a separator, also search qualified names.
277 search_qualified = "." in pattern or "::" in pattern
278
279 try:
280 regex = (
281 re.compile(pattern, re.IGNORECASE)
282 if use_regex
283 else re.compile(re.escape(pattern), re.IGNORECASE)
284 )
285 except re.error as exc:
286 print(f"❌ Invalid regex pattern: {exc}", file=sys.stderr)
287 raise SystemExit(ExitCode.USER_ERROR)
288
289 # ── Repo / commit resolution ──────────────────────────────────────────────
290
291 root = require_repo()
292 repo_id = read_repo_id(root)
293 branch = read_current_branch(root)
294
295 commit = resolve_commit_ref(root, repo_id, branch, ref)
296 if commit is None:
297 print(f"❌ Commit '{ref or 'HEAD'}' not found.", file=sys.stderr)
298 raise SystemExit(ExitCode.USER_ERROR)
299
300 manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {}
301
302 # ── File-filter resolution ────────────────────────────────────────────────
303
304 resolved_file_filter = file_filter
305 if file_filter is not None:
306 found = _resolve_file_filter(file_filter, manifest)
307 if found is not None:
308 resolved_file_filter = found
309 # None → no match; pass original so symbols_for_snapshot returns {}
310
311 # ── Working-tree vs object-store mode ────────────────────────────────────
312
313 working_tree = ref is None
314 workdir = root if working_tree else None
315 source_ref = "working-tree" if working_tree else commit.commit_id
316
317 # ── Symbol extraction ─────────────────────────────────────────────────────
318
319 symbol_map = symbols_for_snapshot(
320 root, manifest,
321 kind_filter=kind_filter,
322 file_filter=resolved_file_filter,
323 language_filter=language_filter,
324 workdir=workdir,
325 )
326
327 # ── Pattern matching ──────────────────────────────────────────────────────
328
329 matches: list[tuple[str, str, SymbolRecord]] = []
330 for file_path, tree in sorted(symbol_map.items()):
331 for addr, rec in sorted(tree.items(), key=lambda kv: kv[1]["lineno"]):
332 name_hit = regex.search(rec["name"])
333 qual_hit = search_qualified and regex.search(rec["qualified_name"])
334 if name_hit or qual_hit:
335 matches.append((file_path, addr, rec))
336
337 # ── Output ────────────────────────────────────────────────────────────────
338
339 if count_only:
340 print(f"{len(matches)} match(es)")
341 return
342
343 if files_only:
344 seen: set[str] = set()
345 for file_path, _addr, _rec in matches:
346 seen.add(file_path)
347 for path in sorted(seen):
348 print(path)
349 return
350
351 if json_out:
352 results: list[_GrepResultEntry] = []
353 for _fp, addr, rec in matches:
354 results.append({
355 "address": addr,
356 "kind": rec["kind"],
357 "name": rec["name"],
358 "qualified_name": rec["qualified_name"],
359 "path": addr.split("::")[0],
360 "lineno": rec["lineno"],
361 "language": language_of(addr.split("::")[0]),
362 "content_id": rec["content_id"],
363 })
364 print(json.dumps(_GrepOutputJson(
365 **make_envelope(elapsed),
366 source_ref=source_ref,
367 working_tree=working_tree,
368 pattern=pattern,
369 total_matches=len(matches),
370 results=results,
371 )))
372 return
373
374 if not matches:
375 print(f" (no symbols matching '{sanitize_display(pattern)}')")
376 return
377
378 files_seen: set[str] = set()
379 for file_path, addr, rec in matches:
380 files_seen.add(file_path)
381 icon = _KIND_ICON.get(rec["kind"], rec["kind"])
382 line = rec["lineno"]
383 hash_part = f" {short_id(rec['content_id'])}.." if show_hashes else ""
384 print(f" {sanitize_display(addr):<60} {icon:<10} line {line:>4}{hash_part}")
385
386 print(f"\n{len(matches)} match(es) across {len(files_seen)} file(s)")
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 123 days ago