test_symbol_detail_fast_reads.py
python
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