query_engine.py python
347 lines 11.9 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 days ago
1 """Domain-agnostic commit-history query engine for Muse.
2
3 Any domain can walk the commit graph, evaluate a predicate per commit, and
4 collect structured matches — without reimplementing the graph-traversal loop.
5
6 Three-tier walker contract
7 --------------------------
8 Muse has three distinct commit-graph walkers, each serving a different tier:
9
10 1. **``walk_history``** (this module) — *porcelain tier*.
11 Use for any query that produces :class:`QueryMatch` results consumed by
12 users or agents. Supports ``follow_merges=True`` (BFS over the full DAG),
13 ``since``/``until`` time filters, ``load_manifest=False`` optimisation,
14 and the standard evaluator protocol. This is the right choice for all
15 ``muse code`` porcelain commands.
16
17 2. **``store.walk_commits_between``** — *plumbing tier, range-bounded*.
18 Use when you need a raw ``list[CommitRecord]`` for a specific linear range
19 (``from_commit_id`` exclusive → ``to_commit_id`` inclusive, first-parent
20 only). Used by ``find_symbol``, ``lineage``, ``query_history``, and
21 ``status`` where the full match protocol is not needed.
22
23 3. **``_query.walk_commits_bfs``** — *low-level DAG primitive*.
24 Available as a low-level escape hatch during the migration period.
25 Prefer ``walk_history(follow_merges=True)`` for new code.
26
27 Architecture
28 ------------
29 ::
30
31 muse/core/query_engine.py ← this file: generic history walker
32 muse/plugins/midi/_midi_query.py ← MIDI predicate evaluator
33 muse/plugins/code/_code_query.py ← code predicate evaluator
34 muse/cli/commands/midi_query.py ← CLI for MIDI query
35 muse/cli/commands/code_query.py ← CLI for code query
36
37 Usage pattern::
38
39 from muse.core.query_engine import walk_history, QueryMatch
40
41 def my_evaluator(
42 commit: CommitRecord,
43 manifest: Manifest,
44 repo_root: pathlib.Path,
45 ) -> list[QueryMatch]:
46 matches = []
47 if "interesting-file.py" in manifest:
48 matches.append(QueryMatch(
49 commit_id=commit.commit_id,
50 author=commit.author,
51 committed_at=commit.committed_at.isoformat(),
52 branch=commit.branch,
53 detail="found interesting-file.py",
54 extra={},
55 ))
56 return matches
57
58 results = walk_history(repo_root, branch="main", evaluator=my_evaluator)
59 # DAG walk with follow_merges:
60 results = walk_history(
61 repo_root, branch="main", evaluator=my_evaluator, follow_merges=True
62 )
63
64 Public API
65 ----------
66 - :class:`QueryMatch` — one result row from the evaluator.
67 - :class:`CommitEvaluator` — type alias for the evaluator callable.
68 - :func:`walk_history` — traverse commits and collect matches.
69 """
70
71 from __future__ import annotations
72
73 import datetime
74 import logging
75 import pathlib
76 from collections import deque
77 from collections.abc import Callable
78 from typing import TypedDict
79
80 from muse.core._types import Manifest
81 from muse.core.store import (
82 CommitRecord,
83 get_commit_snapshot_manifest,
84 get_head_commit_id,
85 read_commit,
86 )
87
88 logger = logging.getLogger(__name__)
89
90 _DEFAULT_MAX_COMMITS = 500
91
92
93 # ---------------------------------------------------------------------------
94 # Result type
95 # ---------------------------------------------------------------------------
96
97
98 class QueryMatch(TypedDict, total=False):
99 """One match returned by a predicate evaluator.
100
101 Required fields:
102 ``commit_id`` The commit that produced this match.
103 ``author`` Commit author string.
104 ``committed_at`` ISO-8601 timestamp string.
105 ``branch`` Branch name.
106 ``detail`` Short human-readable description of what matched.
107
108 Optional:
109 ``extra`` Domain-specific data (e.g. ``{"symbol": "my_fn"}``).
110 ``agent_id`` Agent identity from commit provenance (if present).
111 ``model_id`` Model ID from commit provenance (if present).
112 """
113
114 commit_id: str
115 author: str
116 committed_at: str
117 branch: str
118 detail: str
119 extra: Manifest
120 agent_id: str
121 model_id: str
122
123
124 # ---------------------------------------------------------------------------
125 # Evaluator type alias
126 # ---------------------------------------------------------------------------
127
128 #: Signature every domain evaluator must satisfy.
129 #: Returns a (possibly empty) list of :class:`QueryMatch` for the commit.
130 CommitEvaluator = Callable[
131 [CommitRecord, dict[str, str], pathlib.Path],
132 list[QueryMatch],
133 ]
134
135
136 # ---------------------------------------------------------------------------
137 # Core history walker
138 # ---------------------------------------------------------------------------
139
140
141 def walk_history(
142 repo_root: pathlib.Path,
143 branch: str,
144 evaluator: CommitEvaluator,
145 *,
146 max_commits: int = _DEFAULT_MAX_COMMITS,
147 head_commit_id: str | None = None,
148 load_manifest: bool = True,
149 since: datetime.datetime | None = None,
150 until: datetime.datetime | None = None,
151 follow_merges: bool = False,
152 ) -> list[QueryMatch]:
153 """Walk the commit graph from HEAD and collect matches from *evaluator*.
154
155 For each commit the evaluator receives the :class:`~muse.core.store.CommitRecord`,
156 the raw file manifest (path → SHA-256 hash), and the repository root.
157 It returns a list of :class:`QueryMatch` dicts (empty list if the commit
158 has no matches).
159
160 When *follow_merges* is ``False`` (default), only the main parent chain
161 (``parent_commit_id``) is followed — sufficient for single-branch queries
162 and avoids loading the full DAG.
163
164 When *follow_merges* is ``True``, BFS is used so that merge commits'
165 second parents (``parent2_commit_id``) are also visited. This is
166 necessary for commands like ``hotspots`` and ``blame`` where missing
167 feature-branch commits would give incorrect results.
168
169 Args:
170 repo_root: Repository root containing ``.muse/``.
171 branch: Branch to start from (used to resolve HEAD when
172 *head_commit_id* is ``None``).
173 evaluator: Domain-specific callable — see :data:`CommitEvaluator`.
174 max_commits: Maximum commits to inspect. Default 500.
175 head_commit_id: Override the starting commit. ``None`` → resolve HEAD
176 via the store (same logic as all other commands).
177 load_manifest: When ``False``, passes an empty manifest ``{}`` to the
178 evaluator and skips the snapshot-manifest I/O entirely.
179 Safe for evaluators that only inspect commit-level fields
180 (``author``, ``agent_id``, ``sem_ver_bump``, etc.).
181 Provides a significant speed-up on large repos.
182 since: Skip commits older than this timestamp (inclusive).
183 until: Skip commits newer than this timestamp (inclusive).
184 follow_merges: When ``True``, follow ``parent2_commit_id`` at merge
185 commits (BFS). When ``False`` (default), follow only
186 the linear ``parent_commit_id`` chain.
187
188 Returns:
189 All :class:`QueryMatch` records collected, ordered by walk order
190 (newest-first for the main parent chain).
191 """
192 if head_commit_id is None:
193 resolved = get_head_commit_id(repo_root, branch)
194 if not resolved:
195 logger.warning("Branch '%s' has no commits.", branch)
196 return []
197 head_commit_id = resolved
198
199 results: list[QueryMatch] = []
200
201 if follow_merges:
202 results = _walk_history_bfs(
203 repo_root, head_commit_id, evaluator,
204 max_commits=max_commits,
205 load_manifest=load_manifest,
206 since=since,
207 until=until,
208 )
209 else:
210 results = _walk_history_linear(
211 repo_root, head_commit_id, evaluator,
212 max_commits=max_commits,
213 load_manifest=load_manifest,
214 since=since,
215 until=until,
216 )
217
218 return results
219
220
221 def _make_tz_aware(dt: datetime.datetime) -> datetime.datetime:
222 """Return *dt* with UTC timezone attached if it is naive."""
223 return dt if dt.tzinfo else dt.replace(tzinfo=datetime.timezone.utc)
224
225
226 def _passes_time_filter(
227 commit: CommitRecord,
228 since: datetime.datetime | None,
229 until: datetime.datetime | None,
230 ) -> bool:
231 """Return True if *commit* falls within the [since, until] window."""
232 ts = _make_tz_aware(commit.committed_at)
233 if since is not None and ts < _make_tz_aware(since):
234 return False
235 if until is not None and ts > _make_tz_aware(until):
236 return False
237 return True
238
239
240 def _evaluate_commit(
241 repo_root: pathlib.Path,
242 commit: CommitRecord,
243 evaluator: CommitEvaluator,
244 load_manifest: bool,
245 ) -> list[QueryMatch]:
246 """Load manifest if needed and run *evaluator* on *commit*."""
247 if load_manifest:
248 manifest_rec = get_commit_snapshot_manifest(repo_root, commit.commit_id)
249 manifest: Manifest = dict(manifest_rec) if manifest_rec else {}
250 else:
251 manifest = {}
252 try:
253 return evaluator(commit, manifest, repo_root)
254 except Exception:
255 logger.exception("Evaluator error on commit %s", commit.commit_id)
256 return []
257
258
259 def _walk_history_linear(
260 repo_root: pathlib.Path,
261 start_commit_id: str,
262 evaluator: CommitEvaluator,
263 *,
264 max_commits: int,
265 load_manifest: bool,
266 since: datetime.datetime | None,
267 until: datetime.datetime | None,
268 ) -> list[QueryMatch]:
269 """Linear first-parent walk — internal implementation."""
270 results: list[QueryMatch] = []
271 current_id: str | None = start_commit_id
272 seen = 0
273
274 while current_id and seen < max_commits:
275 commit = read_commit(repo_root, current_id)
276 if commit is None:
277 break
278 seen += 1
279 if _passes_time_filter(commit, since, until):
280 results.extend(_evaluate_commit(repo_root, commit, evaluator, load_manifest))
281 current_id = commit.parent_commit_id
282
283 return results
284
285
286 def _walk_history_bfs(
287 repo_root: pathlib.Path,
288 start_commit_id: str,
289 evaluator: CommitEvaluator,
290 *,
291 max_commits: int,
292 load_manifest: bool,
293 since: datetime.datetime | None,
294 until: datetime.datetime | None,
295 ) -> list[QueryMatch]:
296 """BFS DAG walk following both parents — internal implementation."""
297 results: list[QueryMatch] = []
298 queue: deque[str] = deque([start_commit_id])
299 seen_ids: set[str] = set()
300 visited = 0
301
302 while queue and visited < max_commits:
303 current_id = queue.popleft()
304 if current_id in seen_ids:
305 continue
306 seen_ids.add(current_id)
307 commit = read_commit(repo_root, current_id)
308 if commit is None:
309 continue
310 visited += 1
311 if _passes_time_filter(commit, since, until):
312 results.extend(_evaluate_commit(repo_root, commit, evaluator, load_manifest))
313 if commit.parent_commit_id:
314 queue.append(commit.parent_commit_id)
315 if commit.parent2_commit_id:
316 queue.append(commit.parent2_commit_id)
317
318 return results
319
320
321 def format_matches(matches: list[QueryMatch], *, max_results: int = 50) -> str:
322 """Format a list of matches as a human-readable table.
323
324 Args:
325 matches: The results from :func:`walk_history`.
326 max_results: Maximum rows to show before the "… N more" truncation line.
327
328 Returns:
329 Multi-line string ready for printing to stdout.
330 """
331 if not matches:
332 return "No matches found."
333
334 lines: list[str] = [f"Found {len(matches)} match(es):\n"]
335 for m in matches[:max_results]:
336 cid = m.get("commit_id", "?")[:8]
337 author = m.get("author", "unknown")
338 ts = m.get("committed_at", "")[:10]
339 detail = m.get("detail", "")
340 agent = m.get("agent_id", "")
341 agent_str = f" [{agent}]" if agent else ""
342 lines.append(f" {cid} {ts} {author}{agent_str} — {detail}")
343
344 if len(matches) > max_results:
345 lines.append(f"\n … {len(matches) - max_results} more (use --limit to raise the cap)")
346
347 return "\n".join(lines)
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 days ago