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