gabriel / muse public
test_core_symlog.py python
682 lines 28.9 KB
Raw
sha256:f9828efc523c2f8ccaee12ae76086a09a9a6a10d6dd20e53f0b13793f0fdcf50 docs: add symlog (#53) and reflog (#54) follow-up issue files Sonnet 4.6 18 days ago
1 """Tests for muse/core/symlog.py — Phase 1 core storage layer.
2
3 Covers SL_01 through SL_12.
4 """
5
6 from __future__ import annotations
7
8 import datetime
9 import pathlib
10 import time
11
12 import pytest
13
14 # ---------------------------------------------------------------------------
15 # Helpers that will be imported once the module exists
16 # ---------------------------------------------------------------------------
17
18 from muse.core.symlog import (
19 NULL_CONTENT_ID,
20 SymlogEntry,
21 append_symlog,
22 delete_symlog_entry,
23 expire_symlog,
24 is_null_content_id,
25 list_symlog_symbols,
26 list_symlog_symbols_for_file,
27 read_symlog,
28 symlog_path,
29 )
30
31 # Test fixtures — distinct content IDs (sha256: + 64-hex)
32 _CID_A = "sha256:" + "a" * 64
33 _CID_B = "sha256:" + "b" * 64
34 _CID_C = "sha256:" + "c" * 64
35 _CID_D = "sha256:" + "d" * 64
36 _COMMIT_X = "sha256:" + "1" * 64
37 _COMMIT_Y = "sha256:" + "2" * 64
38 _COMMIT_Z = "sha256:" + "3" * 64
39
40
41 # ===========================================================================
42 # SL_11 — NULL_CONTENT_ID and is_null_content_id
43 # ===========================================================================
44
45
46 class TestNullContentId:
47 def test_null_content_id_format(self) -> None:
48 """NULL_CONTENT_ID is sha256: followed by 64 zero hex chars."""
49 assert NULL_CONTENT_ID == "sha256:" + "0" * 64
50
51 def test_is_null_content_id_true(self) -> None:
52 assert is_null_content_id(NULL_CONTENT_ID) is True
53
54 def test_is_null_content_id_false_for_real_id(self) -> None:
55 assert is_null_content_id(_CID_A) is False
56
57 def test_is_null_content_id_false_for_bare_zeros(self) -> None:
58 """Bare 64-char zeros without prefix are NOT the null ID."""
59 assert is_null_content_id("0" * 64) is False
60
61
62 # ===========================================================================
63 # SL_01 — SymlogEntry dataclass
64 # ===========================================================================
65
66
67 class TestSymlogEntry:
68 def _make_entry(
69 self,
70 old_content_id: str = _CID_A,
71 new_content_id: str = _CID_B,
72 commit_id: str = _COMMIT_X,
73 author: str = "claude-code",
74 operation: str = "symbol-modified: add rounding",
75 ts: datetime.datetime | None = None,
76 ) -> SymlogEntry:
77 return SymlogEntry(
78 old_content_id=old_content_id,
79 new_content_id=new_content_id,
80 commit_id=commit_id,
81 author=author,
82 timestamp=ts or datetime.datetime.now(tz=datetime.timezone.utc),
83 operation=operation,
84 )
85
86 def test_has_seven_fields(self) -> None:
87 """SymlogEntry exposes the seven required fields."""
88 e = self._make_entry()
89 assert hasattr(e, "old_content_id")
90 assert hasattr(e, "new_content_id")
91 assert hasattr(e, "commit_id")
92 assert hasattr(e, "author")
93 assert hasattr(e, "timestamp")
94 assert hasattr(e, "operation")
95 assert hasattr(e, "born_from")
96
97 def test_frozen(self) -> None:
98 e = self._make_entry()
99 with pytest.raises((AttributeError, TypeError)):
100 e.author = "other" # type: ignore[misc]
101
102 def test_born_from_none_for_regular_operation(self) -> None:
103 e = self._make_entry(operation="symbol-modified: fix rounding")
104 assert e.born_from is None
105
106 def test_born_from_parsed_for_born_from_operation(self) -> None:
107 """born_from is parsed from the operation string."""
108 e = self._make_entry(operation="symbol-born-from: src/billing.py::compute_total")
109 assert e.born_from == "src/billing.py::compute_total"
110
111 def test_born_from_none_for_symbol_created(self) -> None:
112 e = self._make_entry(
113 old_content_id=NULL_CONTENT_ID,
114 operation="symbol-created: compute_total",
115 )
116 assert e.born_from is None
117
118 def test_born_from_none_for_symbol_renamed_to(self) -> None:
119 """symbol-renamed-to is a terminal entry on the OLD path — born_from stays None."""
120 e = self._make_entry(
121 new_content_id=NULL_CONTENT_ID,
122 operation="symbol-renamed-to: src/billing.py::compute_invoice_total",
123 )
124 assert e.born_from is None
125
126 def test_born_from_extracts_full_address(self) -> None:
127 addr = "src/deep/module.py::SomeClass.method_name"
128 e = self._make_entry(operation=f"symbol-born-from: {addr}")
129 assert e.born_from == addr
130
131
132 # ===========================================================================
133 # SL_04 — Path encoding
134 # ===========================================================================
135
136
137 class TestPathEncoding:
138 def test_simple_symbol_maps_correctly(self, tmp_path: pathlib.Path) -> None:
139 """src/billing.py::compute_total → .muse/symlogs/src/billing.py/compute_total"""
140 p = symlog_path(tmp_path, "src/billing.py::compute_total")
141 assert p == tmp_path / ".muse" / "symlogs" / "src" / "billing.py" / "compute_total"
142
143 def test_nested_path_maps_correctly(self, tmp_path: pathlib.Path) -> None:
144 p = symlog_path(tmp_path, "tests/test_billing.py::test_compute_total")
145 assert p == tmp_path / ".muse" / "symlogs" / "tests" / "test_billing.py" / "test_compute_total"
146
147 def test_special_chars_in_symbol_name_are_percent_encoded(self, tmp_path: pathlib.Path) -> None:
148 """Characters outside [a-zA-Z0-9._-] in the symbol name are percent-encoded."""
149 p = symlog_path(tmp_path, "src/billing.py::compute total")
150 leaf = p.name
151 assert " " not in leaf
152 assert "%" in leaf # percent-encoded
153
154 def test_colon_in_symbol_name_is_percent_encoded(self, tmp_path: pathlib.Path) -> None:
155 """The :: separator splits file path from symbol name; colons IN the symbol name are encoded."""
156 p = symlog_path(tmp_path, "src/billing.py::SomeClass:method")
157 leaf = p.name
158 assert ":" not in leaf
159
160 def test_dotdot_in_file_path_raises(self, tmp_path: pathlib.Path) -> None:
161 with pytest.raises(ValueError, match=r"\.\.|traversal"):
162 symlog_path(tmp_path, "../escape.py::bad")
163
164 def test_dotdot_in_symbol_name_raises(self, tmp_path: pathlib.Path) -> None:
165 with pytest.raises(ValueError, match=r"\.\.|traversal"):
166 symlog_path(tmp_path, "src/billing.py::../../../etc/passwd")
167
168 def test_missing_double_colon_raises(self, tmp_path: pathlib.Path) -> None:
169 with pytest.raises(ValueError, match=r"::|separator"):
170 symlog_path(tmp_path, "src/billing.py")
171
172 def test_empty_symbol_name_raises(self, tmp_path: pathlib.Path) -> None:
173 with pytest.raises(ValueError, match=r"empty|symbol"):
174 symlog_path(tmp_path, "src/billing.py::")
175
176 def test_plain_symbol_name_unchanged(self, tmp_path: pathlib.Path) -> None:
177 """Names with only [a-zA-Z0-9._-] chars pass through unchanged."""
178 p = symlog_path(tmp_path, "src/m.py::compute_total-v2.helper")
179 assert p.name == "compute_total-v2.helper"
180
181
182 # ===========================================================================
183 # SL_02 + SL_10 — append_symlog and sanitization
184 # ===========================================================================
185
186
187 class TestAppendSymlog:
188 def test_creates_parent_dirs_and_file(self, tmp_path: pathlib.Path) -> None:
189 append_symlog(
190 tmp_path,
191 "src/billing.py::compute_total",
192 old_content_id=NULL_CONTENT_ID,
193 new_content_id=_CID_A,
194 commit_id=_COMMIT_X,
195 author="claude-code",
196 operation="symbol-created: compute_total",
197 )
198 p = symlog_path(tmp_path, "src/billing.py::compute_total")
199 assert p.exists()
200 assert p.is_file()
201
202 def test_one_line_written_per_call(self, tmp_path: pathlib.Path) -> None:
203 addr = "src/billing.py::compute_total"
204 for i in range(3):
205 append_symlog(
206 tmp_path, addr,
207 old_content_id=_CID_A,
208 new_content_id=_CID_B,
209 commit_id=_COMMIT_X,
210 author="claude-code",
211 operation=f"symbol-modified: change {i}",
212 )
213 p = symlog_path(tmp_path, addr)
214 lines = [l for l in p.read_text().splitlines() if l.strip()]
215 assert len(lines) == 3
216
217 def test_line_format(self, tmp_path: pathlib.Path) -> None:
218 """Each line is tab-delimited between metadata and operation."""
219 addr = "src/billing.py::compute_total"
220 append_symlog(
221 tmp_path, addr,
222 old_content_id=_CID_A,
223 new_content_id=_CID_B,
224 commit_id=_COMMIT_X,
225 author="claude-code",
226 operation="symbol-modified: add rounding",
227 )
228 p = symlog_path(tmp_path, addr)
229 line = p.read_text().strip()
230 assert "\t" in line
231 meta, op = line.split("\t", 1)
232 tokens = meta.split()
233 # old_content_id new_content_id commit_id author ts tz
234 assert tokens[0] == _CID_A
235 assert tokens[1] == _CID_B
236 assert tokens[2] == _COMMIT_X
237 assert op == "symbol-modified: add rounding"
238
239 def test_author_newline_stripped(self, tmp_path: pathlib.Path) -> None:
240 """SL_10: newlines in author are stripped before write."""
241 addr = "src/billing.py::compute_total"
242 append_symlog(
243 tmp_path, addr,
244 old_content_id=_CID_A, new_content_id=_CID_B,
245 commit_id=_COMMIT_X,
246 author="bad\nauthor",
247 operation="symbol-modified: x",
248 )
249 entries = read_symlog(tmp_path, addr)
250 assert "\n" not in entries[0].author
251
252 def test_author_tab_stripped(self, tmp_path: pathlib.Path) -> None:
253 addr = "src/billing.py::compute_total"
254 append_symlog(
255 tmp_path, addr,
256 old_content_id=_CID_A, new_content_id=_CID_B,
257 commit_id=_COMMIT_X,
258 author="bad\tauthor",
259 operation="symbol-modified: x",
260 )
261 entries = read_symlog(tmp_path, addr)
262 assert "\t" not in entries[0].author
263
264 def test_operation_newline_stripped(self, tmp_path: pathlib.Path) -> None:
265 """SL_10: newlines in operation are stripped to prevent line injection."""
266 addr = "src/billing.py::compute_total"
267 append_symlog(
268 tmp_path, addr,
269 old_content_id=_CID_A, new_content_id=_CID_B,
270 commit_id=_COMMIT_X,
271 author="claude-code",
272 operation="symbol-modified: injected\nfake entry",
273 )
274 entries = read_symlog(tmp_path, addr)
275 assert "\n" not in entries[0].operation
276
277 def test_different_symbols_same_file_get_separate_files(self, tmp_path: pathlib.Path) -> None:
278 for name in ("compute_total", "validate_invoice"):
279 append_symlog(
280 tmp_path, f"src/billing.py::{name}",
281 old_content_id=NULL_CONTENT_ID, new_content_id=_CID_A,
282 commit_id=_COMMIT_X, author="claude-code",
283 operation=f"symbol-created: {name}",
284 )
285 p1 = symlog_path(tmp_path, "src/billing.py::compute_total")
286 p2 = symlog_path(tmp_path, "src/billing.py::validate_invoice")
287 assert p1.exists()
288 assert p2.exists()
289 assert p1 != p2
290
291
292 # ===========================================================================
293 # SL_03 + SL_09 — read_symlog (newest-first, limit, follow, size cap)
294 # ===========================================================================
295
296
297 class TestReadSymlog:
298 def _write_n_entries(
299 self,
300 tmp_path: pathlib.Path,
301 addr: str,
302 n: int,
303 *,
304 old_cid: str = _CID_A,
305 new_cid: str = _CID_B,
306 ) -> None:
307 for i in range(n):
308 append_symlog(
309 tmp_path, addr,
310 old_content_id=old_cid, new_content_id=new_cid,
311 commit_id=_COMMIT_X, author="claude-code",
312 operation=f"symbol-modified: change {i}",
313 )
314
315 def test_empty_log_returns_empty_list(self, tmp_path: pathlib.Path) -> None:
316 entries = read_symlog(tmp_path, "src/billing.py::compute_total")
317 assert entries == []
318
319 def test_entries_returned_newest_first(self, tmp_path: pathlib.Path) -> None:
320 addr = "src/billing.py::compute_total"
321 append_symlog(tmp_path, addr, _CID_A, _CID_B, _COMMIT_X, "claude-code", "symbol-modified: first")
322 time.sleep(0.01)
323 append_symlog(tmp_path, addr, _CID_B, _CID_C, _COMMIT_Y, "claude-code", "symbol-modified: second")
324 entries = read_symlog(tmp_path, addr)
325 assert entries[0].operation == "symbol-modified: second"
326 assert entries[1].operation == "symbol-modified: first"
327
328 def test_limit_respected(self, tmp_path: pathlib.Path) -> None:
329 addr = "src/billing.py::compute_total"
330 self._write_n_entries(tmp_path, addr, 10)
331 entries = read_symlog(tmp_path, addr, limit=3)
332 assert len(entries) == 3
333
334 def test_all_fields_round_trip(self, tmp_path: pathlib.Path) -> None:
335 addr = "src/billing.py::compute_total"
336 append_symlog(
337 tmp_path, addr,
338 old_content_id=_CID_A, new_content_id=_CID_B,
339 commit_id=_COMMIT_X, author="claude-code",
340 operation="symbol-modified: round-trip test",
341 )
342 entries = read_symlog(tmp_path, addr)
343 assert len(entries) == 1
344 e = entries[0]
345 assert e.old_content_id == _CID_A
346 assert e.new_content_id == _CID_B
347 assert e.commit_id == _COMMIT_X
348 assert e.author == "claude-code"
349 assert e.operation == "symbol-modified: round-trip test"
350 assert isinstance(e.timestamp, datetime.datetime)
351 assert e.timestamp.tzinfo is not None
352
353 def test_born_from_parsed_on_read(self, tmp_path: pathlib.Path) -> None:
354 addr = "src/billing.py::compute_invoice_total"
355 old_addr = "src/billing.py::compute_total"
356 append_symlog(
357 tmp_path, addr,
358 old_content_id=NULL_CONTENT_ID, new_content_id=_CID_B,
359 commit_id=_COMMIT_X, author="claude-code",
360 operation=f"symbol-born-from: {old_addr}",
361 )
362 entries = read_symlog(tmp_path, addr)
363 assert entries[0].born_from == old_addr
364
365 def test_follow_traverses_rename_chain(self, tmp_path: pathlib.Path) -> None:
366 """SL_03: follow=True reads prior symbol's log when oldest entry is born-from."""
367 old_addr = "src/billing.py::compute_total"
368 new_addr = "src/billing.py::compute_invoice_total"
369
370 # Write two entries in old symbol's log
371 append_symlog(tmp_path, old_addr, NULL_CONTENT_ID, _CID_A, _COMMIT_X, "claude-code", "symbol-created: compute_total")
372 append_symlog(tmp_path, old_addr, _CID_A, _CID_B, _COMMIT_Y, "claude-code", "symbol-modified: improve")
373 # Terminal rename entry on old path
374 append_symlog(tmp_path, old_addr, _CID_B, NULL_CONTENT_ID, _COMMIT_Z, "claude-code", f"symbol-renamed-to: {new_addr}")
375 # Born-from entry on new path
376 append_symlog(tmp_path, new_addr, NULL_CONTENT_ID, _CID_C, _COMMIT_Z, "claude-code", f"symbol-born-from: {old_addr}")
377
378 entries = read_symlog(tmp_path, new_addr, follow=True)
379 # Should have new entry + old entries (created, modified, renamed-to)
380 assert len(entries) >= 4
381 # The new_addr entry should be first (newest)
382 assert entries[0].operation.startswith("symbol-born-from:")
383
384 def test_file_size_cap_warns_and_returns(self, tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture) -> None:
385 """SL_09: files over _MAX_SYMLOG_BYTES trigger a warning."""
386 import muse.core.symlog as symlog_mod
387 addr = "src/billing.py::compute_total"
388 p = symlog_path(tmp_path, addr)
389 p.parent.mkdir(parents=True, exist_ok=True)
390
391 original_cap = symlog_mod._MAX_SYMLOG_BYTES
392 try:
393 symlog_mod._MAX_SYMLOG_BYTES = 10 # tiny cap for test
394 p.write_text("x" * 20)
395 import logging
396 with caplog.at_level(logging.WARNING, logger="muse.core.symlog"):
397 result = read_symlog(tmp_path, addr)
398 assert any("MiB" in r.message or "cap" in r.message.lower() or "exceeds" in r.message.lower() for r in caplog.records)
399 finally:
400 symlog_mod._MAX_SYMLOG_BYTES = original_cap
401
402 def test_follow_false_does_not_traverse(self, tmp_path: pathlib.Path) -> None:
403 """follow=False (default) reads only the requested symbol's log."""
404 old_addr = "src/billing.py::compute_total"
405 new_addr = "src/billing.py::compute_invoice_total"
406 append_symlog(tmp_path, old_addr, NULL_CONTENT_ID, _CID_A, _COMMIT_X, "claude-code", "symbol-created: compute_total")
407 append_symlog(tmp_path, new_addr, NULL_CONTENT_ID, _CID_B, _COMMIT_Y, "claude-code", f"symbol-born-from: {old_addr}")
408 entries = read_symlog(tmp_path, new_addr, follow=False)
409 assert len(entries) == 1 # only the new_addr entry, no old_addr entries
410
411
412 # ===========================================================================
413 # SL_05 + SL_06 — list_symlog_symbols and list_symlog_symbols_for_file
414 # ===========================================================================
415
416
417 class TestListSymlogSymbols:
418 def _write(self, tmp_path: pathlib.Path, addr: str) -> None:
419 append_symlog(
420 tmp_path, addr,
421 old_content_id=NULL_CONTENT_ID, new_content_id=_CID_A,
422 commit_id=_COMMIT_X, author="claude-code",
423 operation="symbol-created: x",
424 )
425
426 def test_empty_repo_returns_empty(self, tmp_path: pathlib.Path) -> None:
427 assert list_symlog_symbols(tmp_path) == []
428
429 def test_returns_all_symbol_addresses_sorted(self, tmp_path: pathlib.Path) -> None:
430 self._write(tmp_path, "src/billing.py::compute_total")
431 self._write(tmp_path, "src/billing.py::validate_invoice")
432 self._write(tmp_path, "src/auth.py::login")
433 result = list_symlog_symbols(tmp_path)
434 assert result == sorted(result)
435 assert "src/billing.py::compute_total" in result
436 assert "src/billing.py::validate_invoice" in result
437 assert "src/auth.py::login" in result
438
439 def test_skips_symlinks(self, tmp_path: pathlib.Path) -> None:
440 self._write(tmp_path, "src/billing.py::compute_total")
441 real_p = symlog_path(tmp_path, "src/billing.py::compute_total")
442 link_p = real_p.parent / "symlink_fn"
443 link_p.symlink_to(real_p)
444 result = list_symlog_symbols(tmp_path)
445 # Only the real file — symlink excluded
446 decoded = [r.split("::")[-1] for r in result]
447 assert "symlink_fn" not in decoded
448
449 def test_list_for_file_returns_only_that_files_symbols(self, tmp_path: pathlib.Path) -> None:
450 self._write(tmp_path, "src/billing.py::compute_total")
451 self._write(tmp_path, "src/billing.py::validate_invoice")
452 self._write(tmp_path, "src/auth.py::login")
453 result = list_symlog_symbols_for_file(tmp_path, "src/billing.py")
454 assert set(result) == {
455 "src/billing.py::compute_total",
456 "src/billing.py::validate_invoice",
457 }
458 assert "src/auth.py::login" not in result
459
460 def test_list_for_file_empty_dir_returns_empty(self, tmp_path: pathlib.Path) -> None:
461 result = list_symlog_symbols_for_file(tmp_path, "src/billing.py")
462 assert result == []
463
464 def test_list_for_file_skips_symlinks(self, tmp_path: pathlib.Path) -> None:
465 self._write(tmp_path, "src/billing.py::compute_total")
466 real_p = symlog_path(tmp_path, "src/billing.py::compute_total")
467 link_p = real_p.parent / "symlink_fn"
468 link_p.symlink_to(real_p)
469 result = list_symlog_symbols_for_file(tmp_path, "src/billing.py")
470 decoded = [r.split("::")[-1] for r in result]
471 assert "symlink_fn" not in decoded
472
473
474 # ===========================================================================
475 # SL_07 — expire_symlog
476 # ===========================================================================
477
478
479 class TestExpireSymlog:
480 def _write_old(self, tmp_path: pathlib.Path, addr: str, age_days: float = 100.0) -> None:
481 """Write an entry with a timestamp in the past."""
482 p = symlog_path(tmp_path, addr)
483 p.parent.mkdir(parents=True, exist_ok=True)
484 old_ts = int(time.time()) - int(age_days * 86400)
485 line = f"{_CID_A} {_CID_B} {_COMMIT_X} claude-code {old_ts} +0000\tsymbol-modified: old\n"
486 with p.open("a") as fh:
487 fh.write(line)
488
489 def _write_recent(self, tmp_path: pathlib.Path, addr: str) -> None:
490 append_symlog(
491 tmp_path, addr,
492 old_content_id=_CID_A, new_content_id=_CID_C,
493 commit_id=_COMMIT_Y, author="claude-code",
494 operation="symbol-modified: recent",
495 )
496
497 def test_no_log_file_returns_zero_zero(self, tmp_path: pathlib.Path) -> None:
498 expired, kept = expire_symlog(tmp_path, "src/billing.py::compute_total", expire_days=90)
499 assert (expired, kept) == (0, 0)
500
501 def test_old_entries_expired(self, tmp_path: pathlib.Path) -> None:
502 addr = "src/billing.py::compute_total"
503 self._write_old(tmp_path, addr, age_days=100)
504 self._write_recent(tmp_path, addr)
505 expired, kept = expire_symlog(tmp_path, addr, expire_days=90)
506 assert expired == 1
507 assert kept == 1
508
509 def test_all_expired_file_deleted(self, tmp_path: pathlib.Path) -> None:
510 addr = "src/billing.py::compute_total"
511 self._write_old(tmp_path, addr, age_days=200)
512 expire_symlog(tmp_path, addr, expire_days=90)
513 assert not symlog_path(tmp_path, addr).exists()
514
515 def test_dry_run_writes_nothing(self, tmp_path: pathlib.Path) -> None:
516 addr = "src/billing.py::compute_total"
517 self._write_old(tmp_path, addr, age_days=200)
518 expired, kept = expire_symlog(tmp_path, addr, expire_days=90, dry_run=True)
519 assert expired == 1
520 assert symlog_path(tmp_path, addr).exists() # still there
521
522 def test_atomic_write(self, tmp_path: pathlib.Path) -> None:
523 """After expire the .lock temp file must not exist."""
524 addr = "src/billing.py::compute_total"
525 self._write_old(tmp_path, addr, age_days=200)
526 self._write_recent(tmp_path, addr)
527 expire_symlog(tmp_path, addr, expire_days=90)
528 lock = symlog_path(tmp_path, addr).with_suffix(".lock")
529 assert not lock.exists()
530
531 def test_recent_entries_kept(self, tmp_path: pathlib.Path) -> None:
532 addr = "src/billing.py::compute_total"
533 self._write_recent(tmp_path, addr)
534 expired, kept = expire_symlog(tmp_path, addr, expire_days=90)
535 assert expired == 0
536 assert kept == 1
537 assert symlog_path(tmp_path, addr).exists()
538
539
540 # ===========================================================================
541 # SL_08 — delete_symlog_entry
542 # ===========================================================================
543
544
545 class TestDeleteSymlogEntry:
546 def _write_entries(self, tmp_path: pathlib.Path, addr: str, n: int) -> None:
547 for i in range(n):
548 append_symlog(
549 tmp_path, addr,
550 old_content_id=_CID_A, new_content_id=_CID_B,
551 commit_id=_COMMIT_X, author="claude-code",
552 operation=f"symbol-modified: entry {i}",
553 )
554
555 def test_delete_all_removes_file(self, tmp_path: pathlib.Path) -> None:
556 addr = "src/billing.py::compute_total"
557 self._write_entries(tmp_path, addr, 3)
558 deleted, remaining = delete_symlog_entry(tmp_path, addr, index=None)
559 assert deleted == 3
560 assert remaining == 0
561 assert not symlog_path(tmp_path, addr).exists()
562
563 def test_delete_index_zero_removes_newest(self, tmp_path: pathlib.Path) -> None:
564 addr = "src/billing.py::compute_total"
565 self._write_entries(tmp_path, addr, 3)
566 deleted, remaining = delete_symlog_entry(tmp_path, addr, index=0)
567 assert deleted == 1
568 assert remaining == 2
569 entries = read_symlog(tmp_path, addr)
570 assert len(entries) == 2
571 # @{0} was entry 2 (newest), so now entries 0 and 1 remain
572 assert all("entry 2" not in e.operation for e in entries)
573
574 def test_delete_middle_entry(self, tmp_path: pathlib.Path) -> None:
575 addr = "src/billing.py::compute_total"
576 self._write_entries(tmp_path, addr, 3)
577 delete_symlog_entry(tmp_path, addr, index=1)
578 entries = read_symlog(tmp_path, addr)
579 assert len(entries) == 2
580 ops = [e.operation for e in entries]
581 # Middle entry (index 1, which was "entry 1" in the file) is gone
582 assert not any("entry 1" in op for op in ops)
583
584 def test_out_of_bounds_raises_index_error(self, tmp_path: pathlib.Path) -> None:
585 addr = "src/billing.py::compute_total"
586 self._write_entries(tmp_path, addr, 3)
587 with pytest.raises(IndexError):
588 delete_symlog_entry(tmp_path, addr, index=99)
589
590 def test_missing_log_all_is_graceful(self, tmp_path: pathlib.Path) -> None:
591 deleted, remaining = delete_symlog_entry(tmp_path, "src/billing.py::compute_total", index=None)
592 assert deleted == 0
593 assert remaining == 0
594
595 def test_missing_log_by_index_raises(self, tmp_path: pathlib.Path) -> None:
596 with pytest.raises(FileNotFoundError):
597 delete_symlog_entry(tmp_path, "src/billing.py::compute_total", index=0)
598
599 def test_atomic_write_no_lock_file_left(self, tmp_path: pathlib.Path) -> None:
600 addr = "src/billing.py::compute_total"
601 self._write_entries(tmp_path, addr, 3)
602 delete_symlog_entry(tmp_path, addr, index=0)
603 lock = symlog_path(tmp_path, addr).with_suffix(".lock")
604 assert not lock.exists()
605
606 def test_delete_last_entry_removes_file(self, tmp_path: pathlib.Path) -> None:
607 addr = "src/billing.py::compute_total"
608 self._write_entries(tmp_path, addr, 1)
609 deleted, remaining = delete_symlog_entry(tmp_path, addr, index=0)
610 assert deleted == 1
611 assert remaining == 0
612 assert not symlog_path(tmp_path, addr).exists()
613
614
615 # ===========================================================================
616 # SL_12 — Integration test
617 # ===========================================================================
618
619
620 class TestIntegration:
621 def test_five_entries_round_trip_newest_first(self, tmp_path: pathlib.Path) -> None:
622 """SL_12: Write 5 entries, read back newest-first, verify all fields round-trip."""
623 addr = "src/billing.py::compute_total"
624 ops = [f"symbol-modified: change {i}" for i in range(5)]
625 for op in ops:
626 append_symlog(
627 tmp_path, addr,
628 old_content_id=_CID_A,
629 new_content_id=_CID_B,
630 commit_id=_COMMIT_X,
631 author="claude-code",
632 operation=op,
633 )
634 entries = read_symlog(tmp_path, addr, limit=100)
635 assert len(entries) == 5
636 # Newest first — last written operation is at index 0
637 assert entries[0].operation == "symbol-modified: change 4"
638 assert entries[4].operation == "symbol-modified: change 0"
639 for e in entries:
640 assert e.old_content_id == _CID_A
641 assert e.new_content_id == _CID_B
642 assert e.commit_id == _COMMIT_X
643 assert e.author == "claude-code"
644 assert e.born_from is None
645
646 def test_two_symbols_same_file_independent(self, tmp_path: pathlib.Path) -> None:
647 """SL_12: Two symbols in same file; list_symlog_symbols_for_file returns both."""
648 addr1 = "src/billing.py::compute_total"
649 addr2 = "src/billing.py::validate_invoice"
650 append_symlog(tmp_path, addr1, NULL_CONTENT_ID, _CID_A, _COMMIT_X, "claude-code", "symbol-created: compute_total")
651 append_symlog(tmp_path, addr2, NULL_CONTENT_ID, _CID_B, _COMMIT_Y, "claude-code", "symbol-created: validate_invoice")
652
653 syms = list_symlog_symbols_for_file(tmp_path, "src/billing.py")
654 assert addr1 in syms
655 assert addr2 in syms
656 assert len(syms) == 2
657
658 e1 = read_symlog(tmp_path, addr1)
659 e2 = read_symlog(tmp_path, addr2)
660 assert len(e1) == 1
661 assert len(e2) == 1
662 assert e1[0].new_content_id == _CID_A
663 assert e2[0].new_content_id == _CID_B
664
665 def test_created_deleted_sentinel_fields(self, tmp_path: pathlib.Path) -> None:
666 """Sentinel entries use NULL_CONTENT_ID correctly and is_null_content_id detects them."""
667 addr = "src/billing.py::compute_total"
668 # Created
669 append_symlog(tmp_path, addr, NULL_CONTENT_ID, _CID_A, _COMMIT_X, "claude-code", "symbol-created: compute_total")
670 # Modified
671 append_symlog(tmp_path, addr, _CID_A, _CID_B, _COMMIT_Y, "claude-code", "symbol-modified: improve")
672 # Deleted
673 append_symlog(tmp_path, addr, _CID_B, NULL_CONTENT_ID, _COMMIT_Z, "claude-code", "symbol-deleted: compute_total")
674
675 entries = read_symlog(tmp_path, addr, limit=100)
676 assert len(entries) == 3
677 deleted_entry = entries[0] # newest first
678 created_entry = entries[2]
679 assert is_null_content_id(deleted_entry.new_content_id)
680 assert is_null_content_id(created_entry.old_content_id)
681 assert not is_null_content_id(entries[1].old_content_id)
682 assert not is_null_content_id(entries[1].new_content_id)
File History 1 commit
sha256:f9828efc523c2f8ccaee12ae76086a09a9a6a10d6dd20e53f0b13793f0fdcf50 docs: add symlog (#53) and reflog (#54) follow-up issue files Sonnet 4.6 18 days ago