gabriel / muse public

test_core_symlog.py file-level

at sha256:9 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:c feat: muse domain-info text output articulates every DomainSchema field… · gabriel · Sep 23, 2026
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 # Regression: muse#61 — long/multi-byte symbol names must not exceed the
184 # OS filename limit (255 bytes). Previously this raised
185 # OSError(ENAMETOOLONG) from append_symlog's open() call, silently caught
186 # and downgraded to a warning by the caller (muse/cli/commands/commit.py) —
187 # the commit succeeded but the journal entry for that symbol was
188 # permanently missing, with no test ever exercising the boundary.
189 # ===========================================================================
190
191
192 class TestLongSymbolNameFilenameLimit:
193 #: A markdown-heading-shaped symbol name long enough that percent-encoding
194 #: (each non-safe byte -> 3 chars, "%XX") comfortably exceeds 255 bytes —
195 #: this is the exact shape that kept recurring in real issue-doc commits.
196 _LONG_NAME = (
197 "Empty-directory sentinel: one root design flaw, four discovered "
198 "symptoms — comprehensive fix roadmap.Problem statement — the one "
199 "root flaw behind all four.Blast radius — every place trusting the "
200 "ambiguous discriminator"
201 )
202
203 def test_long_name_produces_filename_within_os_limit(self, tmp_path: pathlib.Path) -> None:
204 p = symlog_path(tmp_path, f"docs/issue.md::{self._LONG_NAME}")
205 assert len(p.name.encode("utf-8")) <= 255, (
206 f"leaf filename is {len(p.name.encode('utf-8'))} bytes — exceeds "
207 "the POSIX 255-byte-per-component limit"
208 )
209
210 def test_long_name_does_not_raise_on_append(self, tmp_path: pathlib.Path) -> None:
211 """The exact failure mode from production: append_symlog must not
212 raise OSError for a long name — it silently corrupted history before."""
213 append_symlog(
214 tmp_path, f"docs/issue.md::{self._LONG_NAME}",
215 old_content_id=NULL_CONTENT_ID, new_content_id=_CID_A,
216 commit_id=_COMMIT_X, author="claude-code",
217 operation="symbol-created: long heading",
218 ) # must not raise
219
220 def test_long_name_round_trips_via_list_symlog_symbols(self, tmp_path: pathlib.Path) -> None:
221 addr = f"docs/issue.md::{self._LONG_NAME}"
222 append_symlog(
223 tmp_path, addr,
224 old_content_id=NULL_CONTENT_ID, new_content_id=_CID_A,
225 commit_id=_COMMIT_X, author="claude-code",
226 operation="symbol-created: long heading",
227 )
228 assert addr in list_symlog_symbols(tmp_path)
229
230 def test_long_name_round_trips_via_list_symlog_symbols_for_file(
231 self, tmp_path: pathlib.Path
232 ) -> None:
233 addr = f"docs/issue.md::{self._LONG_NAME}"
234 append_symlog(
235 tmp_path, addr,
236 old_content_id=NULL_CONTENT_ID, new_content_id=_CID_A,
237 commit_id=_COMMIT_X, author="claude-code",
238 operation="symbol-created: long heading",
239 )
240 assert addr in list_symlog_symbols_for_file(tmp_path, "docs/issue.md")
241
242 def test_long_name_history_readable_via_read_symlog(self, tmp_path: pathlib.Path) -> None:
243 """The actual end-user-visible symptom: muse code symbol-log must
244 return real history for a long-name symbol, not silently nothing."""
245 addr = f"docs/issue.md::{self._LONG_NAME}"
246 append_symlog(
247 tmp_path, addr,
248 old_content_id=NULL_CONTENT_ID, new_content_id=_CID_A,
249 commit_id=_COMMIT_X, author="claude-code",
250 operation="symbol-created: long heading",
251 )
252 entries = read_symlog(tmp_path, addr, limit=10)
253 assert len(entries) == 1
254 assert entries[0].new_content_id == _CID_A
255
256 def test_two_different_long_names_do_not_collide(self, tmp_path: pathlib.Path) -> None:
257 addr_a = f"docs/issue.md::{self._LONG_NAME} A"
258 addr_b = f"docs/issue.md::{self._LONG_NAME} B"
259 append_symlog(
260 tmp_path, addr_a,
261 old_content_id=NULL_CONTENT_ID, new_content_id=_CID_A,
262 commit_id=_COMMIT_X, author="claude-code",
263 operation="symbol-created: A",
264 )
265 append_symlog(
266 tmp_path, addr_b,
267 old_content_id=NULL_CONTENT_ID, new_content_id=_CID_B,
268 commit_id=_COMMIT_X, author="claude-code",
269 operation="symbol-created: B",
270 )
271 result = list_symlog_symbols_for_file(tmp_path, "docs/issue.md")
272 assert addr_a in result
273 assert addr_b in result
274 assert len(result) == 2
275
276 def test_boundary_at_255_bytes_encoded(self, tmp_path: pathlib.Path) -> None:
277 """A symbol name whose plain-ASCII percent-encoded form lands exactly
278 at 255 bytes must still produce a valid, round-trippable filename."""
279 # Plain ASCII safe chars pass through 1:1, so an N-char safe-ASCII
280 # name encodes to exactly N bytes.
281 name_255 = "x" * 255
282 addr = f"docs/issue.md::{name_255}"
283 append_symlog(
284 tmp_path, addr,
285 old_content_id=NULL_CONTENT_ID, new_content_id=_CID_A,
286 commit_id=_COMMIT_X, author="claude-code",
287 operation="symbol-created: boundary",
288 )
289 assert addr in list_symlog_symbols_for_file(tmp_path, "docs/issue.md")
290
291 def test_short_name_unaffected_by_long_name_fix(self, tmp_path: pathlib.Path) -> None:
292 """Existing short-name behavior (direct percent-encoding, no hashing,
293 no index file) must be completely unchanged."""
294 p = symlog_path(tmp_path, "src/billing.py::compute_total")
295 assert p.name == "compute_total"
296
297
298 # ===========================================================================
299 # SL_02 + SL_10 — append_symlog and sanitization
300 # ===========================================================================
301
302
303 class TestAppendSymlog:
304 def test_creates_parent_dirs_and_file(self, tmp_path: pathlib.Path) -> None:
305 append_symlog(
306 tmp_path,
307 "src/billing.py::compute_total",
308 old_content_id=NULL_CONTENT_ID,
309 new_content_id=_CID_A,
310 commit_id=_COMMIT_X,
311 author="claude-code",
312 operation="symbol-created: compute_total",
313 )
314 p = symlog_path(tmp_path, "src/billing.py::compute_total")
315 assert p.exists()
316 assert p.is_file()
317
318 def test_one_line_written_per_call(self, tmp_path: pathlib.Path) -> None:
319 addr = "src/billing.py::compute_total"
320 for i in range(3):
321 append_symlog(
322 tmp_path, addr,
323 old_content_id=_CID_A,
324 new_content_id=_CID_B,
325 commit_id=_COMMIT_X,
326 author="claude-code",
327 operation=f"symbol-modified: change {i}",
328 )
329 p = symlog_path(tmp_path, addr)
330 lines = [l for l in p.read_text().splitlines() if l.strip()]
331 assert len(lines) == 3
332
333 def test_line_format(self, tmp_path: pathlib.Path) -> None:
334 """Each line is tab-delimited between metadata and operation."""
335 addr = "src/billing.py::compute_total"
336 append_symlog(
337 tmp_path, addr,
338 old_content_id=_CID_A,
339 new_content_id=_CID_B,
340 commit_id=_COMMIT_X,
341 author="claude-code",
342 operation="symbol-modified: add rounding",
343 )
344 p = symlog_path(tmp_path, addr)
345 line = p.read_text().strip()
346 assert "\t" in line
347 meta, op = line.split("\t", 1)
348 tokens = meta.split()
349 # old_content_id new_content_id commit_id author ts tz
350 assert tokens[0] == _CID_A
351 assert tokens[1] == _CID_B
352 assert tokens[2] == _COMMIT_X
353 assert op == "symbol-modified: add rounding"
354
355 def test_author_newline_stripped(self, tmp_path: pathlib.Path) -> None:
356 """SL_10: newlines in author are stripped before write."""
357 addr = "src/billing.py::compute_total"
358 append_symlog(
359 tmp_path, addr,
360 old_content_id=_CID_A, new_content_id=_CID_B,
361 commit_id=_COMMIT_X,
362 author="bad\nauthor",
363 operation="symbol-modified: x",
364 )
365 entries = read_symlog(tmp_path, addr)
366 assert "\n" not in entries[0].author
367
368 def test_author_tab_stripped(self, tmp_path: pathlib.Path) -> None:
369 addr = "src/billing.py::compute_total"
370 append_symlog(
371 tmp_path, addr,
372 old_content_id=_CID_A, new_content_id=_CID_B,
373 commit_id=_COMMIT_X,
374 author="bad\tauthor",
375 operation="symbol-modified: x",
376 )
377 entries = read_symlog(tmp_path, addr)
378 assert "\t" not in entries[0].author
379
380 def test_operation_newline_stripped(self, tmp_path: pathlib.Path) -> None:
381 """SL_10: newlines in operation are stripped to prevent line injection."""
382 addr = "src/billing.py::compute_total"
383 append_symlog(
384 tmp_path, addr,
385 old_content_id=_CID_A, new_content_id=_CID_B,
386 commit_id=_COMMIT_X,
387 author="claude-code",
388 operation="symbol-modified: injected\nfake entry",
389 )
390 entries = read_symlog(tmp_path, addr)
391 assert "\n" not in entries[0].operation
392
393 def test_different_symbols_same_file_get_separate_files(self, tmp_path: pathlib.Path) -> None:
394 for name in ("compute_total", "validate_invoice"):
395 append_symlog(
396 tmp_path, f"src/billing.py::{name}",
397 old_content_id=NULL_CONTENT_ID, new_content_id=_CID_A,
398 commit_id=_COMMIT_X, author="claude-code",
399 operation=f"symbol-created: {name}",
400 )
401 p1 = symlog_path(tmp_path, "src/billing.py::compute_total")
402 p2 = symlog_path(tmp_path, "src/billing.py::validate_invoice")
403 assert p1.exists()
404 assert p2.exists()
405 assert p1 != p2
406
407
408 # ===========================================================================
409 # SL_03 + SL_09 — read_symlog (newest-first, limit, follow, size cap)
410 # ===========================================================================
411
412
413 class TestReadSymlog:
414 def _write_n_entries(
415 self,
416 tmp_path: pathlib.Path,
417 addr: str,
418 n: int,
419 *,
420 old_cid: str = _CID_A,
421 new_cid: str = _CID_B,
422 ) -> None:
423 for i in range(n):
424 append_symlog(
425 tmp_path, addr,
426 old_content_id=old_cid, new_content_id=new_cid,
427 commit_id=_COMMIT_X, author="claude-code",
428 operation=f"symbol-modified: change {i}",
429 )
430
431 def test_empty_log_returns_empty_list(self, tmp_path: pathlib.Path) -> None:
432 entries = read_symlog(tmp_path, "src/billing.py::compute_total")
433 assert entries == []
434
435 def test_entries_returned_newest_first(self, tmp_path: pathlib.Path) -> None:
436 addr = "src/billing.py::compute_total"
437 append_symlog(tmp_path, addr, _CID_A, _CID_B, _COMMIT_X, "claude-code", "symbol-modified: first")
438 time.sleep(0.01)
439 append_symlog(tmp_path, addr, _CID_B, _CID_C, _COMMIT_Y, "claude-code", "symbol-modified: second")
440 entries = read_symlog(tmp_path, addr)
441 assert entries[0].operation == "symbol-modified: second"
442 assert entries[1].operation == "symbol-modified: first"
443
444 def test_limit_respected(self, tmp_path: pathlib.Path) -> None:
445 addr = "src/billing.py::compute_total"
446 self._write_n_entries(tmp_path, addr, 10)
447 entries = read_symlog(tmp_path, addr, limit=3)
448 assert len(entries) == 3
449
450 def test_all_fields_round_trip(self, tmp_path: pathlib.Path) -> None:
451 addr = "src/billing.py::compute_total"
452 append_symlog(
453 tmp_path, addr,
454 old_content_id=_CID_A, new_content_id=_CID_B,
455 commit_id=_COMMIT_X, author="claude-code",
456 operation="symbol-modified: round-trip test",
457 )
458 entries = read_symlog(tmp_path, addr)
459 assert len(entries) == 1
460 e = entries[0]
461 assert e.old_content_id == _CID_A
462 assert e.new_content_id == _CID_B
463 assert e.commit_id == _COMMIT_X
464 assert e.author == "claude-code"
465 assert e.operation == "symbol-modified: round-trip test"
466 assert isinstance(e.timestamp, datetime.datetime)
467 assert e.timestamp.tzinfo is not None
468
469 def test_born_from_parsed_on_read(self, tmp_path: pathlib.Path) -> None:
470 addr = "src/billing.py::compute_invoice_total"
471 old_addr = "src/billing.py::compute_total"
472 append_symlog(
473 tmp_path, addr,
474 old_content_id=NULL_CONTENT_ID, new_content_id=_CID_B,
475 commit_id=_COMMIT_X, author="claude-code",
476 operation=f"symbol-born-from: {old_addr}",
477 )
478 entries = read_symlog(tmp_path, addr)
479 assert entries[0].born_from == old_addr
480
481 def test_follow_traverses_rename_chain(self, tmp_path: pathlib.Path) -> None:
482 """SL_03: follow=True reads prior symbol's log when oldest entry is born-from."""
483 old_addr = "src/billing.py::compute_total"
484 new_addr = "src/billing.py::compute_invoice_total"
485
486 # Write two entries in old symbol's log
487 append_symlog(tmp_path, old_addr, NULL_CONTENT_ID, _CID_A, _COMMIT_X, "claude-code", "symbol-created: compute_total")
488 append_symlog(tmp_path, old_addr, _CID_A, _CID_B, _COMMIT_Y, "claude-code", "symbol-modified: improve")
489 # Terminal rename entry on old path
490 append_symlog(tmp_path, old_addr, _CID_B, NULL_CONTENT_ID, _COMMIT_Z, "claude-code", f"symbol-renamed-to: {new_addr}")
491 # Born-from entry on new path
492 append_symlog(tmp_path, new_addr, NULL_CONTENT_ID, _CID_C, _COMMIT_Z, "claude-code", f"symbol-born-from: {old_addr}")
493
494 entries = read_symlog(tmp_path, new_addr, follow=True)
495 # Should have new entry + old entries (created, modified, renamed-to)
496 assert len(entries) >= 4
497 # The new_addr entry should be first (newest)
498 assert entries[0].operation.startswith("symbol-born-from:")
499
500 def test_file_size_cap_warns_and_returns(self, tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture) -> None:
501 """SL_09: files over _MAX_SYMLOG_BYTES trigger a warning."""
502 import muse.core.symlog as symlog_mod
503 addr = "src/billing.py::compute_total"
504 p = symlog_path(tmp_path, addr)
505 p.parent.mkdir(parents=True, exist_ok=True)
506
507 original_cap = symlog_mod._MAX_SYMLOG_BYTES
508 try:
509 symlog_mod._MAX_SYMLOG_BYTES = 10 # tiny cap for test
510 p.write_text("x" * 20)
511 import logging
512 with caplog.at_level(logging.WARNING, logger="muse.core.symlog"):
513 result = read_symlog(tmp_path, addr)
514 assert any("MiB" in r.message or "cap" in r.message.lower() or "exceeds" in r.message.lower() for r in caplog.records)
515 finally:
516 symlog_mod._MAX_SYMLOG_BYTES = original_cap
517
518 def test_follow_false_does_not_traverse(self, tmp_path: pathlib.Path) -> None:
519 """follow=False (default) reads only the requested symbol's log."""
520 old_addr = "src/billing.py::compute_total"
521 new_addr = "src/billing.py::compute_invoice_total"
522 append_symlog(tmp_path, old_addr, NULL_CONTENT_ID, _CID_A, _COMMIT_X, "claude-code", "symbol-created: compute_total")
523 append_symlog(tmp_path, new_addr, NULL_CONTENT_ID, _CID_B, _COMMIT_Y, "claude-code", f"symbol-born-from: {old_addr}")
524 entries = read_symlog(tmp_path, new_addr, follow=False)
525 assert len(entries) == 1 # only the new_addr entry, no old_addr entries
526
527
528 # ===========================================================================
529 # SL_05 + SL_06 — list_symlog_symbols and list_symlog_symbols_for_file
530 # ===========================================================================
531
532
533 class TestListSymlogSymbols:
534 def _write(self, tmp_path: pathlib.Path, addr: str) -> None:
535 append_symlog(
536 tmp_path, addr,
537 old_content_id=NULL_CONTENT_ID, new_content_id=_CID_A,
538 commit_id=_COMMIT_X, author="claude-code",
539 operation="symbol-created: x",
540 )
541
542 def test_empty_repo_returns_empty(self, tmp_path: pathlib.Path) -> None:
543 assert list_symlog_symbols(tmp_path) == []
544
545 def test_returns_all_symbol_addresses_sorted(self, tmp_path: pathlib.Path) -> None:
546 self._write(tmp_path, "src/billing.py::compute_total")
547 self._write(tmp_path, "src/billing.py::validate_invoice")
548 self._write(tmp_path, "src/auth.py::login")
549 result = list_symlog_symbols(tmp_path)
550 assert result == sorted(result)
551 assert "src/billing.py::compute_total" in result
552 assert "src/billing.py::validate_invoice" in result
553 assert "src/auth.py::login" in result
554
555 def test_skips_symlinks(self, tmp_path: pathlib.Path) -> None:
556 self._write(tmp_path, "src/billing.py::compute_total")
557 real_p = symlog_path(tmp_path, "src/billing.py::compute_total")
558 link_p = real_p.parent / "symlink_fn"
559 link_p.symlink_to(real_p)
560 result = list_symlog_symbols(tmp_path)
561 # Only the real file — symlink excluded
562 decoded = [r.split("::")[-1] for r in result]
563 assert "symlink_fn" not in decoded
564
565 def test_list_for_file_returns_only_that_files_symbols(self, tmp_path: pathlib.Path) -> None:
566 self._write(tmp_path, "src/billing.py::compute_total")
567 self._write(tmp_path, "src/billing.py::validate_invoice")
568 self._write(tmp_path, "src/auth.py::login")
569 result = list_symlog_symbols_for_file(tmp_path, "src/billing.py")
570 assert set(result) == {
571 "src/billing.py::compute_total",
572 "src/billing.py::validate_invoice",
573 }
574 assert "src/auth.py::login" not in result
575
576 def test_list_for_file_empty_dir_returns_empty(self, tmp_path: pathlib.Path) -> None:
577 result = list_symlog_symbols_for_file(tmp_path, "src/billing.py")
578 assert result == []
579
580 def test_list_for_file_skips_symlinks(self, tmp_path: pathlib.Path) -> None:
581 self._write(tmp_path, "src/billing.py::compute_total")
582 real_p = symlog_path(tmp_path, "src/billing.py::compute_total")
583 link_p = real_p.parent / "symlink_fn"
584 link_p.symlink_to(real_p)
585 result = list_symlog_symbols_for_file(tmp_path, "src/billing.py")
586 decoded = [r.split("::")[-1] for r in result]
587 assert "symlink_fn" not in decoded
588
589
590 # ===========================================================================
591 # SL_07 — expire_symlog
592 # ===========================================================================
593
594
595 class TestExpireSymlog:
596 def _write_old(self, tmp_path: pathlib.Path, addr: str, age_days: float = 100.0) -> None:
597 """Write an entry with a timestamp in the past."""
598 p = symlog_path(tmp_path, addr)
599 p.parent.mkdir(parents=True, exist_ok=True)
600 old_ts = int(time.time()) - int(age_days * 86400)
601 line = f"{_CID_A} {_CID_B} {_COMMIT_X} claude-code {old_ts} +0000\tsymbol-modified: old\n"
602 with p.open("a") as fh:
603 fh.write(line)
604
605 def _write_recent(self, tmp_path: pathlib.Path, addr: str) -> None:
606 append_symlog(
607 tmp_path, addr,
608 old_content_id=_CID_A, new_content_id=_CID_C,
609 commit_id=_COMMIT_Y, author="claude-code",
610 operation="symbol-modified: recent",
611 )
612
613 def test_no_log_file_returns_zero_zero(self, tmp_path: pathlib.Path) -> None:
614 expired, kept = expire_symlog(tmp_path, "src/billing.py::compute_total", expire_days=90)
615 assert (expired, kept) == (0, 0)
616
617 def test_old_entries_expired(self, tmp_path: pathlib.Path) -> None:
618 addr = "src/billing.py::compute_total"
619 self._write_old(tmp_path, addr, age_days=100)
620 self._write_recent(tmp_path, addr)
621 expired, kept = expire_symlog(tmp_path, addr, expire_days=90)
622 assert expired == 1
623 assert kept == 1
624
625 def test_all_expired_file_deleted(self, tmp_path: pathlib.Path) -> None:
626 addr = "src/billing.py::compute_total"
627 self._write_old(tmp_path, addr, age_days=200)
628 expire_symlog(tmp_path, addr, expire_days=90)
629 assert not symlog_path(tmp_path, addr).exists()
630
631 def test_dry_run_writes_nothing(self, tmp_path: pathlib.Path) -> None:
632 addr = "src/billing.py::compute_total"
633 self._write_old(tmp_path, addr, age_days=200)
634 expired, kept = expire_symlog(tmp_path, addr, expire_days=90, dry_run=True)
635 assert expired == 1
636 assert symlog_path(tmp_path, addr).exists() # still there
637
638 def test_atomic_write(self, tmp_path: pathlib.Path) -> None:
639 """After expire the .lock temp file must not exist."""
640 addr = "src/billing.py::compute_total"
641 self._write_old(tmp_path, addr, age_days=200)
642 self._write_recent(tmp_path, addr)
643 expire_symlog(tmp_path, addr, expire_days=90)
644 lock = symlog_path(tmp_path, addr).with_suffix(".lock")
645 assert not lock.exists()
646
647 def test_recent_entries_kept(self, tmp_path: pathlib.Path) -> None:
648 addr = "src/billing.py::compute_total"
649 self._write_recent(tmp_path, addr)
650 expired, kept = expire_symlog(tmp_path, addr, expire_days=90)
651 assert expired == 0
652 assert kept == 1
653 assert symlog_path(tmp_path, addr).exists()
654
655
656 # ===========================================================================
657 # SL_08 — delete_symlog_entry
658 # ===========================================================================
659
660
661 class TestDeleteSymlogEntry:
662 def _write_entries(self, tmp_path: pathlib.Path, addr: str, n: int) -> None:
663 for i in range(n):
664 append_symlog(
665 tmp_path, addr,
666 old_content_id=_CID_A, new_content_id=_CID_B,
667 commit_id=_COMMIT_X, author="claude-code",
668 operation=f"symbol-modified: entry {i}",
669 )
670
671 def test_delete_all_removes_file(self, tmp_path: pathlib.Path) -> None:
672 addr = "src/billing.py::compute_total"
673 self._write_entries(tmp_path, addr, 3)
674 deleted, remaining = delete_symlog_entry(tmp_path, addr, index=None)
675 assert deleted == 3
676 assert remaining == 0
677 assert not symlog_path(tmp_path, addr).exists()
678
679 def test_delete_index_zero_removes_newest(self, tmp_path: pathlib.Path) -> None:
680 addr = "src/billing.py::compute_total"
681 self._write_entries(tmp_path, addr, 3)
682 deleted, remaining = delete_symlog_entry(tmp_path, addr, index=0)
683 assert deleted == 1
684 assert remaining == 2
685 entries = read_symlog(tmp_path, addr)
686 assert len(entries) == 2
687 # @{0} was entry 2 (newest), so now entries 0 and 1 remain
688 assert all("entry 2" not in e.operation for e in entries)
689
690 def test_delete_middle_entry(self, tmp_path: pathlib.Path) -> None:
691 addr = "src/billing.py::compute_total"
692 self._write_entries(tmp_path, addr, 3)
693 delete_symlog_entry(tmp_path, addr, index=1)
694 entries = read_symlog(tmp_path, addr)
695 assert len(entries) == 2
696 ops = [e.operation for e in entries]
697 # Middle entry (index 1, which was "entry 1" in the file) is gone
698 assert not any("entry 1" in op for op in ops)
699
700 def test_out_of_bounds_raises_index_error(self, tmp_path: pathlib.Path) -> None:
701 addr = "src/billing.py::compute_total"
702 self._write_entries(tmp_path, addr, 3)
703 with pytest.raises(IndexError):
704 delete_symlog_entry(tmp_path, addr, index=99)
705
706 def test_missing_log_all_is_graceful(self, tmp_path: pathlib.Path) -> None:
707 deleted, remaining = delete_symlog_entry(tmp_path, "src/billing.py::compute_total", index=None)
708 assert deleted == 0
709 assert remaining == 0
710
711 def test_missing_log_by_index_raises(self, tmp_path: pathlib.Path) -> None:
712 with pytest.raises(FileNotFoundError):
713 delete_symlog_entry(tmp_path, "src/billing.py::compute_total", index=0)
714
715 def test_atomic_write_no_lock_file_left(self, tmp_path: pathlib.Path) -> None:
716 addr = "src/billing.py::compute_total"
717 self._write_entries(tmp_path, addr, 3)
718 delete_symlog_entry(tmp_path, addr, index=0)
719 lock = symlog_path(tmp_path, addr).with_suffix(".lock")
720 assert not lock.exists()
721
722 def test_delete_last_entry_removes_file(self, tmp_path: pathlib.Path) -> None:
723 addr = "src/billing.py::compute_total"
724 self._write_entries(tmp_path, addr, 1)
725 deleted, remaining = delete_symlog_entry(tmp_path, addr, index=0)
726 assert deleted == 1
727 assert remaining == 0
728 assert not symlog_path(tmp_path, addr).exists()
729
730
731 # ===========================================================================
732 # SL_12 — Integration test
733 # ===========================================================================
734
735
736 class TestIntegration:
737 def test_five_entries_round_trip_newest_first(self, tmp_path: pathlib.Path) -> None:
738 """SL_12: Write 5 entries, read back newest-first, verify all fields round-trip."""
739 addr = "src/billing.py::compute_total"
740 ops = [f"symbol-modified: change {i}" for i in range(5)]
741 for op in ops:
742 append_symlog(
743 tmp_path, addr,
744 old_content_id=_CID_A,
745 new_content_id=_CID_B,
746 commit_id=_COMMIT_X,
747 author="claude-code",
748 operation=op,
749 )
750 entries = read_symlog(tmp_path, addr, limit=100)
751 assert len(entries) == 5
752 # Newest first — last written operation is at index 0
753 assert entries[0].operation == "symbol-modified: change 4"
754 assert entries[4].operation == "symbol-modified: change 0"
755 for e in entries:
756 assert e.old_content_id == _CID_A
757 assert e.new_content_id == _CID_B
758 assert e.commit_id == _COMMIT_X
759 assert e.author == "claude-code"
760 assert e.born_from is None
761
762 def test_two_symbols_same_file_independent(self, tmp_path: pathlib.Path) -> None:
763 """SL_12: Two symbols in same file; list_symlog_symbols_for_file returns both."""
764 addr1 = "src/billing.py::compute_total"
765 addr2 = "src/billing.py::validate_invoice"
766 append_symlog(tmp_path, addr1, NULL_CONTENT_ID, _CID_A, _COMMIT_X, "claude-code", "symbol-created: compute_total")
767 append_symlog(tmp_path, addr2, NULL_CONTENT_ID, _CID_B, _COMMIT_Y, "claude-code", "symbol-created: validate_invoice")
768
769 syms = list_symlog_symbols_for_file(tmp_path, "src/billing.py")
770 assert addr1 in syms
771 assert addr2 in syms
772 assert len(syms) == 2
773
774 e1 = read_symlog(tmp_path, addr1)
775 e2 = read_symlog(tmp_path, addr2)
776 assert len(e1) == 1
777 assert len(e2) == 1
778 assert e1[0].new_content_id == _CID_A
779 assert e2[0].new_content_id == _CID_B
780
781 def test_created_deleted_sentinel_fields(self, tmp_path: pathlib.Path) -> None:
782 """Sentinel entries use NULL_CONTENT_ID correctly and is_null_content_id detects them."""
783 addr = "src/billing.py::compute_total"
784 # Created
785 append_symlog(tmp_path, addr, NULL_CONTENT_ID, _CID_A, _COMMIT_X, "claude-code", "symbol-created: compute_total")
786 # Modified
787 append_symlog(tmp_path, addr, _CID_A, _CID_B, _COMMIT_Y, "claude-code", "symbol-modified: improve")
788 # Deleted
789 append_symlog(tmp_path, addr, _CID_B, NULL_CONTENT_ID, _COMMIT_Z, "claude-code", "symbol-deleted: compute_total")
790
791 entries = read_symlog(tmp_path, addr, limit=100)
792 assert len(entries) == 3
793 deleted_entry = entries[0] # newest first
794 created_entry = entries[2]
795 assert is_null_content_id(deleted_entry.new_content_id)
796 assert is_null_content_id(created_entry.old_content_id)
797 assert not is_null_content_id(entries[1].old_content_id)
798 assert not is_null_content_id(entries[1].new_content_id)