gabriel / musehub public
test_snapshot_symbol_indexer.py python
2,537 lines 107.1 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 122 days ago
1 """Tests for the Snapshot & Symbol Indexer — Section 5 of test-coverage-checklist.md.
2
3 Complements test_snapshot_entries.py (14 tests on the snapshot write/read path).
4 This file focuses on the symbol indexer and the gaps not covered there.
5
6 Coverage layers
7 ───────────────
8 Unit — _extract_ops (flat/nested child_ops, missing address, non-dict delta);
9 _op_to_muse_op (all mapping keys, unknown passthrough).
10 Integration — build_symbol_index: empty list when no structured_delta; returns results
11 for repos with structured_delta; correct symbol_history/hash_occurrence
12 content; upsert semantics (only one row per repo/intel_type); BFS
13 excludes orphaned commits.
14 load_symbol_history: empty when no index; with/without file_path filter.
15 load_hash_occurrence: empty when no index; correct content.
16 get_index_meta: None/present states.
17 load_intel_snapshot: None/present states.
18 get_snapshot_manifests_batch: empty list, single, multi-snapshot.
19 Data — upsert_snapshot_entries atomic replace (stale entries removed);
20 build_symbol_index + persist_intel_results upserts on rebuild;
21 BFS reachability excludes orphaned branches.
22 Security — Corrupt JSON blob returns {} not exception;
23 build_symbol_index with unknown head_commit_id returns empty list.
24 Stress — upsert_snapshot_entries with 1 000-file manifest;
25 get_snapshot_manifests_batch with 50 snapshots in one query;
26 build_symbol_index with 100 commits (10 ops each);
27 load_symbol_history file_path filter on large index.
28 Performance — _extract_ops 1 000 calls < 100 ms;
29 build_symbol_index 100 commits < 3 s.
30 E2E — Full pipeline: commits with structured_delta → build_symbol_index →
31 persist_intel_results → get_index_meta returns correct ref;
32 rebuild replaces previous result; symbol list HTTP page returns 200.
33 """
34 from __future__ import annotations
35
36 import json
37 import secrets
38 import time
39 from datetime import datetime, timezone
40
41 import pytest
42 from sqlalchemy import select
43 from sqlalchemy.ext.asyncio import AsyncSession
44
45 from musehub.db import musehub_models as db
46 from tests.factories import create_repo
47 from musehub.types.json_types import JSONObject
48 from muse.core.types import long_id, blob_id
49
50
51 # ─────────────────────────────────────────────────────────────────────────────
52 # Helpers
53 # ─────────────────────────────────────────────────────────────────────────────
54
55 def _now() -> datetime:
56 return datetime.now(tz=timezone.utc)
57
58
59 async def _commit_with_delta(
60 session: AsyncSession,
61 repo_id: str,
62 commit_id: str,
63 ops: list[JSONObject],
64 parent_ids: list[str] | None = None,
65 branch: str = "main",
66 author: str = "gabriel",
67 ) -> db.MusehubCommit:
68 """Insert a commit with a structured_delta."""
69 commit = db.MusehubCommit(
70 commit_id=commit_id,
71 repo_id=repo_id,
72 branch=branch,
73 parent_ids=parent_ids or [],
74 message="feat: test commit",
75 author=author,
76 timestamp=_now(),
77 structured_delta={"ops": ops},
78 )
79 session.add(commit)
80 await session.flush()
81 return commit
82
83
84 def _insert_op(address: str, content_id: str = "sha256:abc") -> JSONObject:
85 return {"address": address, "op": "insert", "content_id": content_id}
86
87
88 def _move_op(address: str, from_address: str, content_id: str = "sha256:abc") -> JSONObject:
89 return {"address": address, "op": "move", "from_address": from_address, "content_id": content_id}
90
91
92 def _patch_op(file_addr: str, children: list[JSONObject]) -> JSONObject:
93 return {"address": file_addr, "op": "patch", "child_ops": children}
94
95
96 async def _build_and_persist(
97 session: AsyncSession,
98 repo_id: str,
99 commit_id: str,
100 ) -> list[tuple[str, dict]]:
101 """Build symbol index and persist results; returns the result list."""
102 from musehub.services.musehub_symbol_indexer import build_symbol_index
103 from musehub.services.musehub_intel_providers import persist_intel_results
104 results = await build_symbol_index(session, repo_id, commit_id)
105 if results:
106 await persist_intel_results(session, repo_id, commit_id, results)
107 return results
108
109
110 def _get_result_data(results: list[tuple[str, JSONObject]], intel_type: str) -> JSONObject:
111 """Extract data dict for a specific intel_type from the results list."""
112 for t, data in results:
113 if t == intel_type:
114 return data
115 return {}
116
117
118 # ─────────────────────────────────────────────────────────────────────────────
119 # Layer 1 — Unit: pure functions
120 # ─────────────────────────────────────────────────────────────────────────────
121
122 class TestExtractOps:
123 """_extract_ops pulls a flat list of ops including child_ops."""
124
125 def _run(self, structured_delta: JSONObject | None) -> list[JSONObject]:
126 from musehub.services.musehub_symbol_indexer import _extract_ops
127 return _extract_ops(structured_delta)
128
129 def test_no_structured_delta_returns_empty(self) -> None:
130 assert self._run(None) == []
131
132 def test_none_delta_returns_empty(self) -> None:
133 assert self._run(None) == []
134
135 def test_non_dict_delta_returns_empty(self) -> None:
136 assert self._run("bad") == [] # type: ignore[arg-type]
137
138 def test_flat_ops_without_child_ops(self) -> None:
139 delta = {
140 "ops": [
141 {"address": "main.py::Foo", "op": "insert"},
142 {"address": "main.py::Bar", "op": "delete"},
143 ]
144 }
145 result = self._run(delta)
146 assert len(result) == 2
147 assert result[0]["address"] == "main.py::Foo"
148 assert result[1]["address"] == "main.py::Bar"
149
150 def test_patch_op_with_child_ops_flattened(self) -> None:
151 delta = {
152 "ops": [
153 {
154 "address": "src/app.py",
155 "op": "patch",
156 "child_ops": [
157 {"address": "src/app.py::MyClass", "op": "insert"},
158 {"address": "src/app.py::MyClass.run", "op": "insert"},
159 ],
160 }
161 ]
162 }
163 result = self._run(delta)
164 # 1 top-level + 2 child_ops
165 assert len(result) == 3
166 addresses = [op["address"] for op in result]
167 assert "src/app.py" in addresses
168 assert "src/app.py::MyClass" in addresses
169 assert "src/app.py::MyClass.run" in addresses
170
171 def test_op_without_address_skipped(self) -> None:
172 delta = {
173 "ops": [
174 {"op": "insert"}, # no address
175 {"address": "ok.py", "op": "insert"},
176 ]
177 }
178 result = self._run(delta)
179 assert len(result) == 1
180 assert result[0]["address"] == "ok.py"
181
182 def test_child_op_without_address_skipped(self) -> None:
183 delta = {
184 "ops": [
185 {
186 "address": "file.py",
187 "op": "patch",
188 "child_ops": [
189 {"op": "insert"}, # no address — must be skipped
190 {"address": "file.py::Good", "op": "insert"},
191 ],
192 }
193 ]
194 }
195 result = self._run(delta)
196 addresses = [op["address"] for op in result]
197 assert "file.py::Good" in addresses
198 for op in result:
199 assert "address" in op
200
201 def test_non_dict_op_skipped(self) -> None:
202 delta = {"ops": ["not-a-dict", {"address": "f.py", "op": "add"}]}
203 result = self._run(delta)
204 assert len(result) == 1
205
206
207 class TestRawOpStorage:
208 """Raw DomainOp types are stored verbatim in op; full payload in op_payload."""
209
210 @pytest.mark.asyncio
211 async def test_insert_op_stored_raw(self, db_session: AsyncSession) -> None:
212 from musehub.services.musehub_symbol_indexer import build_symbol_index
213 from sqlalchemy import select
214
215 repo = await create_repo(db_session, slug="raw-insert")
216 commit = await _commit_with_delta(
217 db_session, repo.repo_id, "raw-c001",
218 ops=[{
219 "address": "main.py::Foo",
220 "op": "insert",
221 "content_id": "sha256:aaa",
222 "content_summary": "added function Foo",
223 "position": 0,
224 }],
225 )
226 await build_symbol_index(db_session, repo.repo_id, commit.commit_id)
227
228 row = (await db_session.execute(
229 select(db.MusehubSymbolHistoryEntry).where(
230 db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
231 db.MusehubSymbolHistoryEntry.address == "main.py::Foo",
232 )
233 )).scalar_one()
234 assert row.op == "insert"
235 assert row.op_payload is not None
236 assert row.op_payload["content_summary"] == "added function Foo"
237 assert row.op_payload["position"] == 0
238 assert "op" not in row.op_payload
239 assert "address" not in row.op_payload
240
241 @pytest.mark.asyncio
242 async def test_replace_op_stored_raw(self, db_session: AsyncSession) -> None:
243 from musehub.services.musehub_symbol_indexer import build_symbol_index
244 from sqlalchemy import select
245
246 repo = await create_repo(db_session, slug="raw-replace")
247 commit = await _commit_with_delta(
248 db_session, repo.repo_id, "raw-c002",
249 ops=[{
250 "address": "main.py::Foo",
251 "op": "replace",
252 "old_content_id": "sha256:old",
253 "new_content_id": "sha256:new",
254 "old_summary": "function Foo v1",
255 "new_summary": "function Foo v2",
256 "position": None,
257 }],
258 )
259 await build_symbol_index(db_session, repo.repo_id, commit.commit_id)
260
261 row = (await db_session.execute(
262 select(db.MusehubSymbolHistoryEntry).where(
263 db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
264 db.MusehubSymbolHistoryEntry.address == "main.py::Foo",
265 )
266 )).scalar_one()
267 assert row.op == "replace"
268 assert row.content_id == "sha256:new"
269 assert row.op_payload["old_content_id"] == "sha256:old"
270 assert row.op_payload["new_content_id"] == "sha256:new"
271 assert row.op_payload["old_summary"] == "function Foo v1"
272 assert row.op_payload["new_summary"] == "function Foo v2"
273
274 @pytest.mark.asyncio
275 async def test_patch_op_stored_raw_with_child_summary(
276 self, db_session: AsyncSession
277 ) -> None:
278 from musehub.services.musehub_symbol_indexer import build_symbol_index
279 from sqlalchemy import select
280
281 repo = await create_repo(db_session, slug="raw-patch")
282 commit = await _commit_with_delta(
283 db_session, repo.repo_id, "raw-c003",
284 ops=[{
285 "address": "src/app.py",
286 "op": "patch",
287 "child_domain": "python",
288 "child_summary": "2 symbols changed",
289 "child_ops": [
290 {"address": "src/app.py::MyClass", "op": "insert", "content_id": "sha256:cls", "content_summary": "added class", "position": 0},
291 ],
292 }],
293 )
294 await build_symbol_index(db_session, repo.repo_id, commit.commit_id)
295
296 rows = (await db_session.execute(
297 select(db.MusehubSymbolHistoryEntry).where(
298 db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
299 )
300 )).scalars().all()
301 by_addr = {r.address: r for r in rows}
302
303 # Parent patch entry
304 patch_row = by_addr["src/app.py"]
305 assert patch_row.op == "patch"
306 assert patch_row.op_payload["child_summary"] == "2 symbols changed"
307 assert patch_row.op_payload["child_domain"] == "python"
308 assert "child_ops" not in patch_row.op_payload # stripped — those are separate rows
309
310 # Child entry
311 child_row = by_addr["src/app.py::MyClass"]
312 assert child_row.op == "insert"
313
314 @pytest.mark.asyncio
315 async def test_mutate_op_stored_raw_with_fields(
316 self, db_session: AsyncSession
317 ) -> None:
318 from musehub.services.musehub_symbol_indexer import build_symbol_index
319 from sqlalchemy import select
320
321 repo = await create_repo(db_session, slug="raw-mutate")
322 commit = await _commit_with_delta(
323 db_session, repo.repo_id, "raw-c004",
324 ops=[{
325 "address": "track.mid::note@bar4",
326 "op": "mutate",
327 "entity_id": "test-note-42",
328 "old_content_id": "sha256:old",
329 "new_content_id": "sha256:new",
330 "fields": {"velocity": {"old": "80", "new": "100"}},
331 "old_summary": "velocity 80",
332 "new_summary": "velocity 100",
333 "position": 3,
334 }],
335 )
336 await build_symbol_index(db_session, repo.repo_id, commit.commit_id)
337
338 row = (await db_session.execute(
339 select(db.MusehubSymbolHistoryEntry).where(
340 db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
341 )
342 )).scalar_one()
343 assert row.op == "mutate"
344 assert row.op_payload["entity_id"] == "test-note-42"
345 assert row.op_payload["fields"] == {"velocity": {"old": "80", "new": "100"}}
346 assert row.op_payload["new_summary"] == "velocity 100"
347
348 @pytest.mark.asyncio
349 async def test_patch_with_from_address_is_rename(
350 self, db_session: AsyncSession
351 ) -> None:
352 """PatchOp with from_address is a file rename+modify; from_address in payload."""
353 from musehub.services.musehub_symbol_indexer import build_symbol_index
354 from sqlalchemy import select
355
356 repo = await create_repo(db_session, slug="raw-rename")
357 commit = await _commit_with_delta(
358 db_session, repo.repo_id, "raw-c005",
359 ops=[{
360 "address": "src/new.py",
361 "op": "patch",
362 "from_address": "src/old.py",
363 "child_domain": "python",
364 "child_summary": "file renamed",
365 "child_ops": [],
366 }],
367 )
368 await build_symbol_index(db_session, repo.repo_id, commit.commit_id)
369
370 row = (await db_session.execute(
371 select(db.MusehubSymbolHistoryEntry).where(
372 db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
373 db.MusehubSymbolHistoryEntry.address == "src/new.py",
374 )
375 )).scalar_one()
376 assert row.op == "patch"
377 assert row.op_payload["from_address"] == "src/old.py"
378
379 @pytest.mark.asyncio
380 async def test_op_payload_excludes_op_and_address_keys(
381 self, db_session: AsyncSession
382 ) -> None:
383 from musehub.services.musehub_symbol_indexer import build_symbol_index
384 from sqlalchemy import select
385
386 repo = await create_repo(db_session, slug="raw-exclude")
387 commit = await _commit_with_delta(
388 db_session, repo.repo_id, "raw-c006",
389 ops=[{
390 "address": "util.py::helper",
391 "op": "insert",
392 "content_id": "sha256:ccc",
393 "content_summary": "added helper",
394 "position": 1,
395 }],
396 )
397 await build_symbol_index(db_session, repo.repo_id, commit.commit_id)
398
399 row = (await db_session.execute(
400 select(db.MusehubSymbolHistoryEntry).where(
401 db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
402 )
403 )).scalar_one()
404 assert "op" not in row.op_payload
405 assert "address" not in row.op_payload
406
407
408 # ─────────────────────────────────────────────────────────────────────────────
409 # Layer 2 — Integration: build_symbol_index + read functions
410 # ─────────────────────────────────────────────────────────────────────────────
411
412 class TestBuildSymbolIndex:
413 @pytest.mark.asyncio
414 async def test_returns_empty_when_no_structured_delta(
415 self, db_session: AsyncSession
416 ) -> None:
417 from musehub.services.musehub_symbol_indexer import build_symbol_index
418 from tests.factories import create_commit
419
420 repo = await create_repo(db_session, slug="idx-nodelta")
421 commit = await create_commit(db_session, repo.repo_id, branch="main")
422
423 results = await build_symbol_index(db_session, repo.repo_id, commit.commit_id)
424 assert results == []
425
426 @pytest.mark.asyncio
427 async def test_returns_results_for_structured_delta(
428 self, db_session: AsyncSession
429 ) -> None:
430 from musehub.services.musehub_symbol_indexer import load_symbol_history
431 repo = await create_repo(db_session, slug="idx-creates")
432 commit = await _commit_with_delta(
433 db_session, repo.repo_id, "c001",
434 ops=[_insert_op("main.py::Foo", "sha256:aaa")],
435 )
436
437 results = await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
438 await db_session.commit()
439
440 assert results
441 types = {t for t, _ in results}
442 # Aggregate blobs are still produced.
443 assert "code.intel_summary" in types
444 assert "code.intel_snapshot" in types
445 # Per-symbol data now lives in normalized tables, not in blobs.
446 assert "code.symbol_history" not in types
447 assert "code.hash_occurrence" not in types
448 assert "code.per_symbol_intel" not in types
449 # Confirm normalized rows were written.
450 history = await load_symbol_history(db_session, repo.repo_id)
451 assert "main.py::Foo" in history
452
453 @pytest.mark.asyncio
454 async def test_symbol_history_contains_correct_entries(
455 self, db_session: AsyncSession
456 ) -> None:
457 from musehub.services.musehub_symbol_indexer import load_symbol_history
458 repo = await create_repo(db_session, slug="idx-symhist")
459 commit = await _commit_with_delta(
460 db_session, repo.repo_id, "c002",
461 ops=[
462 _insert_op("src/app.py::MyClass", "sha256:class"),
463 _insert_op("src/app.py::my_func", "sha256:func"),
464 ],
465 )
466
467 await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
468 await db_session.commit()
469
470 entries = await load_symbol_history(db_session, repo.repo_id)
471 assert "src/app.py::MyClass" in entries
472 assert "src/app.py::my_func" in entries
473 assert entries["src/app.py::MyClass"][0]["op"] == "insert"
474
475 @pytest.mark.asyncio
476 async def test_hash_occurrence_tracks_shared_content(
477 self, db_session: AsyncSession
478 ) -> None:
479 repo = await create_repo(db_session, slug="idx-hashoc")
480 shared_hash = "sha256:shared"
481 commit = await _commit_with_delta(
482 db_session, repo.repo_id, "c003",
483 ops=[
484 _insert_op("a.py::Foo", shared_hash),
485 _insert_op("b.py::Bar", shared_hash),
486 ],
487 )
488
489 from musehub.services.musehub_symbol_indexer import load_hash_occurrence
490 await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
491 await db_session.commit()
492
493 entries = await load_hash_occurrence(db_session, repo.repo_id)
494 assert shared_hash in entries
495 assert set(entries[shared_hash]) == {"a.py::Foo", "b.py::Bar"}
496
497 @pytest.mark.asyncio
498 async def test_rebuild_upserts_one_row_per_intel_type(
499 self, db_session: AsyncSession
500 ) -> None:
501 from sqlalchemy import select, func
502
503 repo = await create_repo(db_session, slug="idx-prune")
504 c1 = await _commit_with_delta(db_session, repo.repo_id, "c100",
505 ops=[_insert_op("f.py::A")])
506 await _build_and_persist(db_session, repo.repo_id, c1.commit_id)
507 await db_session.commit()
508
509 c2 = await _commit_with_delta(db_session, repo.repo_id, "c101",
510 ops=[_insert_op("f.py::B")])
511 await _build_and_persist(db_session, repo.repo_id, c2.commit_id)
512 await db_session.commit()
513
514 # code.symbol_history is no longer a blob — it lives in normalized rows.
515 # intel_summary/intel_snapshot are the only blobs, each upserted once.
516 blob_count = (await db_session.execute(
517 select(func.count()).select_from(db.MusehubIntelResult).where(
518 db.MusehubIntelResult.repo_id == repo.repo_id,
519 db.MusehubIntelResult.intel_type == "code.intel_summary",
520 )
521 )).scalar_one()
522 assert blob_count == 1
523
524 @pytest.mark.asyncio
525 async def test_bfs_excludes_orphaned_commits(
526 self, db_session: AsyncSession
527 ) -> None:
528 """Commits not reachable from head must not appear in the symbol index."""
529 from musehub.services.musehub_symbol_indexer import load_symbol_history
530
531 repo = await create_repo(db_session, slug="idx-bfs")
532 await _commit_with_delta(
533 db_session, repo.repo_id, "orphan",
534 ops=[_insert_op("orphan.py::OrphanSym", "sha256:orphan")],
535 parent_ids=[],
536 )
537 head = await _commit_with_delta(
538 db_session, repo.repo_id, "head",
539 ops=[_insert_op("main.py::RealSym", "sha256:real")],
540 parent_ids=[],
541 )
542
543 await _build_and_persist(db_session, repo.repo_id, head.commit_id)
544 await db_session.commit()
545
546 history = await load_symbol_history(db_session, repo.repo_id)
547 assert "main.py::RealSym" in history
548 assert "orphan.py::OrphanSym" not in history
549
550
551 class TestLoadFunctions:
552 @pytest.mark.asyncio
553 async def test_load_symbol_history_empty_when_no_index(
554 self, db_session: AsyncSession
555 ) -> None:
556 from musehub.services.musehub_symbol_indexer import load_symbol_history
557 repo = await create_repo(db_session, slug="load-noindex")
558 result = await load_symbol_history(db_session, repo.repo_id)
559 assert result == {}
560
561 @pytest.mark.asyncio
562 async def test_load_symbol_history_with_file_path_filter(
563 self, db_session: AsyncSession
564 ) -> None:
565 from musehub.services.musehub_symbol_indexer import load_symbol_history
566
567 repo = await create_repo(db_session, slug="load-filter")
568 commit = await _commit_with_delta(
569 db_session, repo.repo_id, "cF01",
570 ops=[
571 _insert_op("a.py::Foo", "sha256:x"),
572 _insert_op("a.py", "sha256:file"),
573 _insert_op("b.py::Bar", "sha256:y"),
574 ],
575 )
576 await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
577 await db_session.commit()
578
579 result = await load_symbol_history(db_session, repo.repo_id, file_path="a.py")
580 assert "a.py::Foo" in result
581 assert "a.py" in result
582 assert "b.py::Bar" not in result
583
584 @pytest.mark.asyncio
585 async def test_load_hash_occurrence_empty_when_no_index(
586 self, db_session: AsyncSession
587 ) -> None:
588 from musehub.services.musehub_symbol_indexer import load_hash_occurrence
589 repo = await create_repo(db_session, slug="hash-noindex")
590 assert await load_hash_occurrence(db_session, repo.repo_id) == {}
591
592 @pytest.mark.asyncio
593 async def test_load_hash_occurrence_returns_correct_entries(
594 self, db_session: AsyncSession
595 ) -> None:
596 from musehub.services.musehub_symbol_indexer import load_hash_occurrence
597
598 repo = await create_repo(db_session, slug="hash-entries")
599 commit = await _commit_with_delta(
600 db_session, repo.repo_id, "cH01",
601 ops=[_insert_op("x.py::X", "sha256:hash1"), _insert_op("y.py::Y", "sha256:hash1")],
602 )
603 await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
604 await db_session.commit()
605
606 result = await load_hash_occurrence(db_session, repo.repo_id)
607 assert "sha256:hash1" in result
608 assert set(result["sha256:hash1"]) == {"x.py::X", "y.py::Y"}
609
610 @pytest.mark.asyncio
611 async def test_get_index_meta_none_when_no_index(
612 self, db_session: AsyncSession
613 ) -> None:
614 from musehub.services.musehub_symbol_indexer import get_index_meta
615 repo = await create_repo(db_session, slug="meta-none")
616 assert await get_index_meta(db_session, repo.repo_id) is None
617
618 @pytest.mark.asyncio
619 async def test_get_index_meta_returns_ref_and_symbol_count(
620 self, db_session: AsyncSession
621 ) -> None:
622 from musehub.services.musehub_symbol_indexer import get_index_meta
623
624 repo = await create_repo(db_session, slug="meta-ok")
625 commit = await _commit_with_delta(
626 db_session, repo.repo_id, "cM01",
627 ops=[_insert_op("f.py::A"), _insert_op("f.py::B")],
628 )
629 await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
630 await db_session.commit()
631
632 meta = await get_index_meta(db_session, repo.repo_id)
633 assert meta is not None
634 assert meta["ref"] == commit.commit_id
635 assert meta["built_at"] is not None
636 assert meta["symbol_count"] >= 2
637
638 @pytest.mark.asyncio
639 async def test_load_intel_snapshot_none_when_no_index(
640 self, db_session: AsyncSession
641 ) -> None:
642 from musehub.services.musehub_symbol_indexer import load_intel_snapshot
643 repo = await create_repo(db_session, slug="intel-none")
644 assert await load_intel_snapshot(db_session, repo.repo_id) is None
645
646 @pytest.mark.asyncio
647 async def test_load_intel_snapshot_returns_snapshot_when_built(
648 self, db_session: AsyncSession
649 ) -> None:
650 from musehub.services.musehub_symbol_indexer import load_intel_snapshot
651
652 repo = await create_repo(db_session, slug="intel-ok")
653 commit = await _commit_with_delta(
654 db_session, repo.repo_id, "cI01",
655 ops=[_insert_op("app.py::Handler", "sha256:h1")],
656 )
657 results = await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
658 await db_session.commit()
659
660 assert results, "build_symbol_index returned empty results"
661 assert any(t == "code.intel_snapshot" for t, _ in results), "code.intel_snapshot not in results"
662 snap = await load_intel_snapshot(db_session, repo.repo_id)
663 assert snap is not None
664
665
666 class TestGetSnapshotManifestsBatch:
667 @pytest.mark.asyncio
668 async def test_empty_list_returns_empty_dict(
669 self, db_session: AsyncSession
670 ) -> None:
671 from musehub.services.musehub_snapshot import get_snapshot_manifests_batch
672 result = await get_snapshot_manifests_batch(db_session, [])
673 assert result == {}
674
675 @pytest.mark.asyncio
676 async def test_single_snapshot_manifest(
677 self, db_session: AsyncSession
678 ) -> None:
679 from musehub.services.musehub_snapshot import (
680 get_snapshot_manifests_batch,
681 upsert_snapshot_entries,
682 )
683 repo = await create_repo(db_session, slug="batch-single")
684 snap_id = "snap-batch-01"
685 await upsert_snapshot_entries(
686 db_session, repo.repo_id, snap_id, {"a.py": "sha256:a", "b.py": "sha256:b"}
687 )
688 await db_session.commit()
689
690 result = await get_snapshot_manifests_batch(db_session, [snap_id])
691 assert snap_id in result
692 assert result[snap_id]["a.py"] == "sha256:a"
693 assert result[snap_id]["b.py"] == "sha256:b"
694
695 @pytest.mark.asyncio
696 async def test_multiple_snapshots_grouped_correctly(
697 self, db_session: AsyncSession
698 ) -> None:
699 from musehub.services.musehub_snapshot import (
700 get_snapshot_manifests_batch,
701 upsert_snapshot_entries,
702 )
703 repo = await create_repo(db_session, slug="batch-multi")
704 for i in range(5):
705 snap_id = f"snap-multi-{i:02d}"
706 await upsert_snapshot_entries(
707 db_session, repo.repo_id, snap_id, {f"file{i}.py": long_id(f"{i}")}
708 )
709 await db_session.commit()
710
711 ids = [f"snap-multi-{i:02d}" for i in range(5)]
712 result = await get_snapshot_manifests_batch(db_session, ids)
713 assert len(result) == 5
714 for i, sid in enumerate(ids):
715 assert f"file{i}.py" in result[sid]
716
717 @pytest.mark.asyncio
718 async def test_unknown_snapshot_id_returns_empty_manifest(
719 self, db_session: AsyncSession
720 ) -> None:
721 from musehub.services.musehub_snapshot import get_snapshot_manifests_batch
722 result = await get_snapshot_manifests_batch(db_session, ["ghost-snap"])
723 assert result == {"ghost-snap": {}}
724
725
726 # ─────────────────────────────────────────────────────────────────────────────
727 # Layer 3 — E2E: full pipeline via direct service calls with real DB
728 # ─────────────────────────────────────────────────────────────────────────────
729
730 class TestSymbolIndexPipeline:
731 @pytest.mark.asyncio
732 async def test_build_then_meta_reflects_head_commit(
733 self, db_session: AsyncSession
734 ) -> None:
735 from musehub.services.musehub_symbol_indexer import get_index_meta
736
737 repo = await create_repo(db_session, slug="e2e-pipeline")
738 c1 = await _commit_with_delta(
739 db_session, repo.repo_id, "pipe-c001",
740 ops=[_insert_op("service.py::APIHandler", "sha256:h1")],
741 )
742 await _build_and_persist(db_session, repo.repo_id, c1.commit_id)
743 await db_session.commit()
744
745 meta = await get_index_meta(db_session, repo.repo_id)
746 assert meta is not None
747 assert meta["ref"] == c1.commit_id
748
749 @pytest.mark.asyncio
750 async def test_rebuild_updates_ref_to_latest_commit(
751 self, db_session: AsyncSession
752 ) -> None:
753 from musehub.services.musehub_symbol_indexer import get_index_meta
754
755 repo = await create_repo(db_session, slug="e2e-rebuild")
756 c1 = await _commit_with_delta(db_session, repo.repo_id, "rb-c001",
757 ops=[_insert_op("a.py::Old")])
758 await _build_and_persist(db_session, repo.repo_id, c1.commit_id)
759 await db_session.commit()
760
761 c2 = await _commit_with_delta(db_session, repo.repo_id, "rb-c002",
762 ops=[_insert_op("b.py::New")],
763 parent_ids=[c1.commit_id])
764 await _build_and_persist(db_session, repo.repo_id, c2.commit_id)
765 await db_session.commit()
766
767 meta = await get_index_meta(db_session, repo.repo_id)
768 assert meta is not None
769 assert meta["ref"] == c2.commit_id
770
771 @pytest.mark.asyncio
772 async def test_multi_commit_chain_all_symbols_indexed(
773 self, db_session: AsyncSession
774 ) -> None:
775 """3-commit chain — every symbol from every commit must appear in the index."""
776 from musehub.services.musehub_symbol_indexer import load_symbol_history
777
778 repo = await create_repo(db_session, slug="e2e-chain")
779 c1 = await _commit_with_delta(db_session, repo.repo_id, "chain-c001",
780 ops=[_insert_op("a.py::A1")])
781 c2 = await _commit_with_delta(db_session, repo.repo_id, "chain-c002",
782 ops=[_insert_op("b.py::B1")],
783 parent_ids=[c1.commit_id])
784 c3 = await _commit_with_delta(db_session, repo.repo_id, "chain-c003",
785 ops=[_insert_op("c.py::C1")],
786 parent_ids=[c2.commit_id])
787 await _build_and_persist(db_session, repo.repo_id, c3.commit_id)
788 await db_session.commit()
789
790 history = await load_symbol_history(db_session, repo.repo_id)
791 assert "a.py::A1" in history
792 assert "b.py::B1" in history
793 assert "c.py::C1" in history
794
795
796 # ─────────────────────────────────────────────────────────────────────────────
797 # Layer 4 — Data Integrity
798 # ─────────────────────────────────────────────────────────────────────────────
799
800 class TestDataIntegrity:
801 @pytest.mark.asyncio
802 async def test_upsert_atomic_replace_removes_stale_entries(
803 self, db_session: AsyncSession
804 ) -> None:
805 """Different snap_ids store different manifests independently."""
806 from musehub.services.musehub_snapshot import (
807 get_snapshot_manifest,
808 upsert_snapshot_entries,
809 )
810 repo = await create_repo(db_session, slug="di-atomic")
811 snap_id_a = "snap-atomic-a"
812 snap_id_b = "snap-atomic-b"
813 await upsert_snapshot_entries(
814 db_session, repo.repo_id, snap_id_a,
815 {"old_file.py": "sha256:old", "shared.py": "sha256:shared"},
816 )
817 await db_session.commit()
818
819 await upsert_snapshot_entries(
820 db_session, repo.repo_id, snap_id_b,
821 {"new_file.py": "sha256:new"},
822 )
823 await db_session.commit()
824
825 manifest_b = await get_snapshot_manifest(db_session, snap_id_b)
826 assert "new_file.py" in manifest_b
827 assert "old_file.py" not in manifest_b
828
829 manifest_a = await get_snapshot_manifest(db_session, snap_id_a)
830 assert "old_file.py" in manifest_a
831
832 @pytest.mark.asyncio
833 async def test_only_one_result_per_intel_type_after_multiple_builds(
834 self, db_session: AsyncSession
835 ) -> None:
836 from sqlalchemy import select, func
837
838 repo = await create_repo(db_session, slug="di-onerow")
839 for i in range(3):
840 c = await _commit_with_delta(
841 db_session, repo.repo_id, f"di-c{i:03d}",
842 ops=[_insert_op(f"f{i}.py::Sym")],
843 )
844 await _build_and_persist(db_session, repo.repo_id, c.commit_id)
845 await db_session.commit()
846
847 # code.symbol_history is no longer a blob.
848 # intel_summary must exist with exactly one row (upserted each push).
849 count = (await db_session.execute(
850 select(func.count()).select_from(db.MusehubIntelResult).where(
851 db.MusehubIntelResult.repo_id == repo.repo_id,
852 db.MusehubIntelResult.intel_type == "code.intel_summary",
853 )
854 )).scalar_one()
855 assert count == 1
856
857 @pytest.mark.asyncio
858 async def test_symbol_history_includes_commit_id_and_timestamp(
859 self, db_session: AsyncSession
860 ) -> None:
861 repo = await create_repo(db_session, slug="di-fields")
862 commit = await _commit_with_delta(
863 db_session, repo.repo_id, "di-field-001",
864 ops=[_insert_op("service.py::MyFn", "sha256:myfn")],
865 )
866 from musehub.services.musehub_symbol_indexer import load_symbol_history
867 await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
868 await db_session.commit()
869
870 entries = await load_symbol_history(db_session, repo.repo_id)
871 entry = entries["service.py::MyFn"][0]
872 assert entry["commit_id"] == commit.commit_id
873 assert entry["committed_at"] != ""
874 assert entry["op"] == "insert"
875 assert entry["content_id"] == "sha256:myfn"
876
877
878 # ─────────────────────────────────────────────────────────────────────────────
879 # Layer 5 — Security
880 # ─────────────────────────────────────────────────────────────────────────────
881
882 class TestSecurity:
883 @pytest.mark.asyncio
884 async def test_corrupt_json_returns_empty_not_exception(
885 self, db_session: AsyncSession
886 ) -> None:
887 """A corrupt code.symbol_history data_json must return {} — not raise."""
888 from musehub.services.musehub_symbol_indexer import load_symbol_history
889 from musehub.core.genesis import compute_intel_result_id
890
891 repo = await create_repo(db_session, slug="sec-corrupt")
892 # Manually insert a row with garbage JSON
893 result_id = compute_intel_result_id(repo.repo_id, "code.symbol_history", "bad-ref")
894 from sqlalchemy.dialects.postgresql import insert as pg_insert
895 await db_session.execute(
896 pg_insert(db.MusehubIntelResult).values(
897 result_id=result_id,
898 repo_id=repo.repo_id,
899 intel_type="code.symbol_history",
900 domain="code",
901 ref="bad-ref",
902 data_json="not valid json {{{{",
903 schema_version=1,
904 computed_at=_now(),
905 ).on_conflict_do_nothing()
906 )
907 await db_session.commit()
908
909 result = await load_symbol_history(db_session, repo.repo_id)
910 assert result == {}
911
912 @pytest.mark.asyncio
913 async def test_build_with_unknown_head_commit_returns_empty(
914 self, db_session: AsyncSession
915 ) -> None:
916 """Unknown head_commit_id must return [], not raise."""
917 from musehub.services.musehub_symbol_indexer import build_symbol_index
918
919 repo = await create_repo(db_session, slug="sec-unknown-head")
920 results = await build_symbol_index(
921 db_session, repo.repo_id, "nonexistent-commit-id"
922 )
923 assert results == []
924
925 @pytest.mark.asyncio
926 async def test_corrupt_hash_occurrence_returns_empty(
927 self, db_session: AsyncSession
928 ) -> None:
929 from musehub.services.musehub_symbol_indexer import load_hash_occurrence
930 from musehub.core.genesis import compute_intel_result_id
931 from sqlalchemy.dialects.postgresql import insert as pg_insert
932
933 repo = await create_repo(db_session, slug="sec-corrupt-hash")
934 result_id = compute_intel_result_id(repo.repo_id, "code.hash_occurrence", "bad-ref")
935 await db_session.execute(
936 pg_insert(db.MusehubIntelResult).values(
937 result_id=result_id,
938 repo_id=repo.repo_id,
939 intel_type="code.hash_occurrence",
940 domain="code",
941 ref="bad-ref",
942 data_json="} invalid {",
943 schema_version=1,
944 computed_at=_now(),
945 ).on_conflict_do_nothing()
946 )
947 await db_session.commit()
948
949 result = await load_hash_occurrence(db_session, repo.repo_id)
950 assert result == {}
951
952
953 # ─────────────────────────────────────────────────────────────────────────────
954 # Layer 5B — Per-symbol intel
955 # ─────────────────────────────────────────────────────────────────────────────
956
957 class TestPerSymbolIntel:
958 @pytest.mark.asyncio
959 async def test_early_return_when_already_current(
960 self, db_session: AsyncSession
961 ) -> None:
962 """When the index is current and code.per_symbol_intel exists,
963 build_symbol_index must return [] (early exit, no recompute)."""
964 from musehub.services.musehub_symbol_indexer import build_symbol_index
965
966 repo = await create_repo(db_session, slug="bfil-current")
967 commit = await _commit_with_delta(
968 db_session, repo.repo_id, "bfil-c001",
969 ops=[_insert_op("svc.py::Handler", "sha256:h1")],
970 )
971 await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
972 await db_session.commit()
973
974 # Second call with same head: must early-return (empty list).
975 results2 = await build_symbol_index(db_session, repo.repo_id, commit.commit_id)
976 assert results2 == [], (
977 "build_symbol_index must return [] when index is current "
978 "and per_symbol_intel result exists."
979 )
980
981 @pytest.mark.asyncio
982 async def test_per_symbol_intel_populated_on_first_build(
983 self, db_session: AsyncSession
984 ) -> None:
985 from musehub.services.musehub_symbol_indexer import lookup_symbol_intel
986 repo = await create_repo(db_session, slug="bfil-fresh")
987 commit = await _commit_with_delta(
988 db_session, repo.repo_id, "bfil-fresh-c001",
989 ops=[_insert_op("api.py::Router", "sha256:r1")],
990 )
991 await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
992 await db_session.commit()
993
994 psi_data = await lookup_symbol_intel(db_session, repo.repo_id, ["api.py::Router"])
995 assert "api.py::Router" in psi_data
996
997 @pytest.mark.asyncio
998 async def test_per_symbol_intel_contains_expected_fields(
999 self, db_session: AsyncSession
1000 ) -> None:
1001 from musehub.services.musehub_symbol_indexer import lookup_symbol_intel
1002 repo = await create_repo(db_session, slug="bfil-fields")
1003 commit = await _commit_with_delta(
1004 db_session, repo.repo_id, "bfil-fields-c001",
1005 ops=[_insert_op("lib.py::Parser", "sha256:p1")],
1006 )
1007 await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
1008 await db_session.commit()
1009
1010 psi_data = await lookup_symbol_intel(db_session, repo.repo_id, ["lib.py::Parser"])
1011 entry = psi_data["lib.py::Parser"]
1012 for field in ("churn", "churn_30d", "churn_90d", "blast", "blast_direct",
1013 "blast_cross", "blast_top", "last_changed", "last_author",
1014 "author_count", "gravity", "weekly"):
1015 assert field in entry, f"Missing field '{field}' in per_symbol intel entry."
1016
1017 @pytest.mark.asyncio
1018 async def test_author_count_reflects_unique_authors(
1019 self, db_session: AsyncSession
1020 ) -> None:
1021 repo = await create_repo(db_session, slug="bfil-authors")
1022 authors_seq = [("alice", "bfil-authors-c001"), ("bob", "bfil-authors-c002"), ("alice", "bfil-authors-c003")]
1023 prev_id: list[str] = []
1024 for i, (author, cid) in enumerate(authors_seq, start=1):
1025 commit = await _commit_with_delta(
1026 db_session, repo.repo_id, cid,
1027 ops=[_insert_op("lib.py::Widget", f"sha256:w{i}")],
1028 parent_ids=prev_id,
1029 author=author,
1030 )
1031 prev_id = [cid]
1032 await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
1033 await db_session.commit()
1034
1035 from musehub.services.musehub_symbol_indexer import lookup_symbol_intel
1036 psi_data = await lookup_symbol_intel(db_session, repo.repo_id, ["lib.py::Widget"])
1037 entry = psi_data["lib.py::Widget"]
1038 assert entry["author_count"] == 2, (
1039 f"Expected 2 unique authors (alice, bob), got {entry['author_count']}"
1040 )
1041 assert entry["churn"] == 3
1042
1043 @pytest.mark.asyncio
1044 async def test_lookup_symbol_intel_returns_matching_addresses(
1045 self, db_session: AsyncSession
1046 ) -> None:
1047 from musehub.services.musehub_symbol_indexer import lookup_symbol_intel
1048
1049 repo = await create_repo(db_session, slug="bfil-lookup")
1050 commit = await _commit_with_delta(
1051 db_session, repo.repo_id, "bfil-lookup-c001",
1052 ops=[
1053 _insert_op("a.py::Foo", "sha256:f1"),
1054 _insert_op("b.py::Bar", "sha256:b1"),
1055 _insert_op("c.py::Baz", "sha256:z1"),
1056 ],
1057 )
1058 await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
1059 await db_session.commit()
1060
1061 result = await lookup_symbol_intel(db_session, repo.repo_id, ["a.py::Foo", "c.py::Baz"])
1062 assert set(result.keys()) == {"a.py::Foo", "c.py::Baz"}
1063 assert "b.py::Bar" not in result
1064
1065 @pytest.mark.asyncio
1066 async def test_lookup_symbol_intel_returns_empty_when_no_index(
1067 self, db_session: AsyncSession
1068 ) -> None:
1069 from musehub.services.musehub_symbol_indexer import lookup_symbol_intel
1070
1071 repo = await create_repo(db_session, slug="bfil-lookup-null")
1072 result = await lookup_symbol_intel(db_session, repo.repo_id, ["core.py::Engine"])
1073 assert result == {}
1074
1075
1076 # ─────────────────────────────────────────────────────────────────────────────
1077 # Layer 6 — Stress
1078 # ─────────────────────────────────────────────────────────────────────────────
1079
1080 class TestStress:
1081 @pytest.mark.asyncio
1082 async def test_upsert_1000_file_manifest(self, db_session: AsyncSession) -> None:
1083 from musehub.services.musehub_snapshot import (
1084 get_snapshot_manifest,
1085 upsert_snapshot_entries,
1086 )
1087 repo = await create_repo(db_session, slug="stress-1k-snap")
1088 snap_id = "snap-1k"
1089 manifest = {f"src/file_{i:04d}.py": long_id(f"{i:04d}") for i in range(1000)}
1090
1091 await upsert_snapshot_entries(db_session, repo.repo_id, snap_id, manifest)
1092 await db_session.commit()
1093
1094 result = await get_snapshot_manifest(db_session, snap_id)
1095 assert len(result) == 1000
1096 assert result["src/file_0500.py"] == "sha256:0500"
1097
1098 @pytest.mark.asyncio
1099 async def test_batch_manifest_50_snapshots(self, db_session: AsyncSession) -> None:
1100 from musehub.services.musehub_snapshot import (
1101 get_snapshot_manifests_batch,
1102 upsert_snapshot_entries,
1103 )
1104 repo = await create_repo(db_session, slug="stress-batch-50")
1105 ids: list[str] = []
1106 for i in range(50):
1107 sid = f"stress-snap-{i:02d}"
1108 ids.append(sid)
1109 await upsert_snapshot_entries(
1110 db_session, repo.repo_id, sid,
1111 {f"f{i}.py": long_id(f"{i}")},
1112 )
1113 await db_session.commit()
1114
1115 result = await get_snapshot_manifests_batch(db_session, ids)
1116 assert len(result) == 50
1117 for i, sid in enumerate(ids):
1118 assert f"f{i}.py" in result[sid]
1119
1120 @pytest.mark.asyncio
1121 async def test_build_symbol_index_100_commits(
1122 self, db_session: AsyncSession
1123 ) -> None:
1124 """100-commit chain with 5 ops each — indexer must complete successfully."""
1125 from musehub.services.musehub_symbol_indexer import load_symbol_history
1126
1127 repo = await create_repo(db_session, slug="stress-100-commits")
1128 prev_id: str | None = None
1129 head_id = "stress-head"
1130 for i in range(100):
1131 cid = f"stress-{i:04d}" if i < 99 else head_id
1132 ops = [_insert_op(f"file{i}.py::Sym{j}", long_id(f"{i}{j}")) for j in range(5)]
1133 commit = await _commit_with_delta(
1134 db_session, repo.repo_id, cid, ops=ops,
1135 parent_ids=[prev_id] if prev_id else [],
1136 )
1137 prev_id = commit.commit_id
1138
1139 await _build_and_persist(db_session, repo.repo_id, head_id)
1140 await db_session.commit()
1141
1142 history = await load_symbol_history(db_session, repo.repo_id)
1143 # 100 files × 5 symbols each = 500 top-level symbol entries
1144 assert len(history) == 500
1145
1146 @pytest.mark.asyncio
1147 async def test_load_symbol_history_file_filter_on_large_index(
1148 self, db_session: AsyncSession
1149 ) -> None:
1150 """Filter on large index returns only matching addresses."""
1151 from musehub.services.musehub_symbol_indexer import load_symbol_history
1152
1153 repo = await create_repo(db_session, slug="stress-filter-large")
1154 ops = []
1155 for i in range(50):
1156 for j in range(10):
1157 ops.append(_insert_op(f"src/module_{i:02d}.py::Sym{j}", long_id(f"{i}{j}")))
1158
1159 commit = await _commit_with_delta(db_session, repo.repo_id, "stress-fl-head", ops=ops)
1160 await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
1161 await db_session.commit()
1162
1163 result = await load_symbol_history(db_session, repo.repo_id, file_path="src/module_05.py")
1164 assert len(result) == 10
1165 for key in result:
1166 assert key.startswith("src/module_05.py")
1167
1168
1169 # ─────────────────────────────────────────────────────────────────────────────
1170 # Layer: backfill_genesis_ops
1171 # ─────────────────────────────────────────────────────────────────────────────
1172
1173 class TestBackfillGenesisOps:
1174 """backfill_genesis_ops corrects birth entries that were indexed as
1175 op='modify' because the genesis commit had no structured_delta."""
1176
1177 async def _seed_bad_birth(
1178 self,
1179 session: AsyncSession,
1180 repo_id: str,
1181 address: str = "src/a.py::my_fn",
1182 op: str = "modify",
1183 ) -> db.MusehubSymbolHistoryEntry:
1184 """Insert a history entry that simulates a mis-indexed birth op."""
1185 from datetime import timedelta
1186 entry = db.MusehubSymbolHistoryEntry(
1187 repo_id=repo_id,
1188 address=address,
1189 commit_id=blob_id(secrets.token_bytes(16)),
1190 committed_at=_now() - timedelta(days=10),
1191 author="gabriel",
1192 op=op,
1193 content_id=blob_id(secrets.token_bytes(16)),
1194 )
1195 session.add(entry)
1196 await session.flush()
1197 return entry
1198
1199 @pytest.mark.asyncio
1200 async def test_dry_run_returns_count_without_writing(
1201 self, db_session: AsyncSession
1202 ) -> None:
1203 from musehub.services.musehub_symbol_indexer import backfill_genesis_ops
1204 from sqlalchemy import select
1205
1206 repo = await create_repo(db_session, slug="bf-dry-run")
1207 await self._seed_bad_birth(db_session, repo.repo_id, op="modify")
1208 await db_session.flush()
1209
1210 count = await backfill_genesis_ops(db_session, repo_id=repo.repo_id, dry_run=True)
1211 assert count == 1
1212
1213 # Nothing written — row still has op='modify'
1214 rows = (await db_session.execute(
1215 select(db.MusehubSymbolHistoryEntry).where(
1216 db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
1217 )
1218 )).scalars().all()
1219 assert all(r.op == "modify" for r in rows)
1220
1221 @pytest.mark.asyncio
1222 async def test_corrects_modify_to_add(self, db_session: AsyncSession) -> None:
1223 from musehub.services.musehub_symbol_indexer import backfill_genesis_ops
1224 from sqlalchemy import select
1225
1226 repo = await create_repo(db_session, slug="bf-modify")
1227 entry = await self._seed_bad_birth(db_session, repo.repo_id, op="modify")
1228 await db_session.flush()
1229
1230 updated = await backfill_genesis_ops(db_session, repo_id=repo.repo_id)
1231 assert updated == 1
1232
1233 refreshed = (await db_session.execute(
1234 select(db.MusehubSymbolHistoryEntry).where(
1235 db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
1236 db.MusehubSymbolHistoryEntry.address == entry.address,
1237 db.MusehubSymbolHistoryEntry.commit_id == entry.commit_id,
1238 )
1239 )).scalar_one()
1240 assert refreshed.op == "add"
1241
1242 @pytest.mark.asyncio
1243 async def test_skips_entries_already_add(self, db_session: AsyncSession) -> None:
1244 from musehub.services.musehub_symbol_indexer import backfill_genesis_ops
1245
1246 repo = await create_repo(db_session, slug="bf-already-add")
1247 await self._seed_bad_birth(db_session, repo.repo_id, op="add")
1248 await db_session.flush()
1249
1250 updated = await backfill_genesis_ops(db_session, repo_id=repo.repo_id)
1251 assert updated == 0
1252
1253 @pytest.mark.asyncio
1254 async def test_only_corrects_oldest_entry_not_later_modifies(
1255 self, db_session: AsyncSession
1256 ) -> None:
1257 """A subsequent modify on the same symbol must not be changed."""
1258 from datetime import timedelta
1259 from musehub.services.musehub_symbol_indexer import backfill_genesis_ops
1260 from sqlalchemy import select
1261
1262 repo = await create_repo(db_session, slug="bf-oldest-only")
1263 address = "src/b.py::helper"
1264
1265 birth = db.MusehubSymbolHistoryEntry(
1266 repo_id=repo.repo_id,
1267 address=address,
1268 commit_id=blob_id(secrets.token_bytes(16)),
1269 committed_at=_now() - timedelta(days=5),
1270 author="gabriel",
1271 op="modify",
1272 content_id=blob_id(secrets.token_bytes(16)),
1273 )
1274 later = db.MusehubSymbolHistoryEntry(
1275 repo_id=repo.repo_id,
1276 address=address,
1277 commit_id=blob_id(secrets.token_bytes(16)),
1278 committed_at=_now() - timedelta(days=1),
1279 author="gabriel",
1280 op="modify",
1281 content_id=blob_id(secrets.token_bytes(16)),
1282 )
1283 session = db_session
1284 session.add(birth)
1285 session.add(later)
1286 await session.flush()
1287
1288 updated = await backfill_genesis_ops(session, repo_id=repo.repo_id)
1289 assert updated == 1
1290
1291 rows = (await session.execute(
1292 select(db.MusehubSymbolHistoryEntry)
1293 .where(
1294 db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
1295 db.MusehubSymbolHistoryEntry.address == address,
1296 )
1297 .order_by(db.MusehubSymbolHistoryEntry.committed_at.asc())
1298 )).scalars().all()
1299 assert rows[0].op == "add" # birth corrected
1300 assert rows[1].op == "modify" # later change untouched
1301
1302 @pytest.mark.asyncio
1303 async def test_repo_id_none_corrects_all_repos(
1304 self, db_session: AsyncSession
1305 ) -> None:
1306 from musehub.services.musehub_symbol_indexer import backfill_genesis_ops
1307
1308 repo_a = await create_repo(db_session, slug="bf-all-a")
1309 repo_b = await create_repo(db_session, slug="bf-all-b")
1310 await self._seed_bad_birth(db_session, repo_a.repo_id, op="modify")
1311 await self._seed_bad_birth(db_session, repo_b.repo_id, op="modify")
1312 await db_session.flush()
1313
1314 updated = await backfill_genesis_ops(db_session, repo_id=None)
1315 assert updated >= 2
1316
1317 @pytest.mark.asyncio
1318 async def test_idempotent(self, db_session: AsyncSession) -> None:
1319 """Running twice returns 0 on the second pass."""
1320 from musehub.services.musehub_symbol_indexer import backfill_genesis_ops
1321
1322 repo = await create_repo(db_session, slug="bf-idempotent")
1323 await self._seed_bad_birth(db_session, repo.repo_id, op="modify")
1324 await db_session.flush()
1325
1326 first = await backfill_genesis_ops(db_session, repo_id=repo.repo_id)
1327 assert first == 1
1328 second = await backfill_genesis_ops(db_session, repo_id=repo.repo_id)
1329 assert second == 0
1330
1331
1332 # ─────────────────────────────────────────────────────────────────────────────
1333 # Layer: backfill_content_ids_from_snapshots
1334 # ─────────────────────────────────────────────────────────────────────────────
1335
1336 class TestBackfillContentIdsFromSnapshots:
1337 """backfill_content_ids_from_snapshots fills missing content_id values
1338 on file-level history entries by reading snapshot manifests from the DAG."""
1339
1340 async def _seed_snapshot_and_commit(
1341 self,
1342 session: AsyncSession,
1343 repo_id: str,
1344 manifest: dict[str, str],
1345 commit_id: str | None = None,
1346 ) -> tuple[db.MusehubSnapshot, db.MusehubCommit]:
1347 """Insert a snapshot (msgpack manifest) and a commit pointing to it."""
1348 import msgpack
1349
1350 cid = commit_id or blob_id(secrets.token_bytes(16))
1351 snap_id = blob_id(secrets.token_bytes(16))
1352
1353 snapshot = db.MusehubSnapshot(
1354 snapshot_id=snap_id,
1355 repo_id=repo_id,
1356 directories=[],
1357 manifest_blob=msgpack.packb(manifest, use_bin_type=True),
1358 entry_count=len(manifest),
1359 )
1360 session.add(snapshot)
1361
1362 commit = db.MusehubCommit(
1363 commit_id=cid,
1364 repo_id=repo_id,
1365 branch="main",
1366 parent_ids=[],
1367 message="test commit",
1368 author="gabriel",
1369 timestamp=_now(),
1370 snapshot_id=snap_id,
1371 )
1372 session.add(commit)
1373 await session.flush()
1374 return snapshot, commit
1375
1376 async def _seed_missing_entry(
1377 self,
1378 session: AsyncSession,
1379 repo_id: str,
1380 address: str,
1381 commit_id: str,
1382 ) -> db.MusehubSymbolHistoryEntry:
1383 """Insert a file-level history entry with content_id=None."""
1384 entry = db.MusehubSymbolHistoryEntry(
1385 repo_id=repo_id,
1386 address=address,
1387 commit_id=commit_id,
1388 committed_at=_now(),
1389 author="gabriel",
1390 op="add",
1391 content_id=None,
1392 )
1393 session.add(entry)
1394 await session.flush()
1395 return entry
1396
1397 @pytest.mark.asyncio
1398 async def test_dry_run_returns_count_without_writing(
1399 self, db_session: AsyncSession
1400 ) -> None:
1401 from musehub.services.musehub_symbol_indexer import backfill_content_ids_from_snapshots
1402 from sqlalchemy import select
1403
1404 repo = await create_repo(db_session, slug="bcid-dry")
1405 address = "src/app.ts"
1406 content_id = blob_id(secrets.token_bytes(16))
1407 _, commit = await self._seed_snapshot_and_commit(
1408 db_session, repo.repo_id, {address: content_id}
1409 )
1410 await self._seed_missing_entry(db_session, repo.repo_id, address, commit.commit_id)
1411 await db_session.flush()
1412
1413 count = await backfill_content_ids_from_snapshots(
1414 db_session, repo_id=repo.repo_id, dry_run=True
1415 )
1416 assert count == 1
1417
1418 # Nothing written — content_id still None
1419 rows = (await db_session.execute(
1420 select(db.MusehubSymbolHistoryEntry).where(
1421 db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
1422 )
1423 )).scalars().all()
1424 assert all(r.content_id is None for r in rows)
1425
1426 @pytest.mark.asyncio
1427 async def test_fills_content_id_from_manifest(
1428 self, db_session: AsyncSession
1429 ) -> None:
1430 from musehub.services.musehub_symbol_indexer import backfill_content_ids_from_snapshots
1431 from sqlalchemy import select
1432
1433 repo = await create_repo(db_session, slug="bcid-fill")
1434 address = "src/app.ts"
1435 expected_cid = blob_id(secrets.token_bytes(16))
1436 _, commit = await self._seed_snapshot_and_commit(
1437 db_session, repo.repo_id, {address: expected_cid}
1438 )
1439 await self._seed_missing_entry(db_session, repo.repo_id, address, commit.commit_id)
1440 await db_session.flush()
1441
1442 updated = await backfill_content_ids_from_snapshots(db_session, repo_id=repo.repo_id)
1443 assert updated == 1
1444
1445 row = (await db_session.execute(
1446 select(db.MusehubSymbolHistoryEntry).where(
1447 db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
1448 db.MusehubSymbolHistoryEntry.address == address,
1449 db.MusehubSymbolHistoryEntry.commit_id == commit.commit_id,
1450 )
1451 )).scalar_one()
1452 assert row.content_id == expected_cid
1453
1454 @pytest.mark.asyncio
1455 async def test_skips_symbol_level_addresses(
1456 self, db_session: AsyncSession
1457 ) -> None:
1458 """Entries with '::' in the address are symbol-level and must be skipped."""
1459 from musehub.services.musehub_symbol_indexer import backfill_content_ids_from_snapshots
1460
1461 repo = await create_repo(db_session, slug="bcid-sym")
1462 address = "src/app.ts::MyClass"
1463 content_id = blob_id(secrets.token_bytes(16))
1464 _, commit = await self._seed_snapshot_and_commit(
1465 db_session, repo.repo_id, {"src/app.ts": content_id}
1466 )
1467 entry = db.MusehubSymbolHistoryEntry(
1468 repo_id=repo.repo_id,
1469 address=address,
1470 commit_id=commit.commit_id,
1471 committed_at=_now(),
1472 author="gabriel",
1473 op="add",
1474 content_id=None,
1475 )
1476 db_session.add(entry)
1477 await db_session.flush()
1478
1479 updated = await backfill_content_ids_from_snapshots(db_session, repo_id=repo.repo_id)
1480 assert updated == 0
1481
1482 @pytest.mark.asyncio
1483 async def test_skips_entries_already_with_content_id(
1484 self, db_session: AsyncSession
1485 ) -> None:
1486 from musehub.services.musehub_symbol_indexer import backfill_content_ids_from_snapshots
1487 from sqlalchemy import select
1488
1489 repo = await create_repo(db_session, slug="bcid-skip")
1490 address = "src/keep.py"
1491 existing_cid = blob_id(secrets.token_bytes(16))
1492 manifest_cid = blob_id(secrets.token_bytes(16))
1493 _, commit = await self._seed_snapshot_and_commit(
1494 db_session, repo.repo_id, {address: manifest_cid}
1495 )
1496 entry = db.MusehubSymbolHistoryEntry(
1497 repo_id=repo.repo_id,
1498 address=address,
1499 commit_id=commit.commit_id,
1500 committed_at=_now(),
1501 author="gabriel",
1502 op="add",
1503 content_id=existing_cid,
1504 )
1505 db_session.add(entry)
1506 await db_session.flush()
1507
1508 updated = await backfill_content_ids_from_snapshots(db_session, repo_id=repo.repo_id)
1509 assert updated == 0
1510
1511 # Original content_id preserved
1512 row = (await db_session.execute(
1513 select(db.MusehubSymbolHistoryEntry).where(
1514 db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
1515 )
1516 )).scalar_one()
1517 assert row.content_id == existing_cid
1518
1519 @pytest.mark.asyncio
1520 async def test_skips_entry_when_path_absent_from_manifest(
1521 self, db_session: AsyncSession
1522 ) -> None:
1523 """If the manifest doesn't contain the address, the entry is left alone."""
1524 from musehub.services.musehub_symbol_indexer import backfill_content_ids_from_snapshots
1525 from sqlalchemy import select
1526
1527 repo = await create_repo(db_session, slug="bcid-absent")
1528 address = "src/ghost.py"
1529 _, commit = await self._seed_snapshot_and_commit(
1530 db_session, repo.repo_id, {"src/other.py": blob_id(secrets.token_bytes(16))}
1531 )
1532 await self._seed_missing_entry(db_session, repo.repo_id, address, commit.commit_id)
1533 await db_session.flush()
1534
1535 updated = await backfill_content_ids_from_snapshots(db_session, repo_id=repo.repo_id)
1536 assert updated == 0
1537
1538 row = (await db_session.execute(
1539 select(db.MusehubSymbolHistoryEntry).where(
1540 db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
1541 )
1542 )).scalar_one()
1543 assert row.content_id is None
1544
1545 @pytest.mark.asyncio
1546 async def test_repo_id_none_fills_all_repos(
1547 self, db_session: AsyncSession
1548 ) -> None:
1549 from musehub.services.musehub_symbol_indexer import backfill_content_ids_from_snapshots
1550
1551 repo_a = await create_repo(db_session, slug="bcid-all-a")
1552 repo_b = await create_repo(db_session, slug="bcid-all-b")
1553 cid_a = blob_id(secrets.token_bytes(16))
1554 cid_b = blob_id(secrets.token_bytes(16))
1555
1556 _, commit_a = await self._seed_snapshot_and_commit(
1557 db_session, repo_a.repo_id, {"src/a.py": cid_a}
1558 )
1559 _, commit_b = await self._seed_snapshot_and_commit(
1560 db_session, repo_b.repo_id, {"src/b.py": cid_b}
1561 )
1562 await self._seed_missing_entry(db_session, repo_a.repo_id, "src/a.py", commit_a.commit_id)
1563 await self._seed_missing_entry(db_session, repo_b.repo_id, "src/b.py", commit_b.commit_id)
1564 await db_session.flush()
1565
1566 updated = await backfill_content_ids_from_snapshots(db_session, repo_id=None)
1567 assert updated >= 2
1568
1569 @pytest.mark.asyncio
1570 async def test_idempotent(self, db_session: AsyncSession) -> None:
1571 """Running twice returns 0 on the second pass."""
1572 from musehub.services.musehub_symbol_indexer import backfill_content_ids_from_snapshots
1573
1574 repo = await create_repo(db_session, slug="bcid-idem")
1575 address = "src/main.py"
1576 cid = blob_id(secrets.token_bytes(16))
1577 _, commit = await self._seed_snapshot_and_commit(
1578 db_session, repo.repo_id, {address: cid}
1579 )
1580 await self._seed_missing_entry(db_session, repo.repo_id, address, commit.commit_id)
1581 await db_session.flush()
1582
1583 first = await backfill_content_ids_from_snapshots(db_session, repo_id=repo.repo_id)
1584 assert first == 1
1585 second = await backfill_content_ids_from_snapshots(db_session, repo_id=repo.repo_id)
1586 assert second == 0
1587
1588 @pytest.mark.asyncio
1589 async def test_corrupt_manifest_blob_is_skipped_gracefully(
1590 self, db_session: AsyncSession
1591 ) -> None:
1592 """A corrupt manifest blob must not raise — entry is left with content_id=None."""
1593 from musehub.services.musehub_symbol_indexer import backfill_content_ids_from_snapshots
1594 from sqlalchemy import select
1595
1596 repo = await create_repo(db_session, slug="bcid-corrupt")
1597 snap_id = blob_id(secrets.token_bytes(16))
1598 commit_id = blob_id(secrets.token_bytes(16))
1599 address = "src/broken.py"
1600
1601 snapshot = db.MusehubSnapshot(
1602 snapshot_id=snap_id,
1603 repo_id=repo.repo_id,
1604 directories=[],
1605 manifest_blob=b"\xff\xfe not msgpack",
1606 entry_count=0,
1607 )
1608 db_session.add(snapshot)
1609
1610 commit = db.MusehubCommit(
1611 commit_id=commit_id,
1612 repo_id=repo.repo_id,
1613 branch="main",
1614 parent_ids=[],
1615 message="corrupt test",
1616 author="gabriel",
1617 timestamp=_now(),
1618 snapshot_id=snap_id,
1619 )
1620 db_session.add(commit)
1621 await self._seed_missing_entry(db_session, repo.repo_id, address, commit_id)
1622 await db_session.flush()
1623
1624 # Must not raise
1625 updated = await backfill_content_ids_from_snapshots(db_session, repo_id=repo.repo_id)
1626 assert updated == 0
1627
1628 row = (await db_session.execute(
1629 select(db.MusehubSymbolHistoryEntry).where(
1630 db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
1631 )
1632 )).scalar_one()
1633 assert row.content_id is None
1634
1635
1636 # ─────────────────────────────────────────────────────────────────────────────
1637 # Layer: backfill_raw_ops_from_commits
1638 # ─────────────────────────────────────────────────────────────────────────────
1639
1640 class TestBackfillRawOpsFromCommits:
1641 """backfill_raw_ops_from_commits re-indexes stale coarse-op rows by reading
1642 the original structured_delta from commit_meta."""
1643
1644 async def _seed_commit_with_meta(
1645 self,
1646 session: AsyncSession,
1647 repo_id: str,
1648 ops: list[dict],
1649 commit_id: str | None = None,
1650 ) -> db.MusehubCommit:
1651 cid = commit_id or blob_id(secrets.token_bytes(16))
1652 commit = db.MusehubCommit(
1653 commit_id=cid,
1654 repo_id=repo_id,
1655 branch="main",
1656 parent_ids=[],
1657 message="test",
1658 author="gabriel",
1659 timestamp=_now(),
1660 structured_delta={"ops": ops},
1661 )
1662 session.add(commit)
1663 await session.flush()
1664 return commit
1665
1666 async def _seed_stale_entry(
1667 self,
1668 session: AsyncSession,
1669 repo_id: str,
1670 address: str,
1671 commit_id: str,
1672 coarse_op: str,
1673 content_id: str | None = None,
1674 ) -> db.MusehubSymbolHistoryEntry:
1675 entry = db.MusehubSymbolHistoryEntry(
1676 repo_id=repo_id,
1677 address=address,
1678 commit_id=commit_id,
1679 committed_at=_now(),
1680 author="gabriel",
1681 op=coarse_op,
1682 op_payload=None,
1683 content_id=content_id,
1684 )
1685 session.add(entry)
1686 await session.flush()
1687 return entry
1688
1689 @pytest.mark.asyncio
1690 async def test_dry_run_returns_count_without_writing(
1691 self, db_session: AsyncSession
1692 ) -> None:
1693 from musehub.services.musehub_symbol_indexer import backfill_raw_ops_from_commits
1694 from sqlalchemy import select
1695
1696 repo = await create_repo(db_session, slug="bro-dry")
1697 commit = await self._seed_commit_with_meta(db_session, repo.repo_id, [
1698 {"address": "src/a.py::Fn", "op": "insert", "content_id": "sha256:aaa",
1699 "content_summary": "added Fn", "position": 0},
1700 ])
1701 await self._seed_stale_entry(db_session, repo.repo_id, "src/a.py::Fn",
1702 commit.commit_id, "add")
1703 await db_session.flush()
1704
1705 count = await backfill_raw_ops_from_commits(db_session, repo_id=repo.repo_id, dry_run=True)
1706 assert count == 1
1707
1708 row = (await db_session.execute(
1709 select(db.MusehubSymbolHistoryEntry).where(
1710 db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
1711 )
1712 )).scalar_one()
1713 assert row.op == "add"
1714 assert row.op_payload is None
1715
1716 @pytest.mark.asyncio
1717 async def test_add_becomes_insert_with_payload(
1718 self, db_session: AsyncSession
1719 ) -> None:
1720 from musehub.services.musehub_symbol_indexer import backfill_raw_ops_from_commits
1721
1722 repo = await create_repo(db_session, slug="bro-insert")
1723 commit = await self._seed_commit_with_meta(db_session, repo.repo_id, [
1724 {"address": "src/a.py::Fn", "op": "insert", "content_id": "sha256:aaa",
1725 "content_summary": "added function Fn", "position": 0},
1726 ])
1727 await self._seed_stale_entry(db_session, repo.repo_id, "src/a.py::Fn",
1728 commit.commit_id, "add", "sha256:aaa")
1729 await db_session.flush()
1730
1731 updated = await backfill_raw_ops_from_commits(db_session, repo_id=repo.repo_id)
1732 assert updated == 1
1733
1734 row = (await db_session.execute(
1735 select(db.MusehubSymbolHistoryEntry).where(
1736 db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
1737 )
1738 )).scalar_one()
1739 assert row.op == "insert"
1740 assert row.op_payload["content_summary"] == "added function Fn"
1741 assert row.op_payload["position"] == 0
1742 assert "op" not in row.op_payload
1743 assert "address" not in row.op_payload
1744
1745 @pytest.mark.asyncio
1746 async def test_modify_becomes_replace_with_payload(
1747 self, db_session: AsyncSession
1748 ) -> None:
1749 from musehub.services.musehub_symbol_indexer import backfill_raw_ops_from_commits
1750
1751 repo = await create_repo(db_session, slug="bro-replace")
1752 commit = await self._seed_commit_with_meta(db_session, repo.repo_id, [
1753 {"address": "src/b.py::Bar", "op": "replace",
1754 "old_content_id": "sha256:old", "new_content_id": "sha256:new",
1755 "old_summary": "Bar v1", "new_summary": "Bar v2", "position": None},
1756 ])
1757 await self._seed_stale_entry(db_session, repo.repo_id, "src/b.py::Bar",
1758 commit.commit_id, "modify", "sha256:new")
1759 await db_session.flush()
1760
1761 updated = await backfill_raw_ops_from_commits(db_session, repo_id=repo.repo_id)
1762 assert updated == 1
1763
1764 row = (await db_session.execute(
1765 select(db.MusehubSymbolHistoryEntry).where(
1766 db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
1767 )
1768 )).scalar_one()
1769 assert row.op == "replace"
1770 assert row.op_payload["old_content_id"] == "sha256:old"
1771 assert row.op_payload["new_content_id"] == "sha256:new"
1772 assert row.op_payload["old_summary"] == "Bar v1"
1773
1774 @pytest.mark.asyncio
1775 async def test_modify_becomes_patch_for_file_level(
1776 self, db_session: AsyncSession
1777 ) -> None:
1778 from musehub.services.musehub_symbol_indexer import backfill_raw_ops_from_commits
1779
1780 repo = await create_repo(db_session, slug="bro-patch")
1781 commit = await self._seed_commit_with_meta(db_session, repo.repo_id, [
1782 {"address": "src/c.py", "op": "patch", "child_domain": "python",
1783 "child_summary": "3 symbols changed",
1784 "child_ops": [
1785 {"address": "src/c.py::Cls", "op": "replace",
1786 "old_content_id": "sha256:o", "new_content_id": "sha256:n",
1787 "old_summary": "Cls v1", "new_summary": "Cls v2", "position": 0},
1788 ]},
1789 ])
1790 # Both file-level and symbol-level stale entries
1791 await self._seed_stale_entry(db_session, repo.repo_id, "src/c.py",
1792 commit.commit_id, "modify")
1793 await self._seed_stale_entry(db_session, repo.repo_id, "src/c.py::Cls",
1794 commit.commit_id, "modify", "sha256:n")
1795 await db_session.flush()
1796
1797 updated = await backfill_raw_ops_from_commits(db_session, repo_id=repo.repo_id)
1798 assert updated == 2
1799
1800 rows = {r.address: r for r in (await db_session.execute(
1801 select(db.MusehubSymbolHistoryEntry).where(
1802 db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
1803 )
1804 )).scalars().all()}
1805
1806 assert rows["src/c.py"].op == "patch"
1807 assert rows["src/c.py"].op_payload["child_summary"] == "3 symbols changed"
1808 assert "child_ops" not in rows["src/c.py"].op_payload
1809
1810 assert rows["src/c.py::Cls"].op == "replace"
1811 assert rows["src/c.py::Cls"].op_payload["old_content_id"] == "sha256:o"
1812
1813 @pytest.mark.asyncio
1814 async def test_already_correct_ops_not_touched(
1815 self, db_session: AsyncSession
1816 ) -> None:
1817 """delete and move are already correct raw values — must be skipped."""
1818 from musehub.services.musehub_symbol_indexer import backfill_raw_ops_from_commits
1819
1820 repo = await create_repo(db_session, slug="bro-skip")
1821 commit = await self._seed_commit_with_meta(db_session, repo.repo_id, [
1822 {"address": "src/d.py::Gone", "op": "delete",
1823 "content_id": "sha256:gone", "content_summary": "removed Gone", "position": 0},
1824 ])
1825 await self._seed_stale_entry(db_session, repo.repo_id, "src/d.py::Gone",
1826 commit.commit_id, "delete", "sha256:gone")
1827 await db_session.flush()
1828
1829 updated = await backfill_raw_ops_from_commits(db_session, repo_id=repo.repo_id)
1830 assert updated == 0
1831
1832 @pytest.mark.asyncio
1833 async def test_entry_missing_from_delta_left_alone(
1834 self, db_session: AsyncSession
1835 ) -> None:
1836 """If the delta has no matching address, the row is left untouched."""
1837 from musehub.services.musehub_symbol_indexer import backfill_raw_ops_from_commits
1838
1839 repo = await create_repo(db_session, slug="bro-missing")
1840 commit = await self._seed_commit_with_meta(db_session, repo.repo_id, [
1841 {"address": "src/other.py::X", "op": "insert",
1842 "content_id": "sha256:x", "content_summary": "added X", "position": 0},
1843 ])
1844 await self._seed_stale_entry(db_session, repo.repo_id, "src/ghost.py::Y",
1845 commit.commit_id, "add")
1846 await db_session.flush()
1847
1848 updated = await backfill_raw_ops_from_commits(db_session, repo_id=repo.repo_id)
1849 assert updated == 0
1850
1851 row = (await db_session.execute(
1852 select(db.MusehubSymbolHistoryEntry).where(
1853 db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
1854 )
1855 )).scalar_one()
1856 assert row.op == "add"
1857
1858 @pytest.mark.asyncio
1859 async def test_repo_id_none_fixes_all_repos(
1860 self, db_session: AsyncSession
1861 ) -> None:
1862 from musehub.services.musehub_symbol_indexer import backfill_raw_ops_from_commits
1863
1864 repo_a = await create_repo(db_session, slug="bro-all-a")
1865 repo_b = await create_repo(db_session, slug="bro-all-b")
1866 for repo in (repo_a, repo_b):
1867 commit = await self._seed_commit_with_meta(db_session, repo.repo_id, [
1868 {"address": "f.py::Fn", "op": "insert", "content_id": "sha256:x",
1869 "content_summary": "added Fn", "position": 0},
1870 ])
1871 await self._seed_stale_entry(db_session, repo.repo_id, "f.py::Fn",
1872 commit.commit_id, "add")
1873 await db_session.flush()
1874
1875 updated = await backfill_raw_ops_from_commits(db_session, repo_id=None)
1876 assert updated >= 2
1877
1878 @pytest.mark.asyncio
1879 async def test_idempotent(self, db_session: AsyncSession) -> None:
1880 from musehub.services.musehub_symbol_indexer import backfill_raw_ops_from_commits
1881
1882 repo = await create_repo(db_session, slug="bro-idem")
1883 commit = await self._seed_commit_with_meta(db_session, repo.repo_id, [
1884 {"address": "src/e.py::E", "op": "insert", "content_id": "sha256:e",
1885 "content_summary": "added E", "position": 0},
1886 ])
1887 await self._seed_stale_entry(db_session, repo.repo_id, "src/e.py::E",
1888 commit.commit_id, "add")
1889 await db_session.flush()
1890
1891 first = await backfill_raw_ops_from_commits(db_session, repo_id=repo.repo_id)
1892 assert first == 1
1893 second = await backfill_raw_ops_from_commits(db_session, repo_id=repo.repo_id)
1894 assert second == 0
1895
1896
1897 # ─────────────────────────────────────────────────────────────────────────────
1898 # Layer 2 — Snapshot-diff backfill
1899 # ─────────────────────────────────────────────────────────────────────────────
1900
1901
1902 import msgpack # type: ignore[import]
1903
1904
1905 async def _seed_commit_with_snapshot(
1906 session: AsyncSession,
1907 repo_id: str,
1908 commit_id: str,
1909 manifest: dict[str, str],
1910 parent_ids: list[str] | None = None,
1911 branch: str = "main",
1912 timestamp: datetime | None = None,
1913 ) -> db.MusehubCommit:
1914 """Seed a commit + snapshot row. manifest maps path → object_id.
1915
1916 Snapshot is content-addressed; two commits with identical manifests share
1917 one snapshot row (INSERT ... ON CONFLICT DO NOTHING).
1918 """
1919 from sqlalchemy.dialects.postgresql import insert as pg_insert
1920 snap_id = blob_id(msgpack.packb(sorted(manifest.items()), use_bin_type=True))
1921 await session.execute(
1922 pg_insert(db.MusehubSnapshot).values(
1923 snapshot_id=snap_id,
1924 repo_id=repo_id,
1925 directories=[],
1926 manifest_blob=msgpack.packb(manifest, use_bin_type=True),
1927 entry_count=len(manifest),
1928 created_at=timestamp or _now(),
1929 ).on_conflict_do_nothing(index_elements=["snapshot_id"])
1930 )
1931 commit = db.MusehubCommit(
1932 commit_id=commit_id,
1933 repo_id=repo_id,
1934 branch=branch,
1935 parent_ids=parent_ids or [],
1936 message="test",
1937 author="gabriel",
1938 timestamp=timestamp or _now(),
1939 snapshot_id=snap_id,
1940 )
1941 session.add(commit)
1942 await session.flush()
1943 return commit
1944
1945
1946 class TestBackfillHistoryFromSnapshots:
1947 """backfill_history_from_snapshots walks the commit graph, diffs adjacent
1948 snapshot manifests, and creates history entries for any address/commit pair
1949 not already covered by structured_delta indexing."""
1950
1951 @pytest.mark.asyncio
1952 async def test_genesis_commit_all_inserts(self, db_session: AsyncSession) -> None:
1953 """Every file in the first commit (no parent) is recorded as insert."""
1954 from musehub.services.musehub_symbol_indexer import backfill_history_from_snapshots
1955
1956 repo = await create_repo(db_session, slug="sdb-genesis")
1957 await _seed_commit_with_snapshot(db_session, repo.repo_id, "c1",
1958 {"a.py": "sha256:aaa", "b.py": "sha256:bbb"})
1959 await db_session.commit()
1960
1961 count = await backfill_history_from_snapshots(db_session, repo_id=repo.repo_id)
1962 assert count == 2
1963
1964 rows = (await db_session.execute(
1965 select(db.MusehubSymbolHistoryEntry)
1966 .where(db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id)
1967 )).scalars().all()
1968 ops = {r.address: r.op for r in rows}
1969 assert ops == {"a.py": "insert", "b.py": "insert"}
1970
1971 @pytest.mark.asyncio
1972 async def test_new_file_in_child_is_insert(self, db_session: AsyncSession) -> None:
1973 """A file present in commit N but absent from commit N-1 is an insert."""
1974 from musehub.services.musehub_symbol_indexer import backfill_history_from_snapshots
1975
1976 repo = await create_repo(db_session, slug="sdb-insert")
1977 t1 = datetime(2026, 1, 1, tzinfo=timezone.utc)
1978 t2 = datetime(2026, 1, 2, tzinfo=timezone.utc)
1979 await _seed_commit_with_snapshot(db_session, repo.repo_id, "c1",
1980 {"a.py": "sha256:aaa"}, timestamp=t1)
1981 await _seed_commit_with_snapshot(db_session, repo.repo_id, "c2",
1982 {"a.py": "sha256:aaa", "b.py": "sha256:bbb"},
1983 parent_ids=["c1"], timestamp=t2)
1984 await db_session.commit()
1985
1986 await backfill_history_from_snapshots(db_session, repo_id=repo.repo_id)
1987
1988 rows = (await db_session.execute(
1989 select(db.MusehubSymbolHistoryEntry)
1990 .where(db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
1991 db.MusehubSymbolHistoryEntry.commit_id == "c2")
1992 )).scalars().all()
1993 ops = {r.address: r.op for r in rows}
1994 assert "b.py" in ops
1995 assert ops["b.py"] == "insert"
1996 # a.py content unchanged — no entry needed for c2
1997 assert "a.py" not in ops
1998
1999 @pytest.mark.asyncio
2000 async def test_changed_content_is_replace(self, db_session: AsyncSession) -> None:
2001 """A file with a different object_id in the child commit is a replace."""
2002 from musehub.services.musehub_symbol_indexer import backfill_history_from_snapshots
2003
2004 repo = await create_repo(db_session, slug="sdb-replace")
2005 t1 = datetime(2026, 1, 1, tzinfo=timezone.utc)
2006 t2 = datetime(2026, 1, 2, tzinfo=timezone.utc)
2007 await _seed_commit_with_snapshot(db_session, repo.repo_id, "c1",
2008 {"a.py": "sha256:v1"}, timestamp=t1)
2009 await _seed_commit_with_snapshot(db_session, repo.repo_id, "c2",
2010 {"a.py": "sha256:v2"},
2011 parent_ids=["c1"], timestamp=t2)
2012 await db_session.commit()
2013
2014 await backfill_history_from_snapshots(db_session, repo_id=repo.repo_id)
2015
2016 rows = (await db_session.execute(
2017 select(db.MusehubSymbolHistoryEntry)
2018 .where(db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
2019 db.MusehubSymbolHistoryEntry.commit_id == "c2")
2020 )).scalars().all()
2021 assert len(rows) == 1
2022 assert rows[0].address == "a.py"
2023 assert rows[0].op == "replace"
2024 assert rows[0].content_id == "sha256:v2"
2025
2026 @pytest.mark.asyncio
2027 async def test_removed_file_is_delete(self, db_session: AsyncSession) -> None:
2028 """A file absent from the child but present in the parent is a delete."""
2029 from musehub.services.musehub_symbol_indexer import backfill_history_from_snapshots
2030
2031 repo = await create_repo(db_session, slug="sdb-delete")
2032 t1 = datetime(2026, 1, 1, tzinfo=timezone.utc)
2033 t2 = datetime(2026, 1, 2, tzinfo=timezone.utc)
2034 await _seed_commit_with_snapshot(db_session, repo.repo_id, "c1",
2035 {"a.py": "sha256:v1", "b.py": "sha256:vb"},
2036 timestamp=t1)
2037 await _seed_commit_with_snapshot(db_session, repo.repo_id, "c2",
2038 {"a.py": "sha256:v1"},
2039 parent_ids=["c1"], timestamp=t2)
2040 await db_session.commit()
2041
2042 await backfill_history_from_snapshots(db_session, repo_id=repo.repo_id)
2043
2044 rows = (await db_session.execute(
2045 select(db.MusehubSymbolHistoryEntry)
2046 .where(db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
2047 db.MusehubSymbolHistoryEntry.commit_id == "c2")
2048 )).scalars().all()
2049 ops = {r.address: r.op for r in rows}
2050 assert ops.get("b.py") == "delete"
2051 assert "a.py" not in ops # unchanged
2052
2053 @pytest.mark.asyncio
2054 async def test_unambiguous_rename_is_move(self, db_session: AsyncSession) -> None:
2055 """When exactly one file disappears and one appears with the same object_id,
2056 the appearance is recorded as move with from_address in op_payload."""
2057 from musehub.services.musehub_symbol_indexer import backfill_history_from_snapshots
2058
2059 repo = await create_repo(db_session, slug="sdb-move")
2060 t1 = datetime(2026, 1, 1, tzinfo=timezone.utc)
2061 t2 = datetime(2026, 1, 2, tzinfo=timezone.utc)
2062 await _seed_commit_with_snapshot(db_session, repo.repo_id, "c1",
2063 {"old.py": "sha256:content"}, timestamp=t1)
2064 await _seed_commit_with_snapshot(db_session, repo.repo_id, "c2",
2065 {"new.py": "sha256:content"},
2066 parent_ids=["c1"], timestamp=t2)
2067 await db_session.commit()
2068
2069 await backfill_history_from_snapshots(db_session, repo_id=repo.repo_id)
2070
2071 rows = (await db_session.execute(
2072 select(db.MusehubSymbolHistoryEntry)
2073 .where(db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
2074 db.MusehubSymbolHistoryEntry.commit_id == "c2")
2075 )).scalars().all()
2076 by_addr = {r.address: r for r in rows}
2077
2078 assert "new.py" in by_addr
2079 assert by_addr["new.py"].op == "move"
2080 assert (by_addr["new.py"].op_payload or {}).get("from_address") == "old.py"
2081 # old.py emits a delete with to_address pointing to new location
2082 assert "old.py" in by_addr
2083 assert by_addr["old.py"].op == "delete"
2084 assert (by_addr["old.py"].op_payload or {}).get("to_address") == "new.py"
2085
2086 @pytest.mark.asyncio
2087 async def test_ambiguous_rename_falls_back_to_insert_delete(
2088 self, db_session: AsyncSession
2089 ) -> None:
2090 """Same object_id disappears from two paths → ambiguous rename.
2091 Fall back: record inserts for new paths, deletes for old paths."""
2092 from musehub.services.musehub_symbol_indexer import backfill_history_from_snapshots
2093
2094 repo = await create_repo(db_session, slug="sdb-ambig")
2095 shared = "sha256:shared"
2096 t1 = datetime(2026, 1, 1, tzinfo=timezone.utc)
2097 t2 = datetime(2026, 1, 2, tzinfo=timezone.utc)
2098 await _seed_commit_with_snapshot(db_session, repo.repo_id, "c1",
2099 {"a.py": shared, "b.py": shared}, timestamp=t1)
2100 await _seed_commit_with_snapshot(db_session, repo.repo_id, "c2",
2101 {"c.py": shared},
2102 parent_ids=["c1"], timestamp=t2)
2103 await db_session.commit()
2104
2105 await backfill_history_from_snapshots(db_session, repo_id=repo.repo_id)
2106
2107 rows = (await db_session.execute(
2108 select(db.MusehubSymbolHistoryEntry)
2109 .where(db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
2110 db.MusehubSymbolHistoryEntry.commit_id == "c2")
2111 )).scalars().all()
2112 ops = {r.address: r.op for r in rows}
2113 # c.py cannot be a move — two candidates for origin
2114 assert ops.get("c.py") == "insert"
2115 assert ops.get("a.py") == "delete"
2116 assert ops.get("b.py") == "delete"
2117
2118 def test_diff_manifests_move_emits_delete_with_to_address(self) -> None:
2119 """_diff_manifests includes a delete tuple with to_address for move sources."""
2120 from musehub.services.musehub_symbol_indexer import _diff_manifests
2121
2122 parent = {"old.py": "sha256:content"}
2123 child = {"new.py": "sha256:content"}
2124 ops = _diff_manifests(parent, child)
2125
2126 by_addr = {addr: (op, extra) for addr, op, extra in ops}
2127 # move destination carries from_address
2128 assert by_addr["new.py"] == ("move", "old.py")
2129 # move source carries to_address (not None)
2130 assert "old.py" in by_addr
2131 assert by_addr["old.py"][0] == "delete"
2132 assert by_addr["old.py"][1] == "new.py" # to_address
2133
2134 def test_diff_manifests_ambiguous_delete_has_no_to_address(self) -> None:
2135 """Ambiguous renames fall back to plain delete (no to_address)."""
2136 from musehub.services.musehub_symbol_indexer import _diff_manifests
2137
2138 shared = "sha256:shared"
2139 parent = {"a.py": shared, "b.py": shared}
2140 child = {"c.py": shared}
2141 ops = _diff_manifests(parent, child)
2142
2143 by_addr = {addr: (op, extra) for addr, op, extra in ops}
2144 # c.py is an insert (ambiguous — two possible sources)
2145 assert by_addr["c.py"] == ("insert", None)
2146 # plain deletes: no to_address
2147 assert by_addr["a.py"] == ("delete", None)
2148 assert by_addr["b.py"] == ("delete", None)
2149
2150 @pytest.mark.asyncio
2151 async def test_move_delete_op_payload_has_to_address(
2152 self, db_session: AsyncSession
2153 ) -> None:
2154 """DELETE entry for a move-source path carries to_address in op_payload."""
2155 from musehub.services.musehub_symbol_indexer import backfill_history_from_snapshots
2156
2157 repo = await create_repo(db_session, slug="sdb-move-payload")
2158 t1 = datetime(2026, 1, 1, tzinfo=timezone.utc)
2159 t2 = datetime(2026, 1, 2, tzinfo=timezone.utc)
2160 await _seed_commit_with_snapshot(db_session, repo.repo_id, "c1",
2161 {"old.py": "sha256:content"}, timestamp=t1)
2162 await _seed_commit_with_snapshot(db_session, repo.repo_id, "c2",
2163 {"new.py": "sha256:content"},
2164 parent_ids=["c1"], timestamp=t2)
2165 await db_session.commit()
2166
2167 await backfill_history_from_snapshots(db_session, repo_id=repo.repo_id)
2168
2169 rows = (await db_session.execute(
2170 select(db.MusehubSymbolHistoryEntry)
2171 .where(
2172 db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
2173 db.MusehubSymbolHistoryEntry.address == "old.py",
2174 db.MusehubSymbolHistoryEntry.commit_id == "c2",
2175 )
2176 )).scalars().all()
2177 assert len(rows) == 1
2178 row = rows[0]
2179 assert row.op == "delete"
2180 assert (row.op_payload or {}).get("to_address") == "new.py"
2181 assert (row.op_payload or {}).get("inferred_from") == "snapshot_diff"
2182
2183 @pytest.mark.asyncio
2184 async def test_skips_addresses_already_covered_by_structured_delta(
2185 self, db_session: AsyncSession
2186 ) -> None:
2187 """Addresses that already have a history entry for the commit are not overwritten."""
2188 from musehub.services.musehub_symbol_indexer import backfill_history_from_snapshots
2189
2190 repo = await create_repo(db_session, slug="sdb-skip")
2191 t1 = datetime(2026, 1, 1, tzinfo=timezone.utc)
2192 await _seed_commit_with_snapshot(db_session, repo.repo_id, "c1",
2193 {"a.py": "sha256:v1"}, timestamp=t1)
2194 # Pre-existing entry from structured_delta (e.g. 'patch' — richer semantics)
2195 session_entry = db.MusehubSymbolHistoryEntry(
2196 repo_id=repo.repo_id, address="a.py", commit_id="c1",
2197 op="patch", op_payload={"from_address": "old/a.py"},
2198 content_id="sha256:v1", committed_at=t1, author="gabriel",
2199 )
2200 db_session.add(session_entry)
2201 await db_session.commit()
2202
2203 count = await backfill_history_from_snapshots(db_session, repo_id=repo.repo_id)
2204
2205 assert count == 0 # nothing to do
2206 rows = (await db_session.execute(
2207 select(db.MusehubSymbolHistoryEntry)
2208 .where(db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id)
2209 )).scalars().all()
2210 assert len(rows) == 1
2211 assert rows[0].op == "patch" # original preserved
2212
2213 @pytest.mark.asyncio
2214 async def test_unchanged_files_produce_no_entries(
2215 self, db_session: AsyncSession
2216 ) -> None:
2217 """Files with identical object_ids across parent and child produce no entry."""
2218 from musehub.services.musehub_symbol_indexer import backfill_history_from_snapshots
2219
2220 repo = await create_repo(db_session, slug="sdb-nochange")
2221 t1 = datetime(2026, 1, 1, tzinfo=timezone.utc)
2222 t2 = datetime(2026, 1, 2, tzinfo=timezone.utc)
2223 await _seed_commit_with_snapshot(db_session, repo.repo_id, "c1",
2224 {"a.py": "sha256:same", "b.py": "sha256:same2"},
2225 timestamp=t1)
2226 await _seed_commit_with_snapshot(db_session, repo.repo_id, "c2",
2227 {"a.py": "sha256:same", "b.py": "sha256:same2"},
2228 parent_ids=["c1"], timestamp=t2)
2229 await db_session.commit()
2230
2231 await backfill_history_from_snapshots(db_session, repo_id=repo.repo_id)
2232
2233 c2_rows = (await db_session.execute(
2234 select(db.MusehubSymbolHistoryEntry)
2235 .where(db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
2236 db.MusehubSymbolHistoryEntry.commit_id == "c2")
2237 )).scalars().all()
2238 assert c2_rows == []
2239
2240 @pytest.mark.asyncio
2241 async def test_dry_run_returns_count_without_writing(
2242 self, db_session: AsyncSession
2243 ) -> None:
2244 from musehub.services.musehub_symbol_indexer import backfill_history_from_snapshots
2245
2246 repo = await create_repo(db_session, slug="sdb-dry")
2247 await _seed_commit_with_snapshot(db_session, repo.repo_id, "c1",
2248 {"a.py": "sha256:v1", "b.py": "sha256:v2"})
2249 await db_session.commit()
2250
2251 count = await backfill_history_from_snapshots(
2252 db_session, repo_id=repo.repo_id, dry_run=True
2253 )
2254 assert count == 2
2255
2256 existing = (await db_session.execute(
2257 select(db.MusehubSymbolHistoryEntry)
2258 .where(db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id)
2259 )).scalars().all()
2260 assert existing == []
2261
2262 @pytest.mark.asyncio
2263 async def test_idempotent(self, db_session: AsyncSession) -> None:
2264 from musehub.services.musehub_symbol_indexer import backfill_history_from_snapshots
2265
2266 repo = await create_repo(db_session, slug="sdb-idem")
2267 await _seed_commit_with_snapshot(db_session, repo.repo_id, "c1",
2268 {"a.py": "sha256:v1"})
2269 await db_session.commit()
2270
2271 first = await backfill_history_from_snapshots(db_session, repo_id=repo.repo_id)
2272 await db_session.commit()
2273 second = await backfill_history_from_snapshots(db_session, repo_id=repo.repo_id)
2274 assert first == 1
2275 assert second == 0
2276
2277 @pytest.mark.asyncio
2278 async def test_repo_id_filter(self, db_session: AsyncSession) -> None:
2279 from musehub.services.musehub_symbol_indexer import backfill_history_from_snapshots
2280
2281 repo_a = await create_repo(db_session, slug="sdb-filter-a")
2282 repo_b = await create_repo(db_session, slug="sdb-filter-b")
2283 await _seed_commit_with_snapshot(db_session, repo_a.repo_id, "ca1",
2284 {"a.py": "sha256:a"})
2285 await _seed_commit_with_snapshot(db_session, repo_b.repo_id, "cb1",
2286 {"b.py": "sha256:b"})
2287 await db_session.commit()
2288
2289 count = await backfill_history_from_snapshots(db_session, repo_id=repo_a.repo_id)
2290 assert count == 1
2291
2292 a_rows = (await db_session.execute(
2293 select(db.MusehubSymbolHistoryEntry)
2294 .where(db.MusehubSymbolHistoryEntry.repo_id == repo_a.repo_id)
2295 )).scalars().all()
2296 b_rows = (await db_session.execute(
2297 select(db.MusehubSymbolHistoryEntry)
2298 .where(db.MusehubSymbolHistoryEntry.repo_id == repo_b.repo_id)
2299 )).scalars().all()
2300 assert len(a_rows) == 1
2301 assert len(b_rows) == 0
2302
2303 @pytest.mark.asyncio
2304 async def test_inferred_op_payload_marks_source(
2305 self, db_session: AsyncSession
2306 ) -> None:
2307 """Entries created by snapshot-diff carry inferred_from='snapshot_diff'
2308 in op_payload so callers can distinguish them from structured_delta entries."""
2309 from musehub.services.musehub_symbol_indexer import backfill_history_from_snapshots
2310
2311 repo = await create_repo(db_session, slug="sdb-mark")
2312 await _seed_commit_with_snapshot(db_session, repo.repo_id, "c1",
2313 {"a.py": "sha256:v1"})
2314 await db_session.commit()
2315
2316 await backfill_history_from_snapshots(db_session, repo_id=repo.repo_id)
2317
2318 rows = (await db_session.execute(
2319 select(db.MusehubSymbolHistoryEntry)
2320 .where(db.MusehubSymbolHistoryEntry.repo_id == repo.repo_id)
2321 )).scalars().all()
2322 assert len(rows) == 1
2323 assert (rows[0].op_payload or {}).get("inferred_from") == "snapshot_diff"
2324
2325
2326 # ─────────────────────────────────────────────────────────────────────────────
2327 # Layer 3 — Lineage walk: load_symbol_history follows from_address chains
2328 # ─────────────────────────────────────────────────────────────────────────────
2329
2330
2331 async def _seed_history_entry(
2332 session: AsyncSession,
2333 repo_id: str,
2334 address: str,
2335 commit_id: str,
2336 op: str,
2337 op_payload: JSONObject | None = None,
2338 content_id: str | None = None,
2339 committed_at: datetime | None = None,
2340 ) -> db.MusehubSymbolHistoryEntry:
2341 """Write a single history row directly (bypasses the indexer)."""
2342 row = db.MusehubSymbolHistoryEntry(
2343 repo_id=repo_id,
2344 address=address,
2345 commit_id=commit_id,
2346 op=op,
2347 op_payload=op_payload or {},
2348 content_id=content_id,
2349 committed_at=committed_at or _now(),
2350 author="gabriel",
2351 )
2352 session.add(row)
2353 await session.flush()
2354 return row
2355
2356
2357 class TestLoadSymbolHistoryLineage:
2358 """load_symbol_history follows from_address chains in op_payload to build
2359 full symbol lineage across renames and moves."""
2360
2361 @pytest.mark.asyncio
2362 async def test_no_from_address_unchanged(self, db_session: AsyncSession) -> None:
2363 """A symbol with no move history is returned as-is."""
2364 from musehub.services.musehub_symbol_indexer import load_symbol_history
2365
2366 repo = await create_repo(db_session, slug="lin-noop")
2367 await _seed_history_entry(db_session, repo.repo_id, "src/a.py::Foo", "c1",
2368 "insert", content_id="sha256:v1")
2369 await _seed_history_entry(db_session, repo.repo_id, "src/a.py::Foo", "c2",
2370 "replace", content_id="sha256:v2")
2371 await db_session.commit()
2372
2373 history = await load_symbol_history(db_session, repo.repo_id)
2374 assert "src/a.py::Foo" in history
2375 assert len(history["src/a.py::Foo"]) == 2
2376 assert history["src/a.py::Foo"][0]["op"] == "insert"
2377
2378 @pytest.mark.asyncio
2379 async def test_single_rename_prepends_origin_history(
2380 self, db_session: AsyncSession
2381 ) -> None:
2382 """History for new.py::Foo should include the insert at old.py::Foo."""
2383 from musehub.services.musehub_symbol_indexer import load_symbol_history
2384
2385 repo = await create_repo(db_session, slug="lin-single")
2386 # old.py::Foo was inserted, then modified
2387 await _seed_history_entry(db_session, repo.repo_id, "old.py::Foo", "c1",
2388 "insert", content_id="sha256:v1")
2389 await _seed_history_entry(db_session, repo.repo_id, "old.py::Foo", "c2",
2390 "replace", content_id="sha256:v2")
2391 # new.py::Foo was born via a move from old.py::Foo
2392 await _seed_history_entry(
2393 db_session, repo.repo_id, "new.py::Foo", "c3", "move",
2394 op_payload={"from_address": "old.py::Foo"},
2395 content_id="sha256:v2",
2396 )
2397 await db_session.commit()
2398
2399 history = await load_symbol_history(db_session, repo.repo_id)
2400
2401 # The new address should have the full chain: insert → replace → move
2402 assert "new.py::Foo" in history
2403 ops = [e["op"] for e in history["new.py::Foo"]]
2404 assert ops[0] == "insert", f"Expected insert first, got: {ops}"
2405 assert ops[-1] == "move", f"Expected move last, got: {ops}"
2406 assert len(ops) == 3
2407
2408 @pytest.mark.asyncio
2409 async def test_origin_address_excluded_from_top_level_keys(
2410 self, db_session: AsyncSession
2411 ) -> None:
2412 """After a rename, the old address should not appear as a top-level key."""
2413 from musehub.services.musehub_symbol_indexer import load_symbol_history
2414
2415 repo = await create_repo(db_session, slug="lin-noold")
2416 await _seed_history_entry(db_session, repo.repo_id, "old.py::Foo", "c1",
2417 "insert", content_id="sha256:v1")
2418 await _seed_history_entry(
2419 db_session, repo.repo_id, "new.py::Foo", "c2", "move",
2420 op_payload={"from_address": "old.py::Foo"},
2421 content_id="sha256:v1",
2422 )
2423 await db_session.commit()
2424
2425 history = await load_symbol_history(db_session, repo.repo_id)
2426
2427 assert "old.py::Foo" not in history, (
2428 "Origin address should be folded into new.py::Foo's lineage, "
2429 "not kept as a separate top-level key"
2430 )
2431
2432 @pytest.mark.asyncio
2433 async def test_multi_hop_rename_walks_full_chain(
2434 self, db_session: AsyncSession
2435 ) -> None:
2436 """A→B→C chain: history for C includes all entries from A, B, and C."""
2437 from musehub.services.musehub_symbol_indexer import load_symbol_history
2438
2439 repo = await create_repo(db_session, slug="lin-multi")
2440 await _seed_history_entry(db_session, repo.repo_id, "a.py::Fn", "c1",
2441 "insert", content_id="sha256:v1")
2442 await _seed_history_entry(
2443 db_session, repo.repo_id, "b.py::Fn", "c2", "move",
2444 op_payload={"from_address": "a.py::Fn"},
2445 content_id="sha256:v1",
2446 )
2447 await _seed_history_entry(
2448 db_session, repo.repo_id, "c.py::Fn", "c3", "move",
2449 op_payload={"from_address": "b.py::Fn"},
2450 content_id="sha256:v1",
2451 )
2452 await db_session.commit()
2453
2454 history = await load_symbol_history(db_session, repo.repo_id)
2455
2456 assert "c.py::Fn" in history
2457 assert "b.py::Fn" not in history
2458 assert "a.py::Fn" not in history
2459 ops = [e["op"] for e in history["c.py::Fn"]]
2460 assert ops[0] == "insert"
2461 assert ops[-1] == "move"
2462 assert len(ops) == 3
2463
2464 @pytest.mark.asyncio
2465 async def test_lineage_walk_is_bounded_on_missing_origin(
2466 self, db_session: AsyncSession
2467 ) -> None:
2468 """If from_address has no rows, lineage walk stops gracefully."""
2469 from musehub.services.musehub_symbol_indexer import load_symbol_history
2470
2471 repo = await create_repo(db_session, slug="lin-bound")
2472 # new.py::Foo claims to have come from ghost.py::Foo, which has no rows
2473 await _seed_history_entry(
2474 db_session, repo.repo_id, "new.py::Foo", "c1", "move",
2475 op_payload={"from_address": "ghost.py::Foo"},
2476 content_id="sha256:v1",
2477 )
2478 await db_session.commit()
2479
2480 # Must not raise, must not loop
2481 history = await load_symbol_history(db_session, repo.repo_id)
2482 assert "new.py::Foo" in history
2483 assert len(history["new.py::Foo"]) == 1
2484
2485 @pytest.mark.asyncio
2486 async def test_file_path_filter_includes_lineage(
2487 self, db_session: AsyncSession
2488 ) -> None:
2489 """file_path filter on new.py returns the full lineage including old.py origin."""
2490 from musehub.services.musehub_symbol_indexer import load_symbol_history
2491
2492 repo = await create_repo(db_session, slug="lin-filter")
2493 await _seed_history_entry(db_session, repo.repo_id, "old.py::Foo", "c1",
2494 "insert", content_id="sha256:v1")
2495 await _seed_history_entry(
2496 db_session, repo.repo_id, "new.py::Foo", "c2", "move",
2497 op_payload={"from_address": "old.py::Foo"},
2498 content_id="sha256:v1",
2499 )
2500 # unrelated symbol in another file
2501 await _seed_history_entry(db_session, repo.repo_id, "other.py::Bar", "c3",
2502 "insert", content_id="sha256:vx")
2503 await db_session.commit()
2504
2505 history = await load_symbol_history(db_session, repo.repo_id, file_path="new.py")
2506
2507 assert "new.py::Foo" in history
2508 assert "other.py::Bar" not in history
2509 ops = [e["op"] for e in history["new.py::Foo"]]
2510 assert ops[0] == "insert"
2511
2512 @pytest.mark.asyncio
2513 async def test_lineage_entries_carry_original_address(
2514 self, db_session: AsyncSession
2515 ) -> None:
2516 """Each entry in the merged lineage carries its original address so the
2517 UI can show where the symbol lived at that point in time."""
2518 from musehub.services.musehub_symbol_indexer import load_symbol_history
2519
2520 repo = await create_repo(db_session, slug="lin-addr")
2521 await _seed_history_entry(db_session, repo.repo_id, "old.py::Foo", "c1",
2522 "insert", content_id="sha256:v1")
2523 await _seed_history_entry(
2524 db_session, repo.repo_id, "new.py::Foo", "c2", "move",
2525 op_payload={"from_address": "old.py::Foo"},
2526 content_id="sha256:v1",
2527 )
2528 await db_session.commit()
2529
2530 history = await load_symbol_history(db_session, repo.repo_id)
2531 entries = history["new.py::Foo"]
2532
2533 insert_entries = [e for e in entries if e["op"] == "insert"]
2534 assert insert_entries, "Expected at least one insert entry in lineage"
2535 assert insert_entries[0].get("address") == "old.py::Foo", (
2536 "Lineage entries must carry their original address for UI rendering"
2537 )
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 122 days ago