gabriel / muse public
_code_query.py python
393 lines 12.7 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Code-domain query evaluator for the Muse generic query engine.
2
3 Implements :data:`~muse.core.query_engine.CommitEvaluator` for the code domain.
4 Allows agents and humans to search the commit history for code changes::
5
6 muse code-query "symbol == 'my_function' and change == 'added'"
7 muse code-query "language == 'Python' and author == 'agent-x'"
8 muse code-query "agent_id == 'claude' and sem_ver_bump == 'major'"
9 muse code-query "file == 'src/core.py'"
10 muse code-query "change == 'added' and kind == 'class'"
11 muse code-query "symbol endswith _handler"
12
13 Query language
14 --------------
15
16 query = and_expr ( 'or' and_expr )*
17 and_expr = atom ( 'and' atom )*
18 atom = FIELD OP VALUE
19 FIELD = 'symbol' | 'file' | 'language' | 'kind' | 'change'
20 | 'author' | 'agent_id' | 'model_id' | 'toolchain_id'
21 | 'sem_ver_bump' | 'branch'
22 OP = '==' | '!=' | 'contains' | 'startswith' | 'endswith'
23 VALUE = QUOTED_STRING | UNQUOTED_WORD
24
25 Supported fields
26 ----------------
27
28 ``symbol`` Qualified symbol name (e.g. ``"MyClass.method"``).
29 ``file`` Workspace-relative file path.
30 ``language`` Language name (``"Python"``, ``"TypeScript"``…).
31 ``kind`` Symbol kind (``"function"``, ``"class"``, ``"method"``…).
32 ``change`` ``"added"``, ``"removed"``, or ``"modified"``.
33 ``author`` Commit author string.
34 ``agent_id`` Agent identity from commit provenance.
35 ``model_id`` Model ID from commit provenance.
36 ``toolchain_id`` Toolchain string from commit provenance.
37 ``sem_ver_bump`` Semantic version bump: ``"none"``, ``"patch"``,
38 ``"minor"``, ``"major"``.
39 ``branch`` Branch name.
40
41 Performance note
42 ----------------
43 The code evaluator reads ``commit.structured_delta`` — it never needs the
44 snapshot manifest. Callers should pass ``load_manifest=False`` to
45 :func:`~muse.core.query_engine.walk_history` to skip that I/O entirely.
46 """
47
48 from __future__ import annotations
49
50 import logging
51 import pathlib
52 import re
53 from dataclasses import dataclass
54 from typing import Literal, TypeIs, get_args
55
56 from muse.core.query_engine import CommitEvaluator, QueryMatch
57 from muse.core.store import CommitRecord
58 from muse.domain import DomainOp, PatchOp
59 from muse.plugins.code._query import language_of
60 from muse.core._types import Manifest
61
62 logger = logging.getLogger(__name__)
63
64
65 def _is_patch_op(op: DomainOp) -> TypeIs[PatchOp]:
66 """Narrow *op* to :class:`~muse.domain.PatchOp` so mypy can see ``child_ops``."""
67 return op["op"] == "patch"
68
69
70 # ---------------------------------------------------------------------------
71 # Query AST types
72 # ---------------------------------------------------------------------------
73
74 CodeField = Literal[
75 "symbol", "file", "language", "kind", "change",
76 "author", "agent_id", "model_id", "toolchain_id",
77 "sem_ver_bump", "branch",
78 ]
79
80 CodeOp = Literal["==", "!=", "contains", "startswith", "endswith"]
81
82 @dataclass(frozen=True)
83 class Comparison:
84 """A single field OP value predicate."""
85
86 field: CodeField
87 op: CodeOp
88 value: str
89
90
91 @dataclass(frozen=True)
92 class AndExpr:
93 """Conjunction of predicates (all must match)."""
94
95 clauses: list[Comparison]
96
97
98 @dataclass(frozen=True)
99 class OrExpr:
100 """Disjunction of AND-expressions (any must match)."""
101
102 clauses: list[AndExpr]
103
104
105 # ---------------------------------------------------------------------------
106 # Tokeniser & parser
107 # ---------------------------------------------------------------------------
108
109 _TOKEN_RE = re.compile(
110 r"""
111 (?P<keyword>(?:or|and|contains|startswith|endswith)(?![A-Za-z0-9_.]))
112 |(?P<op>==|!=)
113 |(?P<quoted>"[^"]*"|'[^']*')
114 |(?P<word>[A-Za-z_][A-Za-z0-9_.]*)
115 """,
116 re.VERBOSE,
117 )
118
119 _VALID_FIELDS: frozenset[str] = frozenset(get_args(CodeField))
120 _VALID_OPS: frozenset[str] = frozenset(get_args(CodeOp))
121
122
123 def _is_code_field(tok: str) -> TypeIs[CodeField]:
124 return tok in _VALID_FIELDS
125
126
127 def _is_code_op(tok: str) -> TypeIs[CodeOp]:
128 return tok in _VALID_OPS
129
130
131 def _as_code_field(tok: str) -> CodeField:
132 """Validate and narrow *tok* to :data:`CodeField`; raises :exc:`ValueError` if invalid."""
133 if not _is_code_field(tok):
134 raise ValueError(f"Unknown field: {tok!r}. Valid: {sorted(_VALID_FIELDS)}")
135 return tok
136
137
138 def _as_code_op(tok: str) -> CodeOp:
139 """Validate and narrow *tok* to :data:`CodeOp`; raises :exc:`ValueError` if invalid."""
140 if not _is_code_op(tok):
141 raise ValueError(f"Unknown operator: {tok!r}. Valid: {sorted(_VALID_OPS)}")
142 return tok
143
144
145 def _tokenize(query: str) -> list[str]:
146 return [m.group() for m in _TOKEN_RE.finditer(query)]
147
148
149 def _parse_query(query: str) -> OrExpr:
150 """Parse a query string into an :class:`OrExpr` AST."""
151 tokens = _tokenize(query.strip())
152 pos = 0
153
154 def peek() -> str | None:
155 return tokens[pos] if pos < len(tokens) else None
156
157 def consume() -> str:
158 nonlocal pos
159 tok = tokens[pos]
160 pos += 1
161 return tok
162
163 def parse_atom() -> Comparison:
164 field_tok = consume()
165 validated_field = _as_code_field(field_tok)
166 op_tok = consume()
167 validated_op = _as_code_op(op_tok)
168 val_tok = consume()
169 if val_tok.startswith(("'", '"')):
170 val_tok = val_tok[1:-1]
171 return Comparison(
172 field=validated_field,
173 op=validated_op,
174 value=val_tok,
175 )
176
177 def parse_and() -> AndExpr:
178 clauses: list[Comparison] = [parse_atom()]
179 while peek() == "and":
180 consume()
181 clauses.append(parse_atom())
182 return AndExpr(clauses=clauses)
183
184 def parse_or() -> OrExpr:
185 clauses: list[AndExpr] = [parse_and()]
186 while peek() == "or":
187 consume()
188 clauses.append(parse_and())
189 return OrExpr(clauses=clauses)
190
191 return parse_or()
192
193
194 # ---------------------------------------------------------------------------
195 # Evaluator
196 # ---------------------------------------------------------------------------
197
198
199 def _match_op(actual: str, op: CodeOp, expected: str) -> bool:
200 """Apply *op* to *actual* and *expected* strings (case-insensitive where sensible)."""
201 if op == "==":
202 return actual == expected
203 if op == "!=":
204 return actual != expected
205 if op == "contains":
206 return expected.lower() in actual.lower()
207 if op == "startswith":
208 return actual.lower().startswith(expected.lower())
209 # op == "endswith"
210 return actual.lower().endswith(expected.lower())
211
212
213 def _commit_matches_comparison(
214 comparison: Comparison,
215 commit: CommitRecord,
216 manifest: Manifest,
217 root: pathlib.Path,
218 symbol_matches: list[dict[str, str]],
219 ) -> bool:
220 """Return True if *commit* + its symbols satisfy *comparison*.
221
222 For symbol/file/language/kind/change fields, each (symbol, file) pair
223 that matches is appended to *symbol_matches* for result detail.
224 The ``manifest`` argument is accepted to satisfy the
225 :data:`~muse.core.query_engine.CommitEvaluator` protocol but is unused —
226 all relevant data lives in ``commit.structured_delta``.
227 """
228 f = comparison.field
229 op = comparison.op
230 v = comparison.value
231
232 # Commit-level fields — no delta traversal needed.
233 if f == "author":
234 return _match_op(commit.author, op, v)
235 if f == "agent_id":
236 return _match_op(commit.agent_id, op, v)
237 if f == "model_id":
238 return _match_op(commit.model_id, op, v)
239 if f == "toolchain_id":
240 return _match_op(commit.toolchain_id, op, v)
241 if f == "sem_ver_bump":
242 return _match_op(commit.sem_ver_bump, op, v)
243 if f == "branch":
244 return _match_op(commit.branch, op, v)
245
246 # Symbol/file-level fields — iterate the structured delta.
247 delta = commit.structured_delta
248 if delta is None:
249 return False
250
251 hit = False
252 for op_rec in delta.get("ops", []):
253 op_type: str = op_rec.get("op", "")
254 address: str = op_rec.get("address", "")
255
256 if "::" in address:
257 file_path, symbol_name = address.split("::", 1)
258 else:
259 file_path = address
260 symbol_name = ""
261
262 lang = language_of(file_path)
263 change_type = (
264 "added" if op_type == "insert"
265 else "removed" if op_type == "delete"
266 else "modified"
267 )
268
269 # PatchOps may carry child_ops for the symbols they modify.
270 child_ops: list[DomainOp] = op_rec["child_ops"] if _is_patch_op(op_rec) else []
271 all_ops: list[DomainOp] = [op_rec] + child_ops
272
273 for rec in all_ops:
274 rec_address: str = str(rec.get("address", address))
275 if "::" in rec_address:
276 rec_file, rec_symbol = rec_address.split("::", 1)
277 else:
278 rec_file = rec_address
279 rec_symbol = ""
280
281 rec_kind: str = str(rec.get("kind", ""))
282 rec_op_type: str = str(rec.get("op", ""))
283 rec_change = (
284 "added" if rec_op_type == "insert"
285 else "removed" if rec_op_type == "delete"
286 else "modified"
287 )
288
289 field_val = {
290 "symbol": rec_symbol or symbol_name,
291 "file": rec_file or file_path,
292 "language": lang,
293 "kind": rec_kind,
294 "change": rec_change or change_type,
295 }.get(f, "")
296
297 if _match_op(field_val, op, v):
298 hit = True
299 symbol_matches.append({
300 "file": rec_file or file_path,
301 "symbol": rec_symbol or symbol_name,
302 "kind": rec_kind,
303 "change": rec_change or change_type,
304 "language": lang,
305 })
306
307 return hit
308
309
310 def build_evaluator(query: str) -> CommitEvaluator:
311 """Parse *query* and return a :data:`CommitEvaluator` for :func:`~muse.core.query_engine.walk_history`.
312
313 The evaluator is a single-pass closure: it evaluates each commit once,
314 collecting both the match result and the per-symbol detail in the same
315 traversal. Commit-level fields (``author``, ``agent_id``, etc.) produce
316 one :class:`~muse.core.query_engine.QueryMatch` per matching commit;
317 symbol-level fields produce one match per matching symbol (capped at 20
318 per commit to prevent runaway output on large deltas).
319
320 Args:
321 query: A query string in the code query DSL.
322
323 Returns:
324 A callable that can be passed to :func:`~muse.core.query_engine.walk_history`.
325
326 Raises:
327 ValueError: If the query cannot be parsed.
328 """
329 ast = _parse_query(query)
330
331 def evaluator(
332 commit: CommitRecord,
333 manifest: Manifest,
334 root: pathlib.Path,
335 ) -> list[QueryMatch]:
336 symbol_matches: list[dict[str, str]] = []
337 or_matched = False
338
339 # First matching OR clause wins; we stop early.
340 for and_expr in ast.clauses:
341 clause_symbols: list[dict[str, str]] = []
342 all_match = all(
343 _commit_matches_comparison(cmp, commit, manifest, root, clause_symbols)
344 for cmp in and_expr.clauses
345 )
346 if all_match:
347 symbol_matches.extend(clause_symbols)
348 or_matched = True
349 break
350
351 if not or_matched:
352 return []
353
354 matches: list[QueryMatch] = []
355
356 if symbol_matches:
357 # Symbol-level matches — one QueryMatch per symbol (capped at 20).
358 for sym in symbol_matches[:20]:
359 detail = sym.get("symbol") or sym.get("file", "?")
360 change = sym.get("change", "")
361 if change:
362 detail = f"{detail} ({change})"
363 m = QueryMatch(
364 commit_id=commit.commit_id,
365 author=commit.author,
366 committed_at=commit.committed_at.isoformat(),
367 branch=commit.branch,
368 detail=detail,
369 extra={k: v for k, v in sym.items()},
370 )
371 if commit.agent_id:
372 m["agent_id"] = commit.agent_id
373 matches.append(m)
374 else:
375 # Commit-level match (query touched only commit fields, or the
376 # matching OR clause was a commit-level clause in a mixed query).
377 m = QueryMatch(
378 commit_id=commit.commit_id,
379 author=commit.author,
380 committed_at=commit.committed_at.isoformat(),
381 branch=commit.branch,
382 detail=commit.message[:80],
383 extra={},
384 )
385 if commit.agent_id:
386 m["agent_id"] = commit.agent_id
387 if commit.model_id:
388 m["model_id"] = commit.model_id
389 matches.append(m)
390
391 return matches
392
393 return evaluator
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago