grep.py python
336 lines 13.0 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 14 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 from __future__ import annotations
41
42 import argparse
43 import json
44 import logging
45 import pathlib
46 import re
47 import sys
48
49 from muse.core.errors import ExitCode
50 from muse.core.repo import read_repo_id, require_repo
51 from muse.core.store import get_commit_snapshot_manifest, read_current_branch, resolve_commit_ref
52 from muse.plugins.code._query import language_of, symbols_for_snapshot
53 from muse.plugins.code._query import _SUFFIX_LANG # for case-insensitive lang normalisation
54 from muse.plugins.code.ast_parser import SymbolRecord
55 from muse.core.validation import sanitize_display
56 from muse.core.store import Manifest
57
58
59 type _IconMap = dict[str, str]
60 logger = logging.getLogger(__name__)
61
62 # Guard against ReDoS: reject patterns longer than this before compiling.
63 _MAX_PATTERN_LEN: int = 512
64
65 _KIND_ICON: _IconMap = {
66 "function": "fn",
67 "async_function": "fn~",
68 "class": "class",
69 "method": "method",
70 "async_method": "method~",
71 "variable": "var",
72 "import": "import",
73 }
74
75 # Canonical map for case-insensitive --language matching.
76 _LANG_CANONICAL: _IconMap = {lang.lower(): lang for lang in set(_SUFFIX_LANG.values())}
77
78
79 def _normalise_language(lang: str) -> str:
80 return _LANG_CANONICAL.get(lang.strip().lower(), lang.strip())
81
82
83 # ---------------------------------------------------------------------------
84 # Repository helpers
85 # ---------------------------------------------------------------------------
86
87
88
89 # ---------------------------------------------------------------------------
90 # File-filter helpers (same as symbols.py)
91 # ---------------------------------------------------------------------------
92
93
94 def _file_matches(file_path: str, file_filter: str) -> bool:
95 """True if *file_path* equals or ends with ``/<file_filter>``."""
96 if file_path == file_filter:
97 return True
98 normalized = file_filter.replace("\\", "/")
99 return file_path.endswith("/" + normalized)
100
101
102 def _resolve_file_filter(
103 file_filter: str,
104 manifest: Manifest,
105 ) -> str | None:
106 """Resolve a partial path suffix to the exact manifest key.
107
108 Exits non-zero on ambiguity; returns ``None`` when there is no match
109 (caller handles the empty result).
110 """
111 matching = [p for p in sorted(manifest) if _file_matches(p, file_filter)]
112 if len(matching) == 1:
113 return matching[0]
114 if len(matching) > 1:
115 print(
116 f"❌ '{file_filter}' is ambiguous — matches {len(matching)} files. "
117 "Use a more specific path:",
118 file=sys.stderr,
119 )
120 for m in matching[:10]:
121 print(f" {m}", file=sys.stderr)
122 if len(matching) > 10:
123 print(f" … and {len(matching) - 10} more", file=sys.stderr)
124 raise SystemExit(ExitCode.USER_ERROR)
125 return None # no match — caller handles empty result
126
127
128 # ---------------------------------------------------------------------------
129 # Argument parser registration
130 # ---------------------------------------------------------------------------
131
132
133 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
134 """Register the grep subcommand."""
135 parser = subparsers.add_parser(
136 "grep",
137 help="Search the symbol graph by name — not file text.",
138 description=__doc__,
139 formatter_class=argparse.RawDescriptionHelpFormatter,
140 )
141 parser.add_argument(
142 "pattern", metavar="PATTERN",
143 help="Name pattern to search for.",
144 )
145 parser.add_argument(
146 "--regex", "-e", action="store_true", dest="use_regex",
147 help="Treat PATTERN as a regular expression (default: substring match).",
148 )
149 parser.add_argument(
150 "--kind", "-k", default=None, metavar="KIND", dest="kind_filter",
151 help="Restrict to symbols of this kind (function, class, method, …).",
152 )
153 parser.add_argument(
154 "--language", "-l", default=None, metavar="LANG", dest="language_filter",
155 help="Restrict to symbols from files of this language (case-insensitive).",
156 )
157 parser.add_argument(
158 "--file", "-f", default=None, metavar="PATH", dest="file_filter",
159 help=(
160 "Scope to a single file. Accepts an exact path or a unique suffix "
161 "(e.g. 'billing.py' matches 'src/billing.py'). Up to 24x faster."
162 ),
163 )
164 parser.add_argument(
165 "--commit", "-c", default=None, metavar="REF", dest="ref",
166 help="Search a historical commit instead of the working tree.",
167 )
168 parser.add_argument(
169 "--hashes", action="store_true", dest="show_hashes",
170 help="Include content hashes in output.",
171 )
172
173 output_group = parser.add_mutually_exclusive_group()
174 output_group.add_argument(
175 "--count", action="store_true", dest="count_only",
176 help="Print only the total match count.",
177 )
178 output_group.add_argument(
179 "--json", action="store_true", dest="as_json",
180 help="Emit results as structured JSON.",
181 )
182
183 parser.set_defaults(func=run)
184
185
186 # ---------------------------------------------------------------------------
187 # Command entry point
188 # ---------------------------------------------------------------------------
189
190
191 def run(args: argparse.Namespace) -> None:
192 """Search the symbol graph by name — not file text.
193
194 ``muse code grep`` searches the typed, content-addressed symbol graph.
195 Every result is a real symbol declaration — no false positives from
196 comments, string literals, or call sites.
197
198 The ``--regex`` flag enables full Python regex syntax. Without it,
199 PATTERN is matched as a case-insensitive substring.
200
201 When PATTERN contains a ``.`` or ``::`` it is also matched against the
202 fully-qualified symbol name, so ``Invoice.validate`` returns exactly that
203 method rather than every symbol named ``validate``.
204
205 Use ``--file`` to scope the search to one file — this is up to 24x faster
206 than a full-codebase scan and sufficient for most targeted lookups.
207
208 When ``--commit`` is omitted the working tree is searched, reflecting
209 edits not yet committed.
210
211 Patterns are capped at 512 characters to guard against ReDoS.
212 """
213 pattern: str = args.pattern
214 use_regex: bool = args.use_regex
215 kind_filter: str | None = args.kind_filter
216 language_filter: str | None = args.language_filter
217 file_filter: str | None = args.file_filter
218 ref: str | None = args.ref
219 show_hashes: bool = args.show_hashes
220 count_only: bool = args.count_only
221 as_json: bool = args.as_json
222
223 # ── Input validation ──────────────────────────────────────────────────────
224
225 if len(pattern) > _MAX_PATTERN_LEN:
226 print(
227 f"❌ Pattern too long ({len(pattern)} chars) — maximum is {_MAX_PATTERN_LEN}.",
228 file=sys.stderr,
229 )
230 raise SystemExit(ExitCode.USER_ERROR)
231
232 if language_filter is not None:
233 language_filter = _normalise_language(language_filter)
234
235 # When pattern contains a separator, also search qualified names.
236 search_qualified = "." in pattern or "::" in pattern
237
238 try:
239 regex = (
240 re.compile(pattern, re.IGNORECASE)
241 if use_regex
242 else re.compile(re.escape(pattern), re.IGNORECASE)
243 )
244 except re.error as exc:
245 print(f"❌ Invalid regex pattern: {exc}", file=sys.stderr)
246 raise SystemExit(ExitCode.USER_ERROR)
247
248 # ── Repo / commit resolution ──────────────────────────────────────────────
249
250 root = require_repo()
251 repo_id = read_repo_id(root)
252 branch = read_current_branch(root)
253
254 commit = resolve_commit_ref(root, repo_id, branch, ref)
255 if commit is None:
256 print(f"❌ Commit '{ref or 'HEAD'}' not found.", file=sys.stderr)
257 raise SystemExit(ExitCode.USER_ERROR)
258
259 manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {}
260
261 # ── File-filter resolution ────────────────────────────────────────────────
262
263 resolved_file_filter = file_filter
264 if file_filter is not None:
265 found = _resolve_file_filter(file_filter, manifest)
266 if found is not None:
267 resolved_file_filter = found
268 # None → no match; pass original so symbols_for_snapshot returns {}
269
270 # ── Working-tree vs object-store mode ────────────────────────────────────
271
272 working_tree = ref is None
273 workdir = root if working_tree else None
274 source_ref = "working-tree" if working_tree else commit.commit_id[:8]
275
276 # ── Symbol extraction ─────────────────────────────────────────────────────
277
278 symbol_map = symbols_for_snapshot(
279 root, manifest,
280 kind_filter=kind_filter,
281 file_filter=resolved_file_filter,
282 language_filter=language_filter,
283 workdir=workdir,
284 )
285
286 # ── Pattern matching ──────────────────────────────────────────────────────
287
288 matches: list[tuple[str, str, SymbolRecord]] = []
289 for file_path, tree in sorted(symbol_map.items()):
290 for addr, rec in sorted(tree.items(), key=lambda kv: kv[1]["lineno"]):
291 name_hit = regex.search(rec["name"])
292 qual_hit = search_qualified and regex.search(rec["qualified_name"])
293 if name_hit or qual_hit:
294 matches.append((file_path, addr, rec))
295
296 # ── Output ────────────────────────────────────────────────────────────────
297
298 if count_only:
299 print(f"{len(matches)} match(es)")
300 return
301
302 if as_json:
303 results: list[dict[str, str | int | bool]] = []
304 for _fp, addr, rec in matches:
305 results.append({
306 "address": addr,
307 "kind": rec["kind"],
308 "name": rec["name"],
309 "qualified_name": rec["qualified_name"],
310 "file": addr.split("::")[0],
311 "lineno": rec["lineno"],
312 "language": language_of(addr.split("::")[0]),
313 "content_id": rec["content_id"],
314 })
315 print(json.dumps({
316 "source_ref": source_ref,
317 "working_tree": working_tree,
318 "pattern": pattern,
319 "total_matches": len(matches),
320 "results": results,
321 }, indent=2))
322 return
323
324 if not matches:
325 print(f" (no symbols matching '{sanitize_display(pattern)}')")
326 return
327
328 files_seen: set[str] = set()
329 for file_path, addr, rec in matches:
330 files_seen.add(file_path)
331 icon = _KIND_ICON.get(rec["kind"], rec["kind"])
332 line = rec["lineno"]
333 hash_part = f" {rec['content_id'][:8]}.." if show_hashes else ""
334 print(f" {sanitize_display(addr):<60} {icon:<10} line {line:>4}{hash_part}")
335
336 print(f"\n{len(matches)} match(es) across {len(files_seen)} file(s)")
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 14 days ago