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