gabriel / muse public
test_cmd_commit_symlog.py python
516 lines 19.6 KB
Raw
sha256:c0eba5ad689cec79f4a3fcdc4f5da78556cb4b8cb7b330f944634356c10379ed chore: pivot to nightly channel — bump version to 0.2.0.dev… Sonnet 5 patch 12 days ago
1 """Tests for symlog commit integration — Phase 2.
2
3 Covers SL_13 through SL_22.
4 """
5
6 from __future__ import annotations
7
8 import json
9 import os
10 import pathlib
11 import textwrap
12
13 import pytest
14
15 from tests.cli_test_helper import CliRunner
16
17 from muse.core.ids import hash_blob
18 from muse.core.symlog import (
19 NULL_CONTENT_ID,
20 SymbolDiff,
21 compute_symbol_diff,
22 extract_symbols,
23 is_null_content_id,
24 list_symlog_symbols_for_file,
25 read_symlog,
26 )
27
28 runner = CliRunner()
29
30
31 # ---------------------------------------------------------------------------
32 # Helpers
33 # ---------------------------------------------------------------------------
34
35 def _invoke(repo: pathlib.Path, args: list[str]):
36 saved = os.getcwd()
37 try:
38 os.chdir(repo)
39 return runner.invoke(None, args)
40 finally:
41 os.chdir(saved)
42
43
44 def _init_repo(repo: pathlib.Path) -> None:
45 repo.mkdir(parents=True, exist_ok=True)
46 _invoke(repo, ["init"])
47
48
49 def _write_file(repo: pathlib.Path, rel: str, content: str) -> None:
50 p = repo / rel
51 p.parent.mkdir(parents=True, exist_ok=True)
52 p.write_text(textwrap.dedent(content))
53
54
55 def _commit(repo: pathlib.Path, msg: str) -> str:
56 """Stage all and commit; return the commit_id from JSON output."""
57 _invoke(repo, ["code", "add", "."])
58 result = _invoke(repo, [
59 "commit", "-m", msg,
60 "--agent-id", "claude-code",
61 "--model-id", "claude-sonnet-4-6",
62 "--author", "claude-code",
63 "--json",
64 ])
65 data = json.loads(result.stdout)
66 return data["commit_id"]
67
68
69 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
70 _init_repo(tmp_path)
71 return tmp_path
72
73
74 # ===========================================================================
75 # SL_13 — extract_symbols
76 # ===========================================================================
77
78
79 class TestExtractSymbols:
80 def test_returns_symbol_name_to_content_id_map(self, tmp_path: pathlib.Path) -> None:
81 """extract_symbols reads a file object and returns {name: content_id}."""
82 from muse.core.object_store import write_object
83
84 repo = _make_repo(tmp_path)
85 src = b"def compute_total(items):\n return sum(items)\n"
86 digest = hash_blob(src)
87 write_object(repo, digest, src)
88
89 result = extract_symbols(repo, digest, "src/billing.py")
90 assert isinstance(result, dict)
91 assert "compute_total" in result
92 assert result["compute_total"].startswith("sha256:")
93
94 def test_returns_empty_for_non_code_file(self, tmp_path: pathlib.Path) -> None:
95 """extract_symbols returns {} for binary or non-parseable objects."""
96 from muse.core.object_store import write_object
97
98 repo = _make_repo(tmp_path)
99 src = b"\x00\x01\x02\x03 binary data \xff"
100 digest = hash_blob(src)
101 write_object(repo, digest, src)
102
103 result = extract_symbols(repo, digest, "src/binary.bin")
104 assert result == {}
105
106 def test_returns_empty_for_unknown_object_id(self, tmp_path: pathlib.Path) -> None:
107 repo = _make_repo(tmp_path)
108 result = extract_symbols(repo, "sha256:" + "a" * 64)
109 assert result == {}
110
111 def test_multiple_symbols(self, tmp_path: pathlib.Path) -> None:
112 from muse.core.object_store import write_object
113
114 repo = _make_repo(tmp_path)
115 src = b"""
116 def compute_total(items):
117 return sum(items)
118
119 def validate_invoice(inv):
120 return inv is not None
121
122 class Invoice:
123 pass
124 """
125 digest = hash_blob(src)
126 write_object(repo, digest, src)
127
128 result = extract_symbols(repo, digest, "src/billing.py")
129 assert "compute_total" in result
130 assert "validate_invoice" in result
131 for cid in result.values():
132 assert cid.startswith("sha256:")
133
134
135 # ===========================================================================
136 # SL_14 — compute_symbol_diff
137 # ===========================================================================
138
139
140 class TestComputeSymbolDiff:
141 _CID_A = "sha256:" + "a" * 64
142 _CID_B = "sha256:" + "b" * 64
143 _CID_C = "sha256:" + "c" * 64
144
145 def test_created_symbols(self) -> None:
146 old: dict[str, str] = {}
147 new = {"compute_total": self._CID_A, "validate": self._CID_B}
148 diff = compute_symbol_diff(old, new)
149 assert "compute_total" in diff.created
150 assert "validate" in diff.created
151 assert not diff.deleted
152 assert not diff.modified
153
154 def test_deleted_symbols(self) -> None:
155 old = {"compute_total": self._CID_A}
156 new: dict[str, str] = {}
157 diff = compute_symbol_diff(old, new)
158 assert "compute_total" in diff.deleted
159 assert not diff.created
160 assert not diff.modified
161
162 def test_modified_symbols(self) -> None:
163 old = {"compute_total": self._CID_A}
164 new = {"compute_total": self._CID_B}
165 diff = compute_symbol_diff(old, new)
166 assert "compute_total" in diff.modified
167 assert not diff.created
168 assert not diff.deleted
169
170 def test_unchanged_symbols_not_in_diff(self) -> None:
171 old = {"compute_total": self._CID_A}
172 new = {"compute_total": self._CID_A}
173 diff = compute_symbol_diff(old, new)
174 assert not diff.created
175 assert not diff.deleted
176 assert not diff.modified
177 assert not diff.renames
178
179 def test_rename_detection(self) -> None:
180 """A deleted name whose content_id matches a created name's content_id → rename."""
181 old = {"compute_total": self._CID_A}
182 new = {"compute_invoice_total": self._CID_A}
183 diff = compute_symbol_diff(old, new)
184 assert len(diff.renames) == 1
185 old_name, new_name = diff.renames[0]
186 assert old_name == "compute_total"
187 assert new_name == "compute_invoice_total"
188 assert "compute_total" not in diff.deleted
189 assert "compute_invoice_total" not in diff.created
190
191 def test_rename_with_content_change_is_not_a_rename(self) -> None:
192 old = {"compute_total": self._CID_A}
193 new = {"compute_invoice_total": self._CID_B}
194 diff = compute_symbol_diff(old, new)
195 assert not diff.renames
196 assert "compute_total" in diff.deleted
197 assert "compute_invoice_total" in diff.created
198
199 def test_mixed_operations(self) -> None:
200 old = {
201 "compute_total": self._CID_A,
202 "validate_invoice": self._CID_B,
203 "old_helper": self._CID_C,
204 }
205 new = {
206 "compute_total": self._CID_B,
207 "format_output": self._CID_A,
208 "new_helper": self._CID_C,
209 }
210 diff = compute_symbol_diff(old, new)
211 assert "compute_total" in diff.modified
212 assert "validate_invoice" in diff.deleted
213 assert "format_output" in diff.created
214 assert ("old_helper", "new_helper") in diff.renames
215
216 def test_returns_symbol_diff_dataclass(self) -> None:
217 diff = compute_symbol_diff({}, {})
218 assert isinstance(diff, SymbolDiff)
219 assert hasattr(diff, "created")
220 assert hasattr(diff, "deleted")
221 assert hasattr(diff, "modified")
222 assert hasattr(diff, "renames")
223
224
225 # ===========================================================================
226 # SL_15 — _write_symlogs is non-fatal (tested via commit)
227 # ===========================================================================
228
229
230 class TestWriteSymlogsNonFatal:
231 def test_commit_succeeds_even_with_no_code_files(self, tmp_path: pathlib.Path) -> None:
232 repo = _make_repo(tmp_path)
233 _write_file(repo, "README.txt", "hello\n")
234 commit_id = _commit(repo, "add readme")
235 assert commit_id.startswith("sha256:")
236
237 def test_commit_returns_commit_id(self, tmp_path: pathlib.Path) -> None:
238 repo = _make_repo(tmp_path)
239 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
240 commit_id = _commit(repo, "add billing")
241 assert commit_id.startswith("sha256:")
242
243
244 # ===========================================================================
245 # SL_16 — Created symbols get symbol-created entries
246 # ===========================================================================
247
248
249 class TestSymlogCreated:
250 def test_new_symbol_gets_created_entry(self, tmp_path: pathlib.Path) -> None:
251 repo = _make_repo(tmp_path)
252 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
253 _commit(repo, "add compute_total")
254
255 entries = read_symlog(repo, "src/billing.py::compute_total")
256 assert len(entries) >= 1
257 newest = entries[0]
258 assert newest.operation.startswith("symbol-created:")
259 assert is_null_content_id(newest.old_content_id)
260 assert not is_null_content_id(newest.new_content_id)
261
262 def test_created_entry_has_correct_author(self, tmp_path: pathlib.Path) -> None:
263 repo = _make_repo(tmp_path)
264 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
265 _commit(repo, "add compute_total")
266
267 entries = read_symlog(repo, "src/billing.py::compute_total")
268 assert entries[0].author == "claude-code"
269
270 def test_all_new_symbols_get_created_entries(self, tmp_path: pathlib.Path) -> None:
271 repo = _make_repo(tmp_path)
272 _write_file(repo, "src/billing.py", textwrap.dedent("""\
273 def compute_total(items):
274 return sum(items)
275
276 def validate_invoice(inv):
277 return inv is not None
278 """))
279 _commit(repo, "add billing module")
280
281 syms = list_symlog_symbols_for_file(repo, "src/billing.py")
282 names = {s.split("::")[-1] for s in syms}
283 assert "compute_total" in names
284 assert "validate_invoice" in names
285
286
287 # ===========================================================================
288 # SL_17 — Modified symbols get symbol-modified entries
289 # ===========================================================================
290
291
292 class TestSymlogModified:
293 def test_modified_symbol_gets_modified_entry(self, tmp_path: pathlib.Path) -> None:
294 repo = _make_repo(tmp_path)
295 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
296 _commit(repo, "add compute_total")
297
298 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items) + 0.0\n")
299 _commit(repo, "fix: improve compute_total")
300
301 entries = read_symlog(repo, "src/billing.py::compute_total")
302 assert entries[0].operation.startswith("symbol-modified:")
303 assert not is_null_content_id(entries[0].old_content_id)
304 assert not is_null_content_id(entries[0].new_content_id)
305 assert entries[0].old_content_id != entries[0].new_content_id
306
307 def test_modified_entry_contains_commit_message(self, tmp_path: pathlib.Path) -> None:
308 repo = _make_repo(tmp_path)
309 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
310 _commit(repo, "add compute_total")
311
312 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items) + 0.0\n")
313 _commit(repo, "fix: off-by-one in total")
314
315 entries = read_symlog(repo, "src/billing.py::compute_total")
316 assert "fix: off-by-one in total" in entries[0].operation
317
318 def test_modified_entry_has_correct_commit_id(self, tmp_path: pathlib.Path) -> None:
319 repo = _make_repo(tmp_path)
320 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
321 _commit(repo, "add")
322
323 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items) + 0.0\n")
324 commit_id = _commit(repo, "fix: improve")
325
326 entries = read_symlog(repo, "src/billing.py::compute_total")
327 assert entries[0].commit_id == commit_id
328
329
330 # ===========================================================================
331 # SL_18 — Deleted symbols get symbol-deleted entries
332 # ===========================================================================
333
334
335 class TestSymlogDeleted:
336 def test_deleted_symbol_gets_deleted_entry(self, tmp_path: pathlib.Path) -> None:
337 repo = _make_repo(tmp_path)
338 _write_file(repo, "src/billing.py", textwrap.dedent("""\
339 def compute_total(items):
340 return sum(items)
341
342 def old_helper(x):
343 return x
344 """))
345 _commit(repo, "add billing")
346
347 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
348 _commit(repo, "remove old_helper")
349
350 entries = read_symlog(repo, "src/billing.py::old_helper")
351 assert len(entries) >= 2
352 assert entries[0].operation.startswith("symbol-deleted:")
353 assert is_null_content_id(entries[0].new_content_id)
354 assert not is_null_content_id(entries[0].old_content_id)
355
356
357 # ===========================================================================
358 # SL_19 — Renamed symbols get two entries
359 # ===========================================================================
360
361
362 class TestSymlogRenamed:
363 def test_rename_writes_two_entries(self, tmp_path: pathlib.Path) -> None:
364 repo = _make_repo(tmp_path)
365 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
366 _commit(repo, "add compute_total")
367
368 # Rename: same body, different name
369 _write_file(repo, "src/billing.py", "def compute_invoice_total(items):\n return sum(items)\n")
370 _commit(repo, "rename: compute_total to compute_invoice_total")
371
372 old_entries = read_symlog(repo, "src/billing.py::compute_total")
373 new_entries = read_symlog(repo, "src/billing.py::compute_invoice_total")
374
375 # Old path: terminal renamed-to entry
376 assert any(e.operation.startswith("symbol-renamed-to:") for e in old_entries)
377 renamed_to = next(e for e in old_entries if e.operation.startswith("symbol-renamed-to:"))
378 assert "compute_invoice_total" in renamed_to.operation
379 assert is_null_content_id(renamed_to.new_content_id)
380
381 # New path: born-from entry
382 assert any(e.operation.startswith("symbol-born-from:") for e in new_entries)
383 born_from = next(e for e in new_entries if e.operation.startswith("symbol-born-from:"))
384 assert "compute_total" in born_from.operation
385 assert born_from.born_from is not None
386 assert "compute_total" in born_from.born_from
387 assert is_null_content_id(born_from.old_content_id)
388
389
390 # ===========================================================================
391 # SL_20 — Initial commit: all symbols get symbol-created entries
392 # ===========================================================================
393
394
395 class TestSymlogInitialCommit:
396 def test_initial_commit_all_symbols_created(self, tmp_path: pathlib.Path) -> None:
397 repo = _make_repo(tmp_path)
398 _write_file(repo, "src/billing.py", textwrap.dedent("""\
399 def compute_total(items):
400 return sum(items)
401
402 def validate_invoice(inv):
403 return inv is not None
404 """))
405 _commit(repo, "initial: add billing module")
406
407 for sym in ("compute_total", "validate_invoice"):
408 entries = read_symlog(repo, f"src/billing.py::{sym}")
409 assert len(entries) == 1
410 assert entries[0].operation.startswith("symbol-created:")
411 assert is_null_content_id(entries[0].old_content_id)
412
413
414 # ===========================================================================
415 # SL_21 — Integration: two-commit sequence
416 # ===========================================================================
417
418
419 class TestIntegrationTwoCommitSequence:
420 def test_create_modify_delete_sequence(self, tmp_path: pathlib.Path) -> None:
421 """SL_21: two-commit sequence with 3 functions; verify exact entries."""
422 repo = _make_repo(tmp_path)
423
424 # Commit 1: create 3 functions
425 _write_file(repo, "src/billing.py", textwrap.dedent("""\
426 def compute_total(items):
427 return sum(items)
428
429 def validate_invoice(inv):
430 return inv is not None
431
432 def format_output(result):
433 return str(result)
434 """))
435 commit1 = _commit(repo, "feat: add billing module")
436
437 for sym in ("compute_total", "validate_invoice", "format_output"):
438 entries = read_symlog(repo, f"src/billing.py::{sym}")
439 assert len(entries) == 1, f"expected 1 entry for {sym}, got {len(entries)}"
440 assert entries[0].operation.startswith("symbol-created:")
441 assert entries[0].commit_id == commit1
442
443 # Commit 2: modify compute_total, delete format_output, add new_helper
444 _write_file(repo, "src/billing.py", textwrap.dedent("""\
445 def compute_total(items):
446 return round(sum(items), 2)
447
448 def validate_invoice(inv):
449 return inv is not None
450
451 def new_helper(x):
452 return x * 2
453 """))
454 commit2 = _commit(repo, "fix: round total; add new_helper; remove format_output")
455
456 # compute_total: modified
457 ct_entries = read_symlog(repo, "src/billing.py::compute_total")
458 assert len(ct_entries) == 2
459 assert ct_entries[0].operation.startswith("symbol-modified:")
460 assert ct_entries[0].commit_id == commit2
461 assert ct_entries[1].operation.startswith("symbol-created:")
462 assert ct_entries[1].commit_id == commit1
463
464 # validate_invoice: unchanged — no new entry
465 vi_entries = read_symlog(repo, "src/billing.py::validate_invoice")
466 assert len(vi_entries) == 1
467 assert vi_entries[0].operation.startswith("symbol-created:")
468
469 # format_output: deleted
470 fo_entries = read_symlog(repo, "src/billing.py::format_output")
471 assert fo_entries[0].operation.startswith("symbol-deleted:")
472 assert fo_entries[0].commit_id == commit2
473
474 # new_helper: created
475 nh_entries = read_symlog(repo, "src/billing.py::new_helper")
476 assert len(nh_entries) == 1
477 assert nh_entries[0].operation.startswith("symbol-created:")
478 assert nh_entries[0].commit_id == commit2
479
480
481 # ===========================================================================
482 # SL_22 — Integration: rename scenario with --follow
483 # ===========================================================================
484
485
486 class TestIntegrationRenameFollow:
487 def test_rename_follow_traverses_chain(self, tmp_path: pathlib.Path) -> None:
488 """SL_22: foo's log ends with renamed-to; bar starts with born-from;
489 read_symlog('bar', follow=True) returns full history."""
490 repo = _make_repo(tmp_path)
491
492 # Commit 1: create foo
493 _write_file(repo, "src/billing.py", "def foo(x):\n return x\n")
494 commit1 = _commit(repo, "add foo")
495
496 foo_c1 = read_symlog(repo, "src/billing.py::foo")
497 assert foo_c1[0].operation.startswith("symbol-created:")
498
499 # Commit 2: rename foo → bar (same body, different name)
500 _write_file(repo, "src/billing.py", "def bar(x):\n return x\n")
501 commit2 = _commit(repo, "rename: foo to bar")
502
503 # foo's log ends with symbol-renamed-to
504 foo_entries = read_symlog(repo, "src/billing.py::foo")
505 assert any(e.operation.startswith("symbol-renamed-to:") for e in foo_entries)
506
507 # bar's log has born-from entry
508 bar_entries = read_symlog(repo, "src/billing.py::bar")
509 assert any(e.operation.startswith("symbol-born-from:") for e in bar_entries)
510
511 # --follow: bar's full history includes foo's created entry
512 bar_followed = read_symlog(repo, "src/billing.py::bar", follow=True)
513 ops = [e.operation for e in bar_followed]
514 assert any(op.startswith("symbol-born-from:") for op in ops)
515 assert any(op.startswith("symbol-created:") for op in ops)
516 assert len(bar_followed) >= 3
File History 3 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