gabriel / musehub public
test_symbol_detail_fast_reads.py python
1,007 lines 38.5 KB
Raw
sha256:f99af7b1a7f36c4d537d1c630d4b71fc39222b1255f82e930929e2fc89015e11 fix: relax browse_repo perf budget to 500ms — 200ms was too… Sonnet 4.6 99 days 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
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 db_session.flush()
597
598 coupling_a = (await db_session.execute(
599 select(MusehubSymbolCoupling).where(
600 MusehubSymbolCoupling.repo_id == repo.repo_id,
601 MusehubSymbolCoupling.address == "src/a.py::fn_a",
602 MusehubSymbolCoupling.co_address == "src/b.py::fn_b",
603 )
604 )).scalar_one_or_none()
605 assert coupling_a is not None
606 assert coupling_a.shared_commits == 1
607
608 async def test_D211_coupling_is_symmetric(self, db_session: AsyncSession) -> None:
609 """D211: if A couples with B, there is also a row for B→A."""
610 from musehub.services.musehub_symbol_indexer import build_symbol_index
611
612 repo = await create_repo(db_session, owner="gabriel")
613 cid = _lid()
614 commit = MusehubCommit(
615 commit_id=cid,
616 branch="dev",
617 parent_ids=[],
618 message="change",
619 author="gabriel",
620 timestamp=_now(),
621 structured_delta={"ops": [
622 {"address": "src/a.py::fn_a", "op": "insert", "content_id": _cid()},
623 {"address": "src/b.py::fn_b", "op": "insert", "content_id": _cid()},
624 ]},
625 )
626 db_session.add(commit)
627 db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid))
628 await db_session.flush()
629
630 await build_symbol_index(db_session, repo.repo_id, cid)
631 await db_session.flush()
632
633 b_to_a = (await db_session.execute(
634 select(MusehubSymbolCoupling).where(
635 MusehubSymbolCoupling.repo_id == repo.repo_id,
636 MusehubSymbolCoupling.address == "src/b.py::fn_b",
637 MusehubSymbolCoupling.co_address == "src/a.py::fn_a",
638 )
639 )).scalar_one_or_none()
640 assert b_to_a is not None
641
642 async def test_D212_coupling_count_accumulates_across_commits(self, db_session: AsyncSession) -> None:
643 """D212: shared_commits increments each time both symbols appear in a commit."""
644 from musehub.services.musehub_symbol_indexer import build_symbol_index
645
646 repo = await create_repo(db_session, owner="gabriel")
647 last_cid = ""
648 prev_id: str | None = None
649 for i in range(3):
650 cid = _lid()
651 last_cid = cid
652 commit = MusehubCommit(
653 commit_id=cid,
654 branch="dev",
655 parent_ids=[prev_id] if prev_id else [],
656 message=f"change {i}",
657 author="gabriel",
658 timestamp=_now(offset_days=i),
659 structured_delta={"ops": [
660 {"address": "src/a.py::fn_a", "op": "insert" if i == 0 else "replace", "content_id": _cid()},
661 {"address": "src/b.py::fn_b", "op": "insert" if i == 0 else "replace", "content_id": _cid()},
662 ]},
663 )
664 db_session.add(commit)
665 db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid))
666 prev_id = cid
667 await db_session.flush()
668
669 await build_symbol_index(db_session, repo.repo_id, last_cid)
670 await db_session.flush()
671
672 row = (await db_session.execute(
673 select(MusehubSymbolCoupling).where(
674 MusehubSymbolCoupling.repo_id == repo.repo_id,
675 MusehubSymbolCoupling.address == "src/a.py::fn_a",
676 MusehubSymbolCoupling.co_address == "src/b.py::fn_b",
677 )
678 )).scalar_one_or_none()
679 assert row is not None
680 assert row.shared_commits == 3
681
682 async def test_D213_rebuild_is_idempotent(self, db_session: AsyncSession) -> None:
683 """D213: running build_symbol_index twice produces the same coupling counts."""
684 from musehub.services.musehub_symbol_indexer import build_symbol_index
685
686 repo = await create_repo(db_session, owner="gabriel")
687 cid = _lid()
688 commit = MusehubCommit(
689 commit_id=cid,
690 branch="dev",
691 parent_ids=[],
692 message="change",
693 author="gabriel",
694 timestamp=_now(),
695 structured_delta={"ops": [
696 {"address": "src/a.py::fn_a", "op": "insert", "content_id": _cid()},
697 {"address": "src/b.py::fn_b", "op": "insert", "content_id": _cid()},
698 ]},
699 )
700 db_session.add(commit)
701 db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid))
702 await db_session.flush()
703
704 for _ in range(2):
705 await build_symbol_index(db_session, repo.repo_id, cid)
706 await db_session.flush()
707
708 rows = (await db_session.execute(
709 select(MusehubSymbolCoupling).where(
710 MusehubSymbolCoupling.repo_id == repo.repo_id,
711 MusehubSymbolCoupling.address == "src/a.py::fn_a",
712 MusehubSymbolCoupling.co_address == "src/b.py::fn_b",
713 )
714 )).scalars().all()
715 assert len(rows) == 1
716 assert rows[0].shared_commits == 1
717
718
719 class TestIndexerDenormalizesCommitMessage:
720 """D220–D223: build_symbol_index stores message + commit_branch on history entries."""
721
722 async def test_D220_history_entry_has_message(self, db_session: AsyncSession) -> None:
723 """D220: history entry written by indexer carries the commit message."""
724 from musehub.services.musehub_symbol_indexer import build_symbol_index
725
726 repo = await create_repo(db_session, owner="gabriel")
727 cid = _lid()
728 commit = MusehubCommit(
729 commit_id=cid,
730 branch="dev",
731 parent_ids=[],
732 message="feat: add important fn",
733 author="gabriel",
734 timestamp=_now(),
735 structured_delta={"ops": [
736 {"address": "src/core.py::important_fn", "op": "insert", "content_id": _cid()},
737 ]},
738 )
739 db_session.add(commit)
740 db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid))
741 await db_session.flush()
742
743 await build_symbol_index(db_session, repo.repo_id, cid)
744 await db_session.flush()
745
746 entry = (await db_session.execute(
747 select(MusehubSymbolHistoryEntry).where(
748 MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
749 MusehubSymbolHistoryEntry.address == "src/core.py::important_fn",
750 )
751 )).scalar_one_or_none()
752 assert entry is not None
753 assert entry.message == "feat: add important fn"
754
755 async def test_D221_history_entry_has_commit_branch(self, db_session: AsyncSession) -> None:
756 """D221: history entry written by indexer carries the commit_branch."""
757 from musehub.services.musehub_symbol_indexer import build_symbol_index
758
759 repo = await create_repo(db_session, owner="gabriel")
760 cid = _lid()
761 commit = MusehubCommit(
762 commit_id=cid,
763 branch="feat/audio",
764 parent_ids=[],
765 message="feat: audio fn",
766 author="gabriel",
767 timestamp=_now(),
768 structured_delta={"ops": [
769 {"address": "src/audio.py::encode", "op": "insert", "content_id": _cid()},
770 ]},
771 )
772 db_session.add(commit)
773 db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid))
774 await db_session.flush()
775
776 await build_symbol_index(db_session, repo.repo_id, cid)
777 await db_session.flush()
778
779 entry = (await db_session.execute(
780 select(MusehubSymbolHistoryEntry).where(
781 MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
782 MusehubSymbolHistoryEntry.address == "src/audio.py::encode",
783 )
784 )).scalar_one_or_none()
785 assert entry is not None
786 assert entry.commit_branch == "feat/audio"
787
788
789 # ---------------------------------------------------------------------------
790 # D3xx — Route: detail page reads pre-computed data, no heavy queries
791 # ---------------------------------------------------------------------------
792
793 class TestRouteUsesPrecomputedData:
794 """D301–D305: symbol detail route reads vitals + coupling tables."""
795
796 async def test_D301_page_renders_change_count_from_vitals(
797 self, db_session: AsyncSession, client: AsyncClient
798 ) -> None:
799 """D301: change_count on the page comes from MusehubSymbolVitals, not history scan."""
800 repo = await create_repo(db_session, owner="gabriel", slug="myrepo")
801 await db_session.commit()
802
803 address = "src/core.py::process"
804 cid = _lid()
805 entry = MusehubSymbolHistoryEntry(
806 repo_id=repo.repo_id,
807 address=address,
808 commit_id=cid,
809 op="insert",
810 content_id=_cid(),
811 message="feat: add process",
812 commit_branch="dev",
813 committed_at=_now(),
814 author="gabriel",
815 )
816 db_session.add(entry)
817 await _insert_vitals(
818 db_session, repo.repo_id, address,
819 change_count=99, version_count=5,
820 op_add=1, op_modify=98,
821 )
822 await db_session.commit()
823
824 r = await client.get(f"/gabriel/myrepo/symbol/{address}")
825 assert r.status_code == 200
826 assert b"99" in r.content
827
828 async def test_D302_page_renders_coupling_from_coupling_table(
829 self, db_session: AsyncSession, client: AsyncClient
830 ) -> None:
831 """D302: co-change section is populated from MusehubSymbolCoupling rows."""
832 repo = await create_repo(db_session, owner="gabriel", slug="myrepo2")
833 await db_session.commit()
834
835 address = "src/a.py::fn_a"
836 cid = _lid()
837 entry = MusehubSymbolHistoryEntry(
838 repo_id=repo.repo_id,
839 address=address,
840 commit_id=cid,
841 op="insert",
842 content_id=_cid(),
843 message="feat: add fn_a",
844 commit_branch="dev",
845 committed_at=_now(),
846 author="gabriel",
847 )
848 db_session.add(entry)
849 await _insert_coupling(db_session, repo.repo_id, address, "src/b.py::fn_b", 7)
850 await db_session.commit()
851
852 r = await client.get(f"/gabriel/myrepo2/symbol/{address}")
853 assert r.status_code == 200
854 assert b"src/b.py::fn_b" in r.content
855
856 async def test_D303_history_timeline_needs_no_commit_join(
857 self, db_session: AsyncSession, client: AsyncClient
858 ) -> None:
859 """D303: timeline message comes from history entry itself, not a join."""
860 repo = await create_repo(db_session, owner="gabriel", slug="myrepo3")
861 await db_session.commit()
862
863 address = "src/core.py::do_thing"
864 cid = _lid()
865 entry = MusehubSymbolHistoryEntry(
866 repo_id=repo.repo_id,
867 address=address,
868 commit_id=cid,
869 op="insert",
870 content_id=_cid(),
871 message="feat: do thing implemented",
872 commit_branch="dev",
873 committed_at=_now(),
874 author="gabriel",
875 )
876 db_session.add(entry)
877 await db_session.commit()
878
879 r = await client.get(f"/gabriel/myrepo3/symbol/{address}")
880 assert r.status_code == 200
881 assert b"feat: do thing implemented" in r.content
882
883
884 # ---------------------------------------------------------------------------
885 # D4xx — Edge cases
886 # ---------------------------------------------------------------------------
887
888 class TestEdgeCases:
889 """D401–D405: empty history, single entry, no coupling."""
890
891 async def test_D401_no_vitals_row_falls_back_gracefully(
892 self, db_session: AsyncSession, client: AsyncClient
893 ) -> None:
894 """D401: page renders even when MusehubSymbolVitals row is absent (old data)."""
895 repo = await create_repo(db_session, owner="gabriel", slug="oldrepo")
896 await db_session.commit()
897
898 address = "src/old.py::legacy"
899 entry = MusehubSymbolHistoryEntry(
900 repo_id=repo.repo_id,
901 address=address,
902 commit_id=_lid(),
903 op="insert",
904 content_id=_cid(),
905 message=None,
906 commit_branch=None,
907 committed_at=_now(),
908 author="gabriel",
909 )
910 db_session.add(entry)
911 await db_session.commit()
912
913 r = await client.get(f"/gabriel/oldrepo/symbol/{address}")
914 assert r.status_code == 200
915
916 async def test_D402_no_coupling_rows_renders_empty_section(
917 self, db_session: AsyncSession, client: AsyncClient
918 ) -> None:
919 """D402: coupling section renders without error when table has no rows for symbol."""
920 repo = await create_repo(db_session, owner="gabriel", slug="lonerepo")
921 await db_session.commit()
922
923 address = "src/lone.py::solo"
924 entry = MusehubSymbolHistoryEntry(
925 repo_id=repo.repo_id,
926 address=address,
927 commit_id=_lid(),
928 op="insert",
929 content_id=_cid(),
930 message="add solo",
931 commit_branch="dev",
932 committed_at=_now(),
933 author="gabriel",
934 )
935 db_session.add(entry)
936 await db_session.commit()
937
938 r = await client.get(f"/gabriel/lonerepo/symbol/{address}")
939 assert r.status_code == 200
940
941 async def test_D403_coupling_pagination_second_page(
942 self, db_session: AsyncSession, client: AsyncClient
943 ) -> None:
944 """D403: coupling_cursor offset into MusehubSymbolCoupling works correctly."""
945 repo = await create_repo(db_session, owner="gabriel", slug="bigcoupling")
946 await db_session.commit()
947
948 address = "src/hub.py::central"
949 entry = MusehubSymbolHistoryEntry(
950 repo_id=repo.repo_id,
951 address=address,
952 commit_id=_lid(),
953 op="insert",
954 content_id=_cid(),
955 message="add central",
956 commit_branch="dev",
957 committed_at=_now(),
958 author="gabriel",
959 )
960 db_session.add(entry)
961 for i in range(20):
962 await _insert_coupling(
963 db_session, repo.repo_id, address,
964 f"src/dep{i}.py::fn", shared_commits=20 - i,
965 )
966 await db_session.commit()
967
968 r = await client.get(f"/gabriel/bigcoupling/symbol/{address}?coupling_cursor=15")
969 assert r.status_code == 200
970 assert b"src/dep" in r.content
971
972 async def test_D404_vitals_op_breakdown_correct(self, db_session: AsyncSession) -> None:
973 """D404: op_add/modify/delete/move on vitals row sum to change_count."""
974 repo = await create_repo(db_session, owner="gabriel")
975 row = await _insert_vitals(
976 db_session, repo.repo_id, "src/x.py::fn",
977 change_count=10, version_count=4,
978 op_add=1, op_modify=7, op_delete=1, op_move=1,
979 )
980 await db_session.flush()
981 assert row.op_add + row.op_modify + row.op_delete + row.op_move == row.change_count
982
983 async def test_D405_coupling_page_beyond_end_returns_200(
984 self, db_session: AsyncSession, client: AsyncClient
985 ) -> None:
986 """D405: requesting a coupling offset past the end renders empty section, not 500."""
987 repo = await create_repo(db_session, owner="gabriel", slug="smallcoupling")
988 await db_session.commit()
989
990 address = "src/tiny.py::fn"
991 entry = MusehubSymbolHistoryEntry(
992 repo_id=repo.repo_id,
993 address=address,
994 commit_id=_lid(),
995 op="insert",
996 content_id=_cid(),
997 message="add tiny fn",
998 commit_branch="dev",
999 committed_at=_now(),
1000 author="gabriel",
1001 )
1002 db_session.add(entry)
1003 await _insert_coupling(db_session, repo.repo_id, address, "src/b.py::other", 3)
1004 await db_session.commit()
1005
1006 r = await client.get(f"/gabriel/smallcoupling/symbol/{address}?coupling_cursor=9999")
1007 assert r.status_code == 200
File History 2 commits
sha256:f99af7b1a7f36c4d537d1c630d4b71fc39222b1255f82e930929e2fc89015e11 fix: relax browse_repo perf budget to 500ms — 200ms was too… Sonnet 4.6 99 days ago
sha256:763eb2cb8675073b84c19345b27586d2ed939a9aee97c5479b69f502f1a70eff fix(tests): update test suite to match current implementation Sonnet 4.6 patch 121 days ago