gabriel / muse public
test_core_doc_history.py python
499 lines 17.6 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 133 days ago
1 """Unit and integration tests for ``muse.core.doc_history``.
2
3 Coverage:
4 - :func:`get_symbol_version_events` with empty, single, and multi-entry index.
5 - :func:`infer_since_version` with tagged and untagged events.
6 - :func:`infer_last_changed_version` with various event sequences.
7 - :func:`detect_stale_docstring` with insufficient history, stable, and stale symbols.
8 - :func:`generate_changelog` with added/removed/changed/breaking classifications.
9 - :func:`_build_commit_to_version_map` determinism with multiple tags.
10 """
11
12 from __future__ import annotations
13
14 import datetime
15 import hashlib
16 import pathlib
17 import uuid
18
19 import pytest
20
21 from muse.core.doc_history import (
22 ChangelogReport,
23 StaleInfo,
24 SymbolVersionEvent,
25 _build_commit_to_version_map,
26 detect_stale_docstring,
27 generate_changelog,
28 get_symbol_version_events,
29 infer_last_changed_version,
30 infer_since_version,
31 )
32 from muse.domain import SemVerBump
33 from muse.core.indices import (
34 SymbolHistoryEntry,
35 SymbolHistoryIndex,
36 save_symbol_history,
37 )
38 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
39
40 from muse.core._types import Manifest, fake_id, long_id
41
42 _REPO_ID = fake_id("test-repo-123")
43
44 from muse.core.store import (
45 CommitRecord,
46 SnapshotRecord,
47 TagRecord,
48 write_commit,
49 write_snapshot,
50 write_tag,
51 )
52
53
54 # ---------------------------------------------------------------------------
55 # Fixtures
56 # ---------------------------------------------------------------------------
57
58
59 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
60 """Create a minimal .muse/ repository skeleton."""
61 muse = tmp_path / ".muse"
62 muse.mkdir()
63 import json as _json
64 (muse / "repo.json").write_text(_json.dumps({"repo_id": _REPO_ID, "name": "test"}))
65 refs = muse / "refs" / "heads"
66 refs.mkdir(parents=True)
67 (muse / "HEAD").write_text("ref: refs/heads/main\n")
68 return tmp_path
69
70
71 def _write_commit(
72 root: pathlib.Path,
73 label: str,
74 parent_id: str | None = None,
75 sem_ver_bump: SemVerBump = "none",
76 breaking_changes: list[str] | None = None,
77 ) -> CommitRecord:
78 manifest: Manifest = {}
79 snapshot_id = compute_snapshot_id(manifest)
80 write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=manifest))
81 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
82 message = f"test commit {label}"
83 parent_ids = [parent_id] if parent_id else []
84 commit_id = compute_commit_id(
85 repo_id=_REPO_ID,
86 parent_ids=parent_ids,
87 snapshot_id=snapshot_id,
88 message=message,
89 committed_at_iso=committed_at.isoformat(),
90 author="test",)
91 commit = CommitRecord(
92 commit_id=commit_id,
93 repo_id=_REPO_ID,
94 created_on_branch="main",
95 snapshot_id=snapshot_id,
96 message=message,
97 committed_at=committed_at,
98 author="test",
99 parent_commit_id=parent_id,
100 sem_ver_bump=sem_ver_bump,
101 breaking_changes=breaking_changes or [],
102 )
103 write_commit(root, commit)
104 (root / ".muse" / "refs" / "heads" / "main").write_text(commit_id)
105 return commit
106
107
108 def _write_tag(root: pathlib.Path, tag_name: str, commit_id: str) -> None:
109 tag = TagRecord(
110 tag_id=fake_id(tag_name + "-tag"),
111 repo_id=_REPO_ID,
112 commit_id=commit_id,
113 tag=tag_name,
114 )
115 write_tag(root, tag)
116
117
118 def _make_entry(
119 commit_id: str,
120 op: str = "insert",
121 content_id: str = "c1",
122 body_hash: str = "b1",
123 signature_id: str = "s1",
124 ) -> SymbolHistoryEntry:
125 return SymbolHistoryEntry(
126 commit_id=commit_id,
127 committed_at="2026-01-01T00:00:00+00:00",
128 op=op,
129 content_id=content_id,
130 body_hash=body_hash,
131 signature_id=signature_id,
132 )
133
134
135 # ---------------------------------------------------------------------------
136 # Tests: get_symbol_version_events
137 # ---------------------------------------------------------------------------
138
139
140 class TestGetSymbolVersionEvents:
141 def test_empty_index(self, tmp_path: pathlib.Path) -> None:
142 root = _make_repo(tmp_path)
143 events = get_symbol_version_events(root, _REPO_ID, "foo.py::bar")
144 assert events == []
145
146 def test_address_not_in_index(self, tmp_path: pathlib.Path) -> None:
147 root = _make_repo(tmp_path)
148 index: SymbolHistoryIndex = {
149 "other.py::baz": [_make_entry("abc123")]
150 }
151 save_symbol_history(root, index)
152 events = get_symbol_version_events(root, _REPO_ID, "foo.py::bar")
153 assert events == []
154
155 def test_single_entry_no_commit(self, tmp_path: pathlib.Path) -> None:
156 """When the commit is not in the store, events still have sem_ver_bump=None."""
157 root = _make_repo(tmp_path)
158 index: SymbolHistoryIndex = {
159 "foo.py::bar": [_make_entry(fake_id("deadbeef01"))]
160 }
161 save_symbol_history(root, index)
162 events = get_symbol_version_events(root, _REPO_ID, "foo.py::bar")
163 assert len(events) == 1
164 assert events[0]["op"] == "insert"
165 assert events[0]["sem_ver_bump"] is None
166 assert events[0]["version"] is None
167 assert events[0]["breaking"] is False
168
169 def test_event_with_commit_and_tag(self, tmp_path: pathlib.Path) -> None:
170 root = _make_repo(tmp_path)
171 rec = _write_commit(root, "a", sem_ver_bump="minor")
172 cid = rec.commit_id
173 _write_tag(root, "v1.0.0", cid)
174 index: SymbolHistoryIndex = {"foo.py::bar": [_make_entry(cid)]}
175 save_symbol_history(root, index)
176
177 events = get_symbol_version_events(root, _REPO_ID, "foo.py::bar")
178 assert len(events) == 1
179 assert events[0]["version"] == "v1.0.0"
180 assert events[0]["sem_ver_bump"] == "minor"
181 assert events[0]["breaking"] is False
182
183 def test_event_with_breaking_commit(self, tmp_path: pathlib.Path) -> None:
184 root = _make_repo(tmp_path)
185 rec = _write_commit(root, "b", sem_ver_bump="major", breaking_changes=["Removed foo()"])
186 cid = rec.commit_id
187 index: SymbolHistoryIndex = {
188 "foo.py::bar": [_make_entry(cid, op="replace")]
189 }
190 save_symbol_history(root, index)
191
192 events = get_symbol_version_events(root, _REPO_ID, "foo.py::bar")
193 assert events[0]["breaking"] is True
194
195 def test_multiple_events_ordered(self, tmp_path: pathlib.Path) -> None:
196 root = _make_repo(tmp_path)
197 rec1 = _write_commit(root, "1")
198 rec2 = _write_commit(root, "2")
199 entries = [
200 _make_entry(rec1.commit_id, op="insert"),
201 _make_entry(rec2.commit_id, op="replace", content_id="c2"),
202 ]
203 index: SymbolHistoryIndex = {"foo.py::bar": entries}
204 save_symbol_history(root, index)
205
206 events = get_symbol_version_events(root, _REPO_ID, "foo.py::bar")
207 assert len(events) == 2
208 assert events[0]["op"] == "insert"
209 assert events[1]["op"] == "replace"
210
211
212 # ---------------------------------------------------------------------------
213 # Tests: infer_since_version
214 # ---------------------------------------------------------------------------
215
216
217 class TestInferSinceVersion:
218 def test_empty_events(self) -> None:
219 assert infer_since_version([]) is None
220
221 def test_single_untagged(self) -> None:
222 ev = SymbolVersionEvent(
223 commit_id="abc",
224 committed_at="2026-01-01T00:00:00+00:00",
225 op="insert",
226 version=None,
227 sem_ver_bump=None,
228 breaking=False,
229 )
230 assert infer_since_version([ev]) is None
231
232 def test_insert_with_version(self) -> None:
233 ev = SymbolVersionEvent(
234 commit_id="abc",
235 committed_at="2026-01-01T00:00:00+00:00",
236 op="insert",
237 version="v1.0.0",
238 sem_ver_bump="minor",
239 breaking=False,
240 )
241 assert infer_since_version([ev]) == "v1.0.0"
242
243 def test_prefers_insert_over_replace(self) -> None:
244 ev1 = SymbolVersionEvent(
245 commit_id="a",
246 committed_at="2026-01-01T00:00:00+00:00",
247 op="insert",
248 version="v1.0.0",
249 sem_ver_bump=None,
250 breaking=False,
251 )
252 ev2 = SymbolVersionEvent(
253 commit_id="b",
254 committed_at="2026-02-01T00:00:00+00:00",
255 op="replace",
256 version="v2.0.0",
257 sem_ver_bump=None,
258 breaking=False,
259 )
260 assert infer_since_version([ev1, ev2]) == "v1.0.0"
261
262 def test_fallback_to_first_event_with_version(self) -> None:
263 ev1 = SymbolVersionEvent(
264 commit_id="a",
265 committed_at="2026-01-01T00:00:00+00:00",
266 op="replace", # not "insert"
267 version="v0.9.0",
268 sem_ver_bump=None,
269 breaking=False,
270 )
271 assert infer_since_version([ev1]) == "v0.9.0"
272
273
274 # ---------------------------------------------------------------------------
275 # Tests: infer_last_changed_version
276 # ---------------------------------------------------------------------------
277
278
279 class TestInferLastChangedVersion:
280 def test_empty(self) -> None:
281 assert infer_last_changed_version([]) is None
282
283 def test_only_insert(self) -> None:
284 ev = SymbolVersionEvent(
285 commit_id="a",
286 committed_at="2026-01-01T00:00:00+00:00",
287 op="insert",
288 version="v1.0.0",
289 sem_ver_bump=None,
290 breaking=False,
291 )
292 # "insert" is not "replace"/"delete" so returns None.
293 assert infer_last_changed_version([ev]) is None
294
295 def test_replace_returns_version(self) -> None:
296 ev1 = SymbolVersionEvent(
297 commit_id="a",
298 committed_at="2026-01-01T00:00:00+00:00",
299 op="insert",
300 version="v1.0.0",
301 sem_ver_bump=None,
302 breaking=False,
303 )
304 ev2 = SymbolVersionEvent(
305 commit_id="b",
306 committed_at="2026-02-01T00:00:00+00:00",
307 op="replace",
308 version="v1.1.0",
309 sem_ver_bump=None,
310 breaking=False,
311 )
312 assert infer_last_changed_version([ev1, ev2]) == "v1.1.0"
313
314 def test_newest_first_scan(self) -> None:
315 """infer_last_changed_version scans newest-first."""
316 events = [
317 SymbolVersionEvent(
318 commit_id="a",
319 committed_at="2026-01-01T00:00:00+00:00",
320 op="replace",
321 version="v1.0.0",
322 sem_ver_bump=None,
323 breaking=False,
324 ),
325 SymbolVersionEvent(
326 commit_id="b",
327 committed_at="2026-02-01T00:00:00+00:00",
328 op="replace",
329 version="v2.0.0",
330 sem_ver_bump=None,
331 breaking=False,
332 ),
333 ]
334 assert infer_last_changed_version(events) == "v2.0.0"
335
336
337 # ---------------------------------------------------------------------------
338 # Tests: detect_stale_docstring
339 # ---------------------------------------------------------------------------
340
341
342 class TestDetectStaleDocstring:
343 def test_empty_index(self, tmp_path: pathlib.Path) -> None:
344 root = _make_repo(tmp_path)
345 info = detect_stale_docstring(root, "foo.py::bar")
346 assert info["is_stale"] is False
347 assert info["last_doc_commit"] is None
348
349 def test_single_entry_not_stale(self, tmp_path: pathlib.Path) -> None:
350 root = _make_repo(tmp_path)
351 index: SymbolHistoryIndex = {
352 "foo.py::bar": [_make_entry("abc")]
353 }
354 save_symbol_history(root, index)
355 info = detect_stale_docstring(root, "foo.py::bar")
356 assert info["is_stale"] is False
357
358 def test_stable_body_and_sig(self, tmp_path: pathlib.Path) -> None:
359 """Two events with same hashes — nothing changed."""
360 root = _make_repo(tmp_path)
361 entries = [
362 _make_entry("a1", body_hash="bh1", signature_id="sg1"),
363 _make_entry("a2", op="replace", body_hash="bh1", signature_id="sg1"),
364 ]
365 index: SymbolHistoryIndex = {"foo.py::bar": entries}
366 save_symbol_history(root, index)
367 info = detect_stale_docstring(root, "foo.py::bar")
368 assert info["is_stale"] is False
369
370 def test_sig_changed_after_body(self, tmp_path: pathlib.Path) -> None:
371 """Signature changed after body → stale."""
372 root = _make_repo(tmp_path)
373 entries = [
374 _make_entry("a1", body_hash="bh1", signature_id="sg1"),
375 _make_entry("a2", op="replace", body_hash="bh2", signature_id="sg1"), # body changed
376 _make_entry("a3", op="replace", body_hash="bh2", signature_id="sg2"), # sig changed
377 ]
378 index: SymbolHistoryIndex = {"foo.py::bar": entries}
379 save_symbol_history(root, index)
380 info = detect_stale_docstring(root, "foo.py::bar")
381 assert info["is_stale"] is True
382 assert info["signature_changed"] is True
383
384 def test_not_stale_when_body_last(self, tmp_path: pathlib.Path) -> None:
385 """Body changed last — no staleness."""
386 root = _make_repo(tmp_path)
387 entries = [
388 _make_entry("a1", body_hash="bh1", signature_id="sg1"),
389 _make_entry("a2", op="replace", body_hash="bh1", signature_id="sg2"), # sig changed
390 _make_entry("a3", op="replace", body_hash="bh2", signature_id="sg2"), # body changed
391 ]
392 index: SymbolHistoryIndex = {"foo.py::bar": entries}
393 save_symbol_history(root, index)
394 info = detect_stale_docstring(root, "foo.py::bar")
395 # body changed after sig → body_changed = True → is_stale = True
396 assert info["is_stale"] is True
397 assert info["body_changed"] is True
398
399
400 # ---------------------------------------------------------------------------
401 # Tests: generate_changelog
402 # ---------------------------------------------------------------------------
403
404
405 class TestGenerateChangelog:
406 def test_unresolvable_to_ref(self, tmp_path: pathlib.Path) -> None:
407 root = _make_repo(tmp_path)
408 result = generate_changelog(root, _REPO_ID, "v0.9", "v999")
409 assert result["from_ref"] == "v0.9"
410 assert result["to_ref"] == "v999"
411 assert result["added"] == []
412 assert result["removed"] == []
413 assert result["changed"] == []
414 assert result["breaking"] == []
415
416 def test_empty_range(self, tmp_path: pathlib.Path) -> None:
417 """With no commits in range, all sections are empty."""
418 root = _make_repo(tmp_path)
419 rec = _write_commit(root, "f")
420 _write_tag(root, "v1.0", rec.commit_id)
421 result = generate_changelog(root, _REPO_ID, "v1.0", "v1.0")
422 assert result["added"] == []
423
424 def test_added_symbol(self, tmp_path: pathlib.Path) -> None:
425 """A symbol with only 'insert' events in range appears in 'added'."""
426 root = _make_repo(tmp_path)
427 rec = _write_commit(root, "c")
428 cid = rec.commit_id
429 _write_tag(root, "v1.1", cid)
430
431 entries = [_make_entry(cid, op="insert")]
432 index: SymbolHistoryIndex = {"foo.py::new_fn": entries}
433 save_symbol_history(root, index)
434
435 result = generate_changelog(root, _REPO_ID, "v1.0", "v1.1")
436 added_addrs = [e["address"] for e in result["added"]]
437 assert "foo.py::new_fn" in added_addrs
438
439 def test_breaking_symbol(self, tmp_path: pathlib.Path) -> None:
440 """A symbol in a commit with breaking_changes appears in 'breaking'."""
441 root = _make_repo(tmp_path)
442 rec = _write_commit(root, "d", breaking_changes=["Removed API"])
443 cid = rec.commit_id
444 _write_tag(root, "v2.0", cid)
445
446 entries = [_make_entry(cid, op="replace")]
447 index: SymbolHistoryIndex = {"foo.py::changed_fn": entries}
448 save_symbol_history(root, index)
449
450 result = generate_changelog(root, _REPO_ID, "v1.0", "v2.0")
451 breaking_addrs = [e["address"] for e in result["breaking"]]
452 assert "foo.py::changed_fn" in breaking_addrs
453
454 def test_sorted_output(self, tmp_path: pathlib.Path) -> None:
455 """Output entries are sorted by address."""
456 root = _make_repo(tmp_path)
457 rec = _write_commit(root, "e")
458 cid = rec.commit_id
459 _write_tag(root, "v1.2", cid)
460
461 index: SymbolHistoryIndex = {
462 "z.py::b": [_make_entry(cid, op="insert")],
463 "a.py::a": [_make_entry(cid, op="insert")],
464 }
465 save_symbol_history(root, index)
466
467 result = generate_changelog(root, _REPO_ID, "v0.9", "v1.2")
468 addrs = [e["address"] for e in result["added"]]
469 assert addrs == sorted(addrs)
470
471
472 # ---------------------------------------------------------------------------
473 # Tests: _build_commit_to_version_map
474 # ---------------------------------------------------------------------------
475
476
477 class TestBuildCommitToVersionMap:
478 def test_empty(self, tmp_path: pathlib.Path) -> None:
479 root = _make_repo(tmp_path)
480 result = _build_commit_to_version_map(root, _REPO_ID)
481 assert result == {}
482
483 def test_single_tag(self, tmp_path: pathlib.Path) -> None:
484 root = _make_repo(tmp_path)
485 rec = _write_commit(root, "a")
486 _write_tag(root, "v1.0", rec.commit_id)
487 result = _build_commit_to_version_map(root, _REPO_ID)
488 assert result[rec.commit_id] == "v1.0"
489
490 def test_deterministic_with_multiple_tags(self, tmp_path: pathlib.Path) -> None:
491 """When a commit has multiple tags, the last-sorted tag wins."""
492 root = _make_repo(tmp_path)
493 rec = _write_commit(root, "b")
494 cid = rec.commit_id
495 _write_tag(root, "v1.0.0", cid)
496 _write_tag(root, "v1.0.1", cid)
497 result = _build_commit_to_version_map(root, _REPO_ID)
498 # Sorted: "v1.0.0" < "v1.0.1" — last sorted wins
499 assert result[cid] == "v1.0.1"
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 133 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 139 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 142 days ago