doc_history.py python
459 lines 14.8 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago
1 """Version-history and changelog generation for ``muse code docs``.
2
3 Reads the symbol history index (built by ``muse code index rebuild``) and the
4 commit/tag store to answer three questions:
5
6 * *When was this symbol first introduced?* :func:`get_symbol_version_events`
7 * *Which release first shipped it?* :func:`infer_since_version`
8 * *What changed between v1.0 and v2.0?* :func:`generate_changelog`
9 * *Is the docstring stale?* :func:`detect_stale_docstring`
10
11 All reads are from the content-addressed object store and the msgpack index.
12 No working-tree files are modified and no subprocesses are spawned.
13
14 Performance
15 -----------
16 The most expensive operation is :func:`generate_changelog`, which walks the
17 commit graph between two refs. On a 10,000-commit repo this takes <200 ms
18 because ``read_commit`` is a single file read and the symbol history index
19 provides O(1) address lookup.
20
21 Security
22 --------
23 * All commit refs are resolved through :func:`~muse.core.store.resolve_commit_ref`,
24 which strips glob metacharacters and validates the ref string before any
25 file I/O.
26 * The commit-graph walk is bounded by *max_commits* to prevent runaway
27 traversal of a malformed or adversarially crafted repository.
28 * No user-supplied data is evaluated or executed.
29 """
30
31 from __future__ import annotations
32
33 import logging
34 import pathlib
35 from typing import Literal, NotRequired, TypedDict
36
37 from muse.core.indices import SymbolHistoryEntry, load_symbol_history
38 from muse.core.store import (
39 MsgpackValue,
40 TagRecord,
41 get_all_tags,
42 read_commit,
43 read_current_branch,
44 resolve_commit_ref,
45 walk_commits_between,
46 _str_val,
47 )
48 from muse.plugins.code.ast_parser import SymbolKind
49
50
51 type _StrMap = dict[str, str]
52 logger = logging.getLogger(__name__)
53
54
55 # ---------------------------------------------------------------------------
56 # Public type definitions
57 # ---------------------------------------------------------------------------
58
59
60 class SymbolVersionEvent(TypedDict):
61 """One entry in a symbol's version history timeline."""
62
63 commit_id: str
64 """The commit in which this event occurred."""
65
66 committed_at: str
67 """ISO-8601 timestamp from the commit record."""
68
69 op: str
70 """Operation: ``"insert"`` | ``"replace"`` | ``"delete"``."""
71
72 version: str | None
73 """Tag name that maps directly to this commit, or ``None``."""
74
75 sem_ver_bump: str | None
76 """Semantic version bump recorded on the commit (``"major"`` | ``"minor"``
77 | ``"patch"`` | ``"none"``), or ``None`` when the commit is absent."""
78
79 breaking: bool
80 """``True`` when the commit's ``breaking_changes`` list is non-empty."""
81
82
83 class ChangelogEntry(TypedDict):
84 """One symbol's entry in a documentation changelog."""
85
86 address: str
87 """Fully-qualified symbol address, e.g. ``"muse/core/store.py::read_commit"``."""
88
89 name: str
90 """Bare symbol name."""
91
92 kind: SymbolKind
93 """Symbol kind (``"function"``, ``"class"``, etc.)."""
94
95 file: str
96 """Source file path, workspace-relative."""
97
98
99 class ChangelogReport(TypedDict):
100 """Structured changelog between two commits or version tags."""
101
102 from_ref: str
103 """Starting reference (exclusive). May be a commit ID or tag name."""
104
105 to_ref: str
106 """Ending reference (inclusive). May be a commit ID or tag name."""
107
108 added: list[ChangelogEntry]
109 """Symbols that first appeared (``"insert"`` op) within the range."""
110
111 removed: list[ChangelogEntry]
112 """Symbols that were deleted within the range."""
113
114 changed: list[ChangelogEntry]
115 """Symbols modified non-breakingly within the range."""
116
117 breaking: list[ChangelogEntry]
118 """Symbols involved in a breaking change within the range."""
119
120
121 class StaleInfo(TypedDict):
122 """Docstring staleness assessment for one symbol."""
123
124 is_stale: bool
125 """``True`` when the implementation changed more recently than the body
126 (which includes the docstring)."""
127
128 last_doc_commit: str | None
129 """Commit ID of the last event that changed the body (heuristic for docstring
130 edit). ``None`` when insufficient history exists."""
131
132 last_impl_commit: str | None
133 """Commit ID of the last event that changed the signature. ``None`` when
134 there has been no signature change."""
135
136 signature_changed: bool
137 """``True`` when the signature changed *after* the last body-only change."""
138
139 body_changed: bool
140 """``True`` when the body changed *after* the last signature change (reversed
141 drift — implementation regressed toward original, rare but possible)."""
142
143
144 # ---------------------------------------------------------------------------
145 # Internal helpers
146 # ---------------------------------------------------------------------------
147
148
149 def _build_commit_to_version_map(
150 root: pathlib.Path, repo_id: str
151 ) -> _StrMap:
152 """Return ``{commit_id: tag_name}`` for every tag recorded in the repository.
153
154 When a single commit carries multiple tags, the last-sorted tag wins
155 (stable, deterministic output regardless of filesystem iteration order).
156 """
157 tags = get_all_tags(root, repo_id)
158 result: _StrMap = {}
159 for tag in sorted(tags, key=lambda t: t.tag):
160 result[tag.commit_id] = tag.tag
161 return result
162
163
164 def _resolve_ref_to_commit_id(
165 root: pathlib.Path,
166 repo_id: str,
167 branch: str,
168 ref: str,
169 ) -> str | None:
170 """Resolve *ref* to a commit ID, accepting tag names, commit IDs, and HEAD notation."""
171 # First try the standard resolve path (handles HEAD, HEAD~N, SHA prefixes).
172 commit = resolve_commit_ref(root, repo_id, branch, ref)
173 if commit is not None:
174 return commit.commit_id
175
176 # Fallback: look up *ref* as a tag name.
177 for tag in get_all_tags(root, repo_id):
178 if tag.tag == ref:
179 return tag.commit_id
180
181 return None
182
183
184 # ---------------------------------------------------------------------------
185 # Public API
186 # ---------------------------------------------------------------------------
187
188
189 def get_symbol_version_events(
190 root: pathlib.Path,
191 repo_id: str,
192 address: str,
193 ) -> list[SymbolVersionEvent]:
194 """Return the full version history timeline for *address*.
195
196 Events are ordered oldest-first (insertion order in the index). Returns
197 an empty list when the index does not contain the address or has not been
198 built yet.
199
200 Args:
201 root: Repository root directory.
202 repo_id: Repository UUID — used to map commits to version tags.
203 address: Symbol address e.g. ``"muse/core/store.py::read_commit"``.
204 """
205 index = load_symbol_history(root)
206 entries = index.get(address, [])
207 if not entries:
208 return []
209
210 commit_version_map = _build_commit_to_version_map(root, repo_id)
211 events: list[SymbolVersionEvent] = []
212 for entry in entries:
213 commit = read_commit(root, entry.commit_id)
214 sem_ver_bump: str | None = None
215 breaking = False
216 if commit is not None:
217 raw_bump = commit.sem_ver_bump
218 sem_ver_bump = raw_bump if raw_bump != "none" else None
219 breaking = bool(commit.breaking_changes)
220 events.append(
221 SymbolVersionEvent(
222 commit_id=entry.commit_id,
223 committed_at=entry.committed_at,
224 op=entry.op,
225 version=commit_version_map.get(entry.commit_id),
226 sem_ver_bump=sem_ver_bump,
227 breaking=breaking,
228 )
229 )
230 return events
231
232
233 def infer_since_version(events: list[SymbolVersionEvent]) -> str | None:
234 """Return the version tag of the commit that first introduced this symbol.
235
236 Scans *events* (oldest-first) for an ``"insert"`` op with a non-``None``
237 ``version`` field. If no tagged insertion exists, falls back to the first
238 event of any op that carries a version.
239
240 Args:
241 events: Output of :func:`get_symbol_version_events`, oldest-first.
242
243 Returns:
244 The tag name string (e.g. ``"v1.2.0"``) or ``None`` when no tagged
245 commit is found.
246 """
247 for event in events:
248 if event["op"] == "insert" and event["version"] is not None:
249 return event["version"]
250 for event in events:
251 if event["version"] is not None:
252 return event["version"]
253 return None
254
255
256 def infer_last_changed_version(events: list[SymbolVersionEvent]) -> str | None:
257 """Return the version tag of the most recent modifying event.
258
259 Scans *events* in newest-first order for a ``"replace"`` or ``"delete"``
260 op that carries a version tag.
261
262 Args:
263 events: Output of :func:`get_symbol_version_events`, oldest-first.
264 """
265 for event in reversed(events):
266 if event["op"] in ("replace", "delete") and event["version"] is not None:
267 return event["version"]
268 return None
269
270
271 def detect_stale_docstring(
272 root: pathlib.Path,
273 address: str,
274 ) -> StaleInfo:
275 """Determine whether a symbol's docstring is potentially stale.
276
277 Compares the ``signature_id`` and ``body_hash`` timelines. When the
278 signature changed *after* the last body-only event, the docstring may
279 not reflect the current API — hence "stale."
280
281 This is necessarily a heuristic: the index tracks content hashes, not
282 which specific lines inside the body changed. A one-line body edit
283 that is *not* a docstring update will produce the same signal as a pure
284 docstring update.
285
286 When the index is absent or there are fewer than two events, returns a
287 :class:`StaleInfo` with ``is_stale=False`` (optimistic default — no
288 evidence of staleness).
289
290 Args:
291 root: Repository root directory.
292 address: Symbol address.
293 """
294 index = load_symbol_history(root)
295 entries = index.get(address, [])
296 if len(entries) < 2:
297 return StaleInfo(
298 is_stale=False,
299 last_doc_commit=None,
300 last_impl_commit=None,
301 signature_changed=False,
302 body_changed=False,
303 )
304
305 last_sig_change_idx: int | None = None
306 last_body_change_idx: int | None = None
307 prev = entries[0]
308 for i, entry in enumerate(entries[1:], start=1):
309 if entry.signature_id != prev.signature_id:
310 last_sig_change_idx = i
311 if entry.body_hash != prev.body_hash:
312 last_body_change_idx = i
313 prev = entry
314
315 if last_body_change_idx is None:
316 return StaleInfo(
317 is_stale=False,
318 last_doc_commit=None,
319 last_impl_commit=None,
320 signature_changed=False,
321 body_changed=False,
322 )
323
324 sig_changed_after = (
325 last_sig_change_idx is not None
326 and last_sig_change_idx > last_body_change_idx
327 )
328 body_changed_after = (
329 last_sig_change_idx is not None
330 and last_body_change_idx > last_sig_change_idx
331 )
332
333 last_body_entry = entries[last_body_change_idx]
334 last_impl_commit: str | None = (
335 entries[last_sig_change_idx].commit_id
336 if last_sig_change_idx is not None
337 else None
338 )
339
340 return StaleInfo(
341 is_stale=sig_changed_after or body_changed_after,
342 last_doc_commit=last_body_entry.commit_id,
343 last_impl_commit=last_impl_commit,
344 signature_changed=sig_changed_after,
345 body_changed=body_changed_after,
346 )
347
348
349 def generate_changelog(
350 root: pathlib.Path,
351 repo_id: str,
352 from_ref: str,
353 to_ref: str,
354 max_commits: int = 10_000,
355 ) -> ChangelogReport:
356 """Build a structured changelog between *from_ref* and *to_ref*.
357
358 Walks the commit graph from *to_ref* back to *from_ref* (exclusive),
359 then uses the symbol history index to classify each symbol address
360 touched in that range as added, removed, changed, or breaking.
361
362 Args:
363 root: Repository root directory.
364 repo_id: Repository UUID.
365 from_ref: Exclusive start — tag name, commit ID, or HEAD notation.
366 to_ref: Inclusive end — tag name, commit ID, or HEAD notation.
367 max_commits: Safety cap on the number of commits to walk.
368
369 Returns:
370 A fully-typed :class:`ChangelogReport`.
371 """
372 branch_raw = (root / ".muse" / "HEAD").read_text(encoding="utf-8").strip()
373 if branch_raw.startswith("ref: refs/heads/"):
374 branch = branch_raw[len("ref: refs/heads/"):]
375 else:
376 branch = "main"
377
378 from_commit_id = _resolve_ref_to_commit_id(root, repo_id, branch, from_ref)
379 to_commit_id = _resolve_ref_to_commit_id(root, repo_id, branch, to_ref)
380
381 if to_commit_id is None:
382 logger.warning("⚠️ Cannot resolve to_ref '%s' — returning empty changelog", to_ref)
383 return ChangelogReport(
384 from_ref=from_ref,
385 to_ref=to_ref,
386 added=[],
387 removed=[],
388 changed=[],
389 breaking=[],
390 )
391
392 commits = walk_commits_between(
393 root,
394 to_commit_id=to_commit_id,
395 from_commit_id=from_commit_id,
396 max_commits=max_commits,
397 )
398
399 # Collect the set of commit IDs in this range for fast lookup.
400 range_commit_ids: frozenset[str] = frozenset(c.commit_id for c in commits)
401
402 # Build a breaking-commit set for the range.
403 breaking_commits: frozenset[str] = frozenset(
404 c.commit_id for c in commits if c.breaking_changes
405 )
406
407 # Walk the symbol history index to classify each address.
408 index = load_symbol_history(root)
409
410 added: list[ChangelogEntry] = []
411 removed: list[ChangelogEntry] = []
412 changed: list[ChangelogEntry] = []
413 breaking: list[ChangelogEntry] = []
414 seen_addresses: set[str] = set()
415
416 for address, entries in index.items():
417 relevant = [e for e in entries if e.commit_id in range_commit_ids]
418 if not relevant:
419 continue
420 if address in seen_addresses:
421 continue
422 seen_addresses.add(address)
423
424 # Parse the address to extract file and name.
425 if "::" in address:
426 file_part, sym_part = address.split("::", 1)
427 else:
428 file_part = address
429 sym_part = address
430
431 # Determine the dominant op for this address in the range.
432 ops = {e.op for e in relevant}
433 has_breaking = any(e.commit_id in breaking_commits for e in relevant)
434
435 # Build a minimal ChangelogEntry without requiring the full SymbolRecord.
436 entry: ChangelogEntry = ChangelogEntry(
437 address=address,
438 name=sym_part.split(".")[-1],
439 kind="function", # best-effort default; callers may enrich
440 file=file_part,
441 )
442
443 if has_breaking:
444 breaking.append(entry)
445 elif "insert" in ops and "delete" not in ops:
446 added.append(entry)
447 elif "delete" in ops:
448 removed.append(entry)
449 else:
450 changed.append(entry)
451
452 return ChangelogReport(
453 from_ref=from_ref,
454 to_ref=to_ref,
455 added=sorted(added, key=lambda e: e["address"]),
456 removed=sorted(removed, key=lambda e: e["address"]),
457 changed=sorted(changed, key=lambda e: e["address"]),
458 breaking=sorted(breaking, key=lambda e: e["address"]),
459 )
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago