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