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