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