gabriel / musehub public
test_symbol_detail_fast_reads.py python
1,011 lines 38.8 KB
Raw
sha256:94ef169c149a452bff7c604ded8b280b19bd477c2dabcb56972780b0b784c7aa Merge 'fix/assignee-sigil-inline' into 'dev' — proposal: As… Human 1 day ago
1 """TDD spec — fast symbol detail reads via push-time pre-computation.
2
3 Problem
4 ───────
5 The symbol detail page takes 2-3 s because it computes expensive data at
6 request time: loading all history rows for an entire file, joining commit
7 metadata for every entry, and running a large GROUP BY coupling query.
8
9 Solution
10 ────────
11 At push time a background job populates three structures:
12
13 1. ``MusehubSymbolHistoryEntry`` — add ``message`` + ``commit_branch``
14 columns so the timeline read needs no join to ``MusehubCommit``.
15
16 2. ``MusehubSymbolVitals`` — new table: one pre-computed row per
17 symbol with first_introduced, change_count, version_count, op breakdown.
18 Eliminates loading the full history to derive vitals at request time.
19
20 3. ``MusehubSymbolCoupling`` — new table: one row per (symbol, co_symbol)
21 pair with shared_commits count. Replaces the request-time
22 GROUP BY across a large commit_id IN (...) set.
23
24 At page load the route becomes:
25 SELECT * FROM musehub_symbol_history_entries WHERE repo_id=X AND address=Y
26 SELECT * FROM musehub_symbol_vitals WHERE repo_id=X AND address=Y
27 SELECT * FROM musehub_symbol_intel WHERE repo_id=X AND address=Y
28 SELECT * FROM musehub_symbol_coupling WHERE repo_id=X AND address=Y LIMIT N OFFSET M
29 + 4 small per-table intel reads (type, blast_risk, dead, api)
30
31 Tier breakdown
32 ──────────────
33 D1xx Schema — new columns / tables exist and have correct types
34 D2xx Indexer — background job populates data correctly at push time
35 D3xx Route — detail page uses pre-computed data (no heavy queries)
36 D4xx Edge — empty history, single entry, rename/lineage, rebuild idempotency
37 """
38 from __future__ import annotations
39
40 import datetime as _dt
41 import secrets
42 from datetime import timezone
43
44 import pytest
45 from httpx import AsyncClient
46 from sqlalchemy import select, text
47 from sqlalchemy.ext.asyncio import AsyncSession
48
49 from musehub.db.musehub_intel_models import MusehubSymbolCoupling, MusehubSymbolHistoryEntry, MusehubSymbolVitals
50 from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef
51 from muse.core.types import blob_id, long_id
52 from tests.factories import create_repo
53
54
55 # ---------------------------------------------------------------------------
56 # Helpers
57 # ---------------------------------------------------------------------------
58
59 def _now(offset_days: int = 0) -> _dt.datetime:
60 return _dt.datetime.now(tz=timezone.utc) + _dt.timedelta(days=offset_days)
61
62
63 def _cid() -> str:
64 return blob_id(secrets.token_bytes(32))
65
66
67 def _lid() -> str:
68 return long_id(secrets.token_hex(32))
69
70
71 async def _insert_history(
72 session: AsyncSession,
73 repo_id: str,
74 address: str,
75 commit_id: str,
76 op: str = "insert",
77 content_id: str | None = None,
78 message: str = "feat: test",
79 commit_branch: str = "dev",
80 committed_at: _dt.datetime | None = None,
81 author: str = "gabriel",
82 ) -> MusehubSymbolHistoryEntry:
83 entry = MusehubSymbolHistoryEntry(
84 repo_id=repo_id,
85 address=address,
86 commit_id=commit_id,
87 op=op,
88 content_id=content_id or _cid(),
89 message=message,
90 commit_branch=commit_branch,
91 committed_at=committed_at or _now(),
92 author=author,
93 )
94 session.add(entry)
95 await session.flush()
96 return entry
97
98
99 async def _insert_vitals(
100 session: AsyncSession,
101 repo_id: str,
102 address: str,
103 *,
104 first_introduced: _dt.datetime | None = None,
105 change_count: int = 1,
106 version_count: int = 1,
107 op_add: int = 1,
108 op_modify: int = 0,
109 op_delete: int = 0,
110 op_move: int = 0,
111 ) -> "MusehubSymbolVitals":
112 row = MusehubSymbolVitals(
113 repo_id=repo_id,
114 address=address,
115 first_introduced=first_introduced or _now(),
116 change_count=change_count,
117 version_count=version_count,
118 op_add=op_add,
119 op_modify=op_modify,
120 op_delete=op_delete,
121 op_move=op_move,
122 )
123 session.add(row)
124 await session.flush()
125 return row
126
127
128 async def _insert_coupling(
129 session: AsyncSession,
130 repo_id: str,
131 address: str,
132 co_address: str,
133 shared_commits: int,
134 ) -> "MusehubSymbolCoupling":
135 row = MusehubSymbolCoupling(
136 repo_id=repo_id,
137 address=address,
138 co_address=co_address,
139 shared_commits=shared_commits,
140 )
141 session.add(row)
142 await session.flush()
143 return row
144
145
146 # ---------------------------------------------------------------------------
147 # D1xx — Schema: new columns / tables exist
148 # ---------------------------------------------------------------------------
149
150 class TestHistoryEntrySchema:
151 """D101–D103: MusehubSymbolHistoryEntry has message + commit_branch."""
152
153 async def test_D101_message_column_exists(self, db_session: AsyncSession) -> None:
154 """D101: MusehubSymbolHistoryEntry.message column is present."""
155 repo = await create_repo(db_session, owner="gabriel")
156 cid = _lid()
157 entry = MusehubSymbolHistoryEntry(
158 repo_id=repo.repo_id,
159 address="src/foo.py::bar",
160 commit_id=cid,
161 op="insert",
162 content_id=_cid(),
163 message="feat: add bar",
164 commit_branch="dev",
165 committed_at=_now(),
166 )
167 db_session.add(entry)
168 await db_session.flush()
169 fetched = (await db_session.execute(
170 select(MusehubSymbolHistoryEntry).where(
171 MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
172 MusehubSymbolHistoryEntry.address == "src/foo.py::bar",
173 )
174 )).scalar_one()
175 assert fetched.message == "feat: add bar"
176
177 async def test_D102_commit_branch_column_exists(self, db_session: AsyncSession) -> None:
178 """D102: MusehubSymbolHistoryEntry.commit_branch column is present."""
179 repo = await create_repo(db_session, owner="gabriel")
180 cid = _lid()
181 entry = MusehubSymbolHistoryEntry(
182 repo_id=repo.repo_id,
183 address="src/foo.py::bar",
184 commit_id=cid,
185 op="insert",
186 content_id=_cid(),
187 message="feat: x",
188 commit_branch="feat/my-thing",
189 committed_at=_now(),
190 )
191 db_session.add(entry)
192 await db_session.flush()
193 fetched = (await db_session.execute(
194 select(MusehubSymbolHistoryEntry).where(
195 MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
196 MusehubSymbolHistoryEntry.address == "src/foo.py::bar",
197 )
198 )).scalar_one()
199 assert fetched.commit_branch == "feat/my-thing"
200
201 async def test_D103_message_and_branch_nullable(self, db_session: AsyncSession) -> None:
202 """D103: message and commit_branch accept None (backward compat for old rows)."""
203 repo = await create_repo(db_session, owner="gabriel")
204 entry = MusehubSymbolHistoryEntry(
205 repo_id=repo.repo_id,
206 address="src/foo.py::baz",
207 commit_id=_lid(),
208 op="insert",
209 content_id=_cid(),
210 message=None,
211 commit_branch=None,
212 committed_at=_now(),
213 )
214 db_session.add(entry)
215 await db_session.flush()
216 fetched = (await db_session.execute(
217 select(MusehubSymbolHistoryEntry).where(
218 MusehubSymbolHistoryEntry.address == "src/foo.py::baz",
219 )
220 )).scalar_one()
221 assert fetched.message is None
222 assert fetched.commit_branch is None
223
224
225 class TestSymbolVitalsSchema:
226 """D110–D116: MusehubSymbolVitals table exists with correct columns."""
227
228 async def test_D110_table_exists(self, db_session: AsyncSession) -> None:
229 """D110: MusehubSymbolVitals ORM class is importable and maps to a table."""
230 assert MusehubSymbolVitals is not None
231
232 async def test_D111_insert_and_fetch(self, db_session: AsyncSession) -> None:
233 """D111: can insert and retrieve a vitals row."""
234 repo = await create_repo(db_session, owner="gabriel")
235 introduced = _now(offset_days=-30)
236 row = await _insert_vitals(
237 db_session, repo.repo_id, "src/core.py::process",
238 first_introduced=introduced,
239 change_count=42,
240 version_count=7,
241 op_add=1,
242 op_modify=40,
243 op_delete=0,
244 op_move=1,
245 )
246 fetched = (await db_session.execute(
247 select(MusehubSymbolVitals).where(
248 MusehubSymbolVitals.repo_id == repo.repo_id,
249 MusehubSymbolVitals.address == "src/core.py::process",
250 )
251 )).scalar_one()
252 assert fetched.change_count == 42
253 assert fetched.version_count == 7
254 assert fetched.op_add == 1
255 assert fetched.op_modify == 40
256 assert fetched.op_delete == 0
257 assert fetched.op_move == 1
258
259 async def test_D112_first_introduced_is_datetime(self, db_session: AsyncSession) -> None:
260 """D112: first_introduced stores a timezone-aware datetime."""
261 repo = await create_repo(db_session, owner="gabriel")
262 ts = _now(offset_days=-10)
263 await _insert_vitals(db_session, repo.repo_id, "src/a.py::fn", first_introduced=ts)
264 fetched = (await db_session.execute(
265 select(MusehubSymbolVitals).where(
266 MusehubSymbolVitals.repo_id == repo.repo_id,
267 MusehubSymbolVitals.address == "src/a.py::fn",
268 )
269 )).scalar_one()
270 assert fetched.first_introduced is not None
271 assert fetched.first_introduced.tzinfo is not None
272
273 async def test_D113_primary_key_is_repo_and_address(self, db_session: AsyncSession) -> None:
274 """D113: upsert on (repo_id, address) replaces the row."""
275 repo = await create_repo(db_session, owner="gabriel")
276 await _insert_vitals(db_session, repo.repo_id, "src/a.py::fn", change_count=5)
277 await db_session.commit()
278
279 # Re-insert with updated count
280 row2 = MusehubSymbolVitals(
281 repo_id=repo.repo_id,
282 address="src/a.py::fn",
283 first_introduced=_now(),
284 change_count=10,
285 version_count=2,
286 op_add=1, op_modify=9, op_delete=0, op_move=0,
287 )
288 from sqlalchemy.dialects.postgresql import insert as pg_insert
289 stmt = pg_insert(MusehubSymbolVitals).values(
290 repo_id=row2.repo_id,
291 address=row2.address,
292 first_introduced=row2.first_introduced,
293 change_count=row2.change_count,
294 version_count=row2.version_count,
295 op_add=row2.op_add,
296 op_modify=row2.op_modify,
297 op_delete=row2.op_delete,
298 op_move=row2.op_move,
299 ).on_conflict_do_update(
300 index_elements=["repo_id", "address"],
301 set_={"change_count": row2.change_count, "version_count": row2.version_count},
302 )
303 await db_session.execute(stmt)
304 await db_session.commit()
305
306 rows = (await db_session.execute(
307 select(MusehubSymbolVitals).where(
308 MusehubSymbolVitals.repo_id == repo.repo_id,
309 MusehubSymbolVitals.address == "src/a.py::fn",
310 )
311 )).scalars().all()
312 assert len(rows) == 1
313 assert rows[0].change_count == 10
314
315 async def test_D114_cascade_delete_with_repo(self, db_session: AsyncSession) -> None:
316 """D114: vitals rows are deleted when the repo is deleted."""
317 repo = await create_repo(db_session, owner="gabriel")
318 await _insert_vitals(db_session, repo.repo_id, "src/a.py::fn")
319 await db_session.commit()
320 await db_session.delete(repo)
321 await db_session.commit()
322 rows = (await db_session.execute(
323 select(MusehubSymbolVitals).where(
324 MusehubSymbolVitals.repo_id == repo.repo_id,
325 )
326 )).scalars().all()
327 assert rows == []
328
329
330 class TestSymbolCouplingSchema:
331 """D120–D126: MusehubSymbolCoupling table exists with correct columns."""
332
333 async def test_D120_table_exists(self, db_session: AsyncSession) -> None:
334 """D120: MusehubSymbolCoupling ORM class is importable."""
335 assert MusehubSymbolCoupling is not None
336
337 async def test_D121_insert_and_fetch(self, db_session: AsyncSession) -> None:
338 """D121: can insert and retrieve a coupling row."""
339 repo = await create_repo(db_session, owner="gabriel")
340 row = await _insert_coupling(
341 db_session, repo.repo_id,
342 "src/a.py::fn", "src/b.py::helper", shared_commits=12,
343 )
344 fetched = (await db_session.execute(
345 select(MusehubSymbolCoupling).where(
346 MusehubSymbolCoupling.repo_id == repo.repo_id,
347 MusehubSymbolCoupling.address == "src/a.py::fn",
348 )
349 )).scalar_one()
350 assert fetched.co_address == "src/b.py::helper"
351 assert fetched.shared_commits == 12
352
353 async def test_D122_ordered_by_shared_commits_desc(self, db_session: AsyncSession) -> None:
354 """D122: coupling rows can be fetched ordered by shared_commits descending."""
355 repo = await create_repo(db_session, owner="gabriel")
356 await _insert_coupling(db_session, repo.repo_id, "src/a.py::fn", "src/b.py::b", 3)
357 await _insert_coupling(db_session, repo.repo_id, "src/a.py::fn", "src/c.py::c", 10)
358 await _insert_coupling(db_session, repo.repo_id, "src/a.py::fn", "src/d.py::d", 7)
359 await db_session.flush()
360
361 rows = (await db_session.execute(
362 select(MusehubSymbolCoupling)
363 .where(
364 MusehubSymbolCoupling.repo_id == repo.repo_id,
365 MusehubSymbolCoupling.address == "src/a.py::fn",
366 )
367 .order_by(MusehubSymbolCoupling.shared_commits.desc())
368 )).scalars().all()
369 counts = [r.shared_commits for r in rows]
370 assert counts == sorted(counts, reverse=True)
371 assert counts[0] == 10
372
373 async def test_D123_pagination_with_limit_offset(self, db_session: AsyncSession) -> None:
374 """D123: coupling supports limit/offset for cursor pagination."""
375 repo = await create_repo(db_session, owner="gabriel")
376 for i in range(20):
377 await _insert_coupling(
378 db_session, repo.repo_id,
379 "src/a.py::fn", f"src/x{i}.py::sym", shared_commits=20 - i,
380 )
381 await db_session.flush()
382
383 page1 = (await db_session.execute(
384 select(MusehubSymbolCoupling)
385 .where(
386 MusehubSymbolCoupling.repo_id == repo.repo_id,
387 MusehubSymbolCoupling.address == "src/a.py::fn",
388 )
389 .order_by(MusehubSymbolCoupling.shared_commits.desc())
390 .limit(15).offset(0)
391 )).scalars().all()
392 page2 = (await db_session.execute(
393 select(MusehubSymbolCoupling)
394 .where(
395 MusehubSymbolCoupling.repo_id == repo.repo_id,
396 MusehubSymbolCoupling.address == "src/a.py::fn",
397 )
398 .order_by(MusehubSymbolCoupling.shared_commits.desc())
399 .limit(15).offset(15)
400 )).scalars().all()
401
402 assert len(page1) == 15
403 assert len(page2) == 5
404 all_addrs = {r.co_address for r in page1} | {r.co_address for r in page2}
405 assert len(all_addrs) == 20
406
407 async def test_D124_cascade_delete_with_repo(self, db_session: AsyncSession) -> None:
408 """D124: coupling rows are deleted when the repo is deleted."""
409 repo = await create_repo(db_session, owner="gabriel")
410 await _insert_coupling(db_session, repo.repo_id, "src/a.py::fn", "src/b.py::g", 5)
411 await db_session.commit()
412 await db_session.delete(repo)
413 await db_session.commit()
414 rows = (await db_session.execute(
415 select(MusehubSymbolCoupling).where(
416 MusehubSymbolCoupling.repo_id == repo.repo_id,
417 )
418 )).scalars().all()
419 assert rows == []
420
421 async def test_D125_primary_key_is_repo_address_co_address(self, db_session: AsyncSession) -> None:
422 """D125: (repo_id, address, co_address) is the primary key — no duplicates."""
423 repo = await create_repo(db_session, owner="gabriel")
424 await _insert_coupling(db_session, repo.repo_id, "src/a.py::fn", "src/b.py::g", 5)
425 await db_session.commit()
426
427 from sqlalchemy.dialects.postgresql import insert as pg_insert
428 stmt = pg_insert(MusehubSymbolCoupling).values(
429 repo_id=repo.repo_id,
430 address="src/a.py::fn",
431 co_address="src/b.py::g",
432 shared_commits=99,
433 ).on_conflict_do_update(
434 index_elements=["repo_id", "address", "co_address"],
435 set_={"shared_commits": 99},
436 )
437 await db_session.execute(stmt)
438 await db_session.commit()
439
440 rows = (await db_session.execute(
441 select(MusehubSymbolCoupling).where(
442 MusehubSymbolCoupling.repo_id == repo.repo_id,
443 MusehubSymbolCoupling.address == "src/a.py::fn",
444 )
445 )).scalars().all()
446 assert len(rows) == 1
447 assert rows[0].shared_commits == 99
448
449
450 # ---------------------------------------------------------------------------
451 # D2xx — Indexer: background job populates data at push time
452 # ---------------------------------------------------------------------------
453
454 class TestIndexerPopulatesVitals:
455 """D201–D207: build_symbol_index writes MusehubSymbolVitals rows."""
456
457 async def test_D201_vitals_written_after_index_build(self, db_session: AsyncSession) -> None:
458 """D201: after build_symbol_index, a vitals row exists for each indexed symbol."""
459 from musehub.services.musehub_symbol_indexer import build_symbol_index
460
461 repo = await create_repo(db_session, owner="gabriel")
462 cid = _lid()
463 commit = MusehubCommit(
464 commit_id=cid,
465 branch="dev",
466 parent_ids=[],
467 message="feat: add fn",
468 author="gabriel",
469 timestamp=_now(),
470 structured_delta={"ops": [
471 {"address": "src/core.py::process", "op": "insert", "content_id": _cid()},
472 ]},
473 )
474 db_session.add(commit)
475 db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid))
476 await db_session.flush()
477
478 await build_symbol_index(db_session, repo.repo_id, cid)
479 await db_session.flush()
480
481 row = (await db_session.execute(
482 select(MusehubSymbolVitals).where(
483 MusehubSymbolVitals.repo_id == repo.repo_id,
484 MusehubSymbolVitals.address == "src/core.py::process",
485 )
486 )).scalar_one_or_none()
487 assert row is not None
488 assert row.change_count == 1
489 assert row.op_add == 1
490
491 async def test_D202_vitals_change_count_increments_on_rebuild(self, db_session: AsyncSession) -> None:
492 """D202: change_count reflects all commits for the symbol after rebuild."""
493 from musehub.services.musehub_symbol_indexer import build_symbol_index
494
495 repo = await create_repo(db_session, owner="gabriel")
496 content = _cid()
497 prev_id: str | None = None
498 last_cid = ""
499 for i in range(5):
500 cid = _lid()
501 last_cid = cid
502 commit = MusehubCommit(
503 commit_id=cid,
504 branch="dev",
505 parent_ids=[prev_id] if prev_id else [],
506 message=f"feat: change {i}",
507 author="gabriel",
508 timestamp=_now(offset_days=i),
509 structured_delta={"ops": [
510 {"address": "src/core.py::process", "op": "replace" if i else "insert", "content_id": content},
511 ]},
512 )
513 db_session.add(commit)
514 db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid))
515 prev_id = cid
516 await db_session.flush()
517
518 await build_symbol_index(db_session, repo.repo_id, last_cid)
519 await db_session.flush()
520
521 row = (await db_session.execute(
522 select(MusehubSymbolVitals).where(
523 MusehubSymbolVitals.repo_id == repo.repo_id,
524 MusehubSymbolVitals.address == "src/core.py::process",
525 )
526 )).scalar_one_or_none()
527 assert row is not None
528 assert row.change_count == 5
529
530 async def test_D203_vitals_first_introduced_is_earliest_commit(self, db_session: AsyncSession) -> None:
531 """D203: first_introduced matches the committed_at of the symbol's first entry."""
532 from musehub.services.musehub_symbol_indexer import build_symbol_index
533
534 repo = await create_repo(db_session, owner="gabriel")
535 first_ts = _now(offset_days=-10)
536 prev_id: str | None = None
537 last_cid = ""
538 for i, ts in enumerate([first_ts, _now(offset_days=-5), _now()]):
539 cid = _lid()
540 last_cid = cid
541 commit = MusehubCommit(
542 commit_id=cid,
543 branch="dev",
544 parent_ids=[prev_id] if prev_id else [],
545 message="change",
546 author="gabriel",
547 timestamp=ts,
548 structured_delta={"ops": [
549 {"address": "src/a.py::fn", "op": "insert" if i == 0 else "replace", "content_id": _cid()},
550 ]},
551 )
552 db_session.add(commit)
553 db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid))
554 prev_id = cid
555 await db_session.flush()
556
557 await build_symbol_index(db_session, repo.repo_id, last_cid)
558 await db_session.flush()
559
560 row = (await db_session.execute(
561 select(MusehubSymbolVitals).where(
562 MusehubSymbolVitals.repo_id == repo.repo_id,
563 MusehubSymbolVitals.address == "src/a.py::fn",
564 )
565 )).scalar_one_or_none()
566 assert row is not None
567 assert abs((row.first_introduced - first_ts).total_seconds()) < 2
568
569
570 class TestIndexerPopulatesCoupling:
571 """D210–D215: build_symbol_index writes MusehubSymbolCoupling rows."""
572
573 async def test_D210_coupling_written_for_co_changed_symbols(self, db_session: AsyncSession) -> None:
574 """D210: symbols changed in the same commit get coupling rows."""
575 from musehub.services.musehub_symbol_indexer import build_symbol_index, backfill_coupling
576
577 repo = await create_repo(db_session, owner="gabriel")
578 cid = _lid()
579 commit = MusehubCommit(
580 commit_id=cid,
581 branch="dev",
582 parent_ids=[],
583 message="feat: big change",
584 author="gabriel",
585 timestamp=_now(),
586 structured_delta={"ops": [
587 {"address": "src/a.py::fn_a", "op": "insert", "content_id": _cid()},
588 {"address": "src/b.py::fn_b", "op": "insert", "content_id": _cid()},
589 ]},
590 )
591 db_session.add(commit)
592 db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid))
593 await db_session.flush()
594
595 await build_symbol_index(db_session, repo.repo_id, cid)
596 await backfill_coupling(db_session, repo.repo_id, min_shared=1)
597 await db_session.flush()
598
599 coupling_a = (await db_session.execute(
600 select(MusehubSymbolCoupling).where(
601 MusehubSymbolCoupling.repo_id == repo.repo_id,
602 MusehubSymbolCoupling.address == "src/a.py::fn_a",
603 MusehubSymbolCoupling.co_address == "src/b.py::fn_b",
604 )
605 )).scalar_one_or_none()
606 assert coupling_a is not None
607 assert coupling_a.shared_commits == 1
608
609 async def test_D211_coupling_is_symmetric(self, db_session: AsyncSession) -> None:
610 """D211: if A couples with B, there is also a row for B→A."""
611 from musehub.services.musehub_symbol_indexer import build_symbol_index, backfill_coupling
612
613 repo = await create_repo(db_session, owner="gabriel")
614 cid = _lid()
615 commit = MusehubCommit(
616 commit_id=cid,
617 branch="dev",
618 parent_ids=[],
619 message="change",
620 author="gabriel",
621 timestamp=_now(),
622 structured_delta={"ops": [
623 {"address": "src/a.py::fn_a", "op": "insert", "content_id": _cid()},
624 {"address": "src/b.py::fn_b", "op": "insert", "content_id": _cid()},
625 ]},
626 )
627 db_session.add(commit)
628 db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid))
629 await db_session.flush()
630
631 await build_symbol_index(db_session, repo.repo_id, cid)
632 await backfill_coupling(db_session, repo.repo_id, min_shared=1)
633 await db_session.flush()
634
635 b_to_a = (await db_session.execute(
636 select(MusehubSymbolCoupling).where(
637 MusehubSymbolCoupling.repo_id == repo.repo_id,
638 MusehubSymbolCoupling.address == "src/b.py::fn_b",
639 MusehubSymbolCoupling.co_address == "src/a.py::fn_a",
640 )
641 )).scalar_one_or_none()
642 assert b_to_a is not None
643
644 async def test_D212_coupling_count_accumulates_across_commits(self, db_session: AsyncSession) -> None:
645 """D212: shared_commits increments each time both symbols appear in a commit."""
646 from musehub.services.musehub_symbol_indexer import build_symbol_index, backfill_coupling
647
648 repo = await create_repo(db_session, owner="gabriel")
649 last_cid = ""
650 prev_id: str | None = None
651 for i in range(3):
652 cid = _lid()
653 last_cid = cid
654 commit = MusehubCommit(
655 commit_id=cid,
656 branch="dev",
657 parent_ids=[prev_id] if prev_id else [],
658 message=f"change {i}",
659 author="gabriel",
660 timestamp=_now(offset_days=i),
661 structured_delta={"ops": [
662 {"address": "src/a.py::fn_a", "op": "insert" if i == 0 else "replace", "content_id": _cid()},
663 {"address": "src/b.py::fn_b", "op": "insert" if i == 0 else "replace", "content_id": _cid()},
664 ]},
665 )
666 db_session.add(commit)
667 db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid))
668 prev_id = cid
669 await db_session.flush()
670
671 await build_symbol_index(db_session, repo.repo_id, last_cid)
672 await backfill_coupling(db_session, repo.repo_id, min_shared=1)
673 await db_session.flush()
674
675 row = (await db_session.execute(
676 select(MusehubSymbolCoupling).where(
677 MusehubSymbolCoupling.repo_id == repo.repo_id,
678 MusehubSymbolCoupling.address == "src/a.py::fn_a",
679 MusehubSymbolCoupling.co_address == "src/b.py::fn_b",
680 )
681 )).scalar_one_or_none()
682 assert row is not None
683 assert row.shared_commits == 3
684
685 async def test_D213_rebuild_is_idempotent(self, db_session: AsyncSession) -> None:
686 """D213: running build_symbol_index twice produces the same coupling counts."""
687 from musehub.services.musehub_symbol_indexer import build_symbol_index, backfill_coupling
688
689 repo = await create_repo(db_session, owner="gabriel")
690 cid = _lid()
691 commit = MusehubCommit(
692 commit_id=cid,
693 branch="dev",
694 parent_ids=[],
695 message="change",
696 author="gabriel",
697 timestamp=_now(),
698 structured_delta={"ops": [
699 {"address": "src/a.py::fn_a", "op": "insert", "content_id": _cid()},
700 {"address": "src/b.py::fn_b", "op": "insert", "content_id": _cid()},
701 ]},
702 )
703 db_session.add(commit)
704 db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid))
705 await db_session.flush()
706
707 for _ in range(2):
708 await build_symbol_index(db_session, repo.repo_id, cid)
709 await backfill_coupling(db_session, repo.repo_id, min_shared=1)
710 await db_session.flush()
711
712 rows = (await db_session.execute(
713 select(MusehubSymbolCoupling).where(
714 MusehubSymbolCoupling.repo_id == repo.repo_id,
715 MusehubSymbolCoupling.address == "src/a.py::fn_a",
716 MusehubSymbolCoupling.co_address == "src/b.py::fn_b",
717 )
718 )).scalars().all()
719 assert len(rows) == 1
720 assert rows[0].shared_commits == 1
721
722
723 class TestIndexerDenormalizesCommitMessage:
724 """D220–D223: build_symbol_index stores message + commit_branch on history entries."""
725
726 async def test_D220_history_entry_has_message(self, db_session: AsyncSession) -> None:
727 """D220: history entry written by indexer carries the commit message."""
728 from musehub.services.musehub_symbol_indexer import build_symbol_index
729
730 repo = await create_repo(db_session, owner="gabriel")
731 cid = _lid()
732 commit = MusehubCommit(
733 commit_id=cid,
734 branch="dev",
735 parent_ids=[],
736 message="feat: add important fn",
737 author="gabriel",
738 timestamp=_now(),
739 structured_delta={"ops": [
740 {"address": "src/core.py::important_fn", "op": "insert", "content_id": _cid()},
741 ]},
742 )
743 db_session.add(commit)
744 db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid))
745 await db_session.flush()
746
747 await build_symbol_index(db_session, repo.repo_id, cid)
748 await db_session.flush()
749
750 entry = (await db_session.execute(
751 select(MusehubSymbolHistoryEntry).where(
752 MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
753 MusehubSymbolHistoryEntry.address == "src/core.py::important_fn",
754 )
755 )).scalar_one_or_none()
756 assert entry is not None
757 assert entry.message == "feat: add important fn"
758
759 async def test_D221_history_entry_has_commit_branch(self, db_session: AsyncSession) -> None:
760 """D221: history entry written by indexer carries the commit_branch."""
761 from musehub.services.musehub_symbol_indexer import build_symbol_index
762
763 repo = await create_repo(db_session, owner="gabriel")
764 cid = _lid()
765 commit = MusehubCommit(
766 commit_id=cid,
767 branch="feat/audio",
768 parent_ids=[],
769 message="feat: audio fn",
770 author="gabriel",
771 timestamp=_now(),
772 structured_delta={"ops": [
773 {"address": "src/audio.py::encode", "op": "insert", "content_id": _cid()},
774 ]},
775 )
776 db_session.add(commit)
777 db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid))
778 await db_session.flush()
779
780 await build_symbol_index(db_session, repo.repo_id, cid)
781 await db_session.flush()
782
783 entry = (await db_session.execute(
784 select(MusehubSymbolHistoryEntry).where(
785 MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
786 MusehubSymbolHistoryEntry.address == "src/audio.py::encode",
787 )
788 )).scalar_one_or_none()
789 assert entry is not None
790 assert entry.commit_branch == "feat/audio"
791
792
793 # ---------------------------------------------------------------------------
794 # D3xx — Route: detail page reads pre-computed data, no heavy queries
795 # ---------------------------------------------------------------------------
796
797 class TestRouteUsesPrecomputedData:
798 """D301–D305: symbol detail route reads vitals + coupling tables."""
799
800 async def test_D301_page_renders_change_count_from_vitals(
801 self, db_session: AsyncSession, client: AsyncClient
802 ) -> None:
803 """D301: change_count on the page comes from MusehubSymbolVitals, not history scan."""
804 repo = await create_repo(db_session, owner="gabriel", slug="myrepo")
805 await db_session.commit()
806
807 address = "src/core.py::process"
808 cid = _lid()
809 entry = MusehubSymbolHistoryEntry(
810 repo_id=repo.repo_id,
811 address=address,
812 commit_id=cid,
813 op="insert",
814 content_id=_cid(),
815 message="feat: add process",
816 commit_branch="dev",
817 committed_at=_now(),
818 author="gabriel",
819 )
820 db_session.add(entry)
821 await _insert_vitals(
822 db_session, repo.repo_id, address,
823 change_count=99, version_count=5,
824 op_add=1, op_modify=98,
825 )
826 await db_session.commit()
827
828 r = await client.get(f"/gabriel/myrepo/symbol/{address}")
829 assert r.status_code == 200
830 assert b"99" in r.content
831
832 async def test_D302_page_renders_coupling_from_coupling_table(
833 self, db_session: AsyncSession, client: AsyncClient
834 ) -> None:
835 """D302: co-change section is populated from MusehubSymbolCoupling rows."""
836 repo = await create_repo(db_session, owner="gabriel", slug="myrepo2")
837 await db_session.commit()
838
839 address = "src/a.py::fn_a"
840 cid = _lid()
841 entry = MusehubSymbolHistoryEntry(
842 repo_id=repo.repo_id,
843 address=address,
844 commit_id=cid,
845 op="insert",
846 content_id=_cid(),
847 message="feat: add fn_a",
848 commit_branch="dev",
849 committed_at=_now(),
850 author="gabriel",
851 )
852 db_session.add(entry)
853 await _insert_coupling(db_session, repo.repo_id, address, "src/b.py::fn_b", 7)
854 await db_session.commit()
855
856 r = await client.get(f"/gabriel/myrepo2/symbol/{address}")
857 assert r.status_code == 200
858 assert b"src/b.py::fn_b" in r.content
859
860 async def test_D303_history_timeline_needs_no_commit_join(
861 self, db_session: AsyncSession, client: AsyncClient
862 ) -> None:
863 """D303: timeline message comes from history entry itself, not a join."""
864 repo = await create_repo(db_session, owner="gabriel", slug="myrepo3")
865 await db_session.commit()
866
867 address = "src/core.py::do_thing"
868 cid = _lid()
869 entry = MusehubSymbolHistoryEntry(
870 repo_id=repo.repo_id,
871 address=address,
872 commit_id=cid,
873 op="insert",
874 content_id=_cid(),
875 message="feat: do thing implemented",
876 commit_branch="dev",
877 committed_at=_now(),
878 author="gabriel",
879 )
880 db_session.add(entry)
881 await db_session.commit()
882
883 r = await client.get(f"/gabriel/myrepo3/symbol/{address}")
884 assert r.status_code == 200
885 assert b"feat: do thing implemented" in r.content
886
887
888 # ---------------------------------------------------------------------------
889 # D4xx — Edge cases
890 # ---------------------------------------------------------------------------
891
892 class TestEdgeCases:
893 """D401–D405: empty history, single entry, no coupling."""
894
895 async def test_D401_no_vitals_row_falls_back_gracefully(
896 self, db_session: AsyncSession, client: AsyncClient
897 ) -> None:
898 """D401: page renders even when MusehubSymbolVitals row is absent (old data)."""
899 repo = await create_repo(db_session, owner="gabriel", slug="oldrepo")
900 await db_session.commit()
901
902 address = "src/old.py::legacy"
903 entry = MusehubSymbolHistoryEntry(
904 repo_id=repo.repo_id,
905 address=address,
906 commit_id=_lid(),
907 op="insert",
908 content_id=_cid(),
909 message=None,
910 commit_branch=None,
911 committed_at=_now(),
912 author="gabriel",
913 )
914 db_session.add(entry)
915 await db_session.commit()
916
917 r = await client.get(f"/gabriel/oldrepo/symbol/{address}")
918 assert r.status_code == 200
919
920 async def test_D402_no_coupling_rows_renders_empty_section(
921 self, db_session: AsyncSession, client: AsyncClient
922 ) -> None:
923 """D402: coupling section renders without error when table has no rows for symbol."""
924 repo = await create_repo(db_session, owner="gabriel", slug="lonerepo")
925 await db_session.commit()
926
927 address = "src/lone.py::solo"
928 entry = MusehubSymbolHistoryEntry(
929 repo_id=repo.repo_id,
930 address=address,
931 commit_id=_lid(),
932 op="insert",
933 content_id=_cid(),
934 message="add solo",
935 commit_branch="dev",
936 committed_at=_now(),
937 author="gabriel",
938 )
939 db_session.add(entry)
940 await db_session.commit()
941
942 r = await client.get(f"/gabriel/lonerepo/symbol/{address}")
943 assert r.status_code == 200
944
945 async def test_D403_coupling_pagination_second_page(
946 self, db_session: AsyncSession, client: AsyncClient
947 ) -> None:
948 """D403: coupling_cursor offset into MusehubSymbolCoupling works correctly."""
949 repo = await create_repo(db_session, owner="gabriel", slug="bigcoupling")
950 await db_session.commit()
951
952 address = "src/hub.py::central"
953 entry = MusehubSymbolHistoryEntry(
954 repo_id=repo.repo_id,
955 address=address,
956 commit_id=_lid(),
957 op="insert",
958 content_id=_cid(),
959 message="add central",
960 commit_branch="dev",
961 committed_at=_now(),
962 author="gabriel",
963 )
964 db_session.add(entry)
965 for i in range(20):
966 await _insert_coupling(
967 db_session, repo.repo_id, address,
968 f"src/dep{i}.py::fn", shared_commits=20 - i,
969 )
970 await db_session.commit()
971
972 r = await client.get(f"/gabriel/bigcoupling/symbol/{address}?coupling_cursor=15")
973 assert r.status_code == 200
974 assert b"src/dep" in r.content
975
976 async def test_D404_vitals_op_breakdown_correct(self, db_session: AsyncSession) -> None:
977 """D404: op_add/modify/delete/move on vitals row sum to change_count."""
978 repo = await create_repo(db_session, owner="gabriel")
979 row = await _insert_vitals(
980 db_session, repo.repo_id, "src/x.py::fn",
981 change_count=10, version_count=4,
982 op_add=1, op_modify=7, op_delete=1, op_move=1,
983 )
984 await db_session.flush()
985 assert row.op_add + row.op_modify + row.op_delete + row.op_move == row.change_count
986
987 async def test_D405_coupling_page_beyond_end_returns_200(
988 self, db_session: AsyncSession, client: AsyncClient
989 ) -> None:
990 """D405: requesting a coupling offset past the end renders empty section, not 500."""
991 repo = await create_repo(db_session, owner="gabriel", slug="smallcoupling")
992 await db_session.commit()
993
994 address = "src/tiny.py::fn"
995 entry = MusehubSymbolHistoryEntry(
996 repo_id=repo.repo_id,
997 address=address,
998 commit_id=_lid(),
999 op="insert",
1000 content_id=_cid(),
1001 message="add tiny fn",
1002 commit_branch="dev",
1003 committed_at=_now(),
1004 author="gabriel",
1005 )
1006 db_session.add(entry)
1007 await _insert_coupling(db_session, repo.repo_id, address, "src/b.py::other", 3)
1008 await db_session.commit()
1009
1010 r = await client.get(f"/gabriel/smallcoupling/symbol/{address}?coupling_cursor=9999")
1011 assert r.status_code == 200
File History 3 commits
sha256:94ef169c149a452bff7c604ded8b280b19bd477c2dabcb56972780b0b784c7aa Merge 'fix/assignee-sigil-inline' into 'dev' — proposal: As… Human 1 day ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 9 days ago
sha256:4aed3d8601c8dd3ed37074de35f11f4a9699a0a4b99d43727048fd3f8e6fd13d chore: doc sweep, ignore wrangler build state, misc fixes Sonnet 4.6 minor 12 days ago