gabriel / musehub public
test_intel_normalized_schema.py python
839 lines 36.7 KB
Raw
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ breaking 142 days ago
1 """TDD spec for normalized symbol intel schema — SI1–SI40.
2
3 Current architecture stores per-symbol data as unbounded JSON blobs in
4 musehub_intel_results.data_json. Every symbol page load deserializes
5 megabytes of JSON to return one entry. This test file defines the
6 correct normalized architecture.
7
8 All tests are RED until the implementation is complete.
9
10 New tables (replaces code.symbol_history / code.per_symbol_intel / code.hash_occurrence blobs):
11
12 musehub_symbol_history_entries — one row per (repo_id, address, commit_id)
13 musehub_symbol_intel — one row per (repo_id, address)
14 musehub_hash_occurrence_entries — one row per (content_id, repo_id, address)
15
16 musehub_intel_results keeps only:
17 code.intel_summary — small scalar aggregate, fine as blob
18 code.intel_snapshot — computed panel data, fine as blob
19
20 Layers:
21 1. Schema — ORM model shape, column types, indexes, constraints
22 2. Write — build_symbol_index upserts normalized rows
23 3. Read — helpers return correct data from normalized tables
24 4. Incremental— second push merges without duplication
25 5. Integrity — corrupt data, unknown refs, empty repos
26 6. Performance— point lookups sub-millisecond; no full-table deserialize
27 7. Stress — 500 symbols × 50 commits each
28 8. Aggregates — intel_summary and intel_snapshot still produced as blobs
29 """
30 from __future__ import annotations
31
32 import json
33 import time
34 import uuid
35 from datetime import datetime, timezone
36
37 import pytest
38 from sqlalchemy import select, func
39 from sqlalchemy.ext.asyncio import AsyncSession
40
41 from tests.factories import create_repo
42 from musehub.types.json_types import JSONObject
43
44
45 # ─────────────────────────────────────────────────────────────────────────────
46 # Helpers
47 # ─────────────────────────────────────────────────────────────────────────────
48
49 def _now() -> datetime:
50 return datetime.now(tz=timezone.utc)
51
52
53 def _uid() -> str:
54 return f"sha256:{uuid.uuid4().hex}{uuid.uuid4().hex}"[:71]
55
56
57 def _cid() -> str:
58 return f"sha256:{uuid.uuid4().hex}{uuid.uuid4().hex}"[:71]
59
60
61 def _insert_op(address: str, content_id: str | None = None) -> JSONObject:
62 return {
63 "address": address,
64 "op": "insert",
65 "content_id": content_id or _cid(),
66 }
67
68
69 def _patch_op(file_addr: str, children: list[JSONObject]) -> JSONObject:
70 return {"address": file_addr, "op": "patch", "child_ops": children}
71
72
73 async def _commit_with_delta(
74 session: AsyncSession,
75 repo_id: str,
76 commit_id: str,
77 ops: list[JSONObject],
78 parent_ids: list[str] | None = None,
79 author: str = "gabriel",
80 ) -> None:
81 from musehub.db import musehub_models as db
82 c = db.MusehubCommit(
83 commit_id=commit_id,
84 repo_id=repo_id,
85 branch="main",
86 parent_ids=parent_ids or [],
87 message="test commit",
88 author=author,
89 timestamp=_now(),
90 commit_meta={"structured_delta": {"ops": ops}},
91 )
92 session.add(c)
93 await session.flush()
94
95
96 async def _build_and_persist(
97 session: AsyncSession,
98 repo_id: str,
99 commit_id: str,
100 ) -> list[tuple[str, dict]]:
101 from musehub.services.musehub_symbol_indexer import build_symbol_index
102 from musehub.services.musehub_intel_providers import persist_intel_results
103 results = await build_symbol_index(session, repo_id, commit_id)
104 if results:
105 await persist_intel_results(session, repo_id, commit_id, results)
106 return results
107
108
109 # ─────────────────────────────────────────────────────────────────────────────
110 # Layer 1 — Schema: ORM model shape
111 # ─────────────────────────────────────────────────────────────────────────────
112
113 class TestNormalizedSchemaModels:
114 """SI1–SI6: ORM models for the three new normalized tables exist and have
115 the right columns, primary keys, and indexes."""
116
117 def test_SI1_symbol_history_entry_model_importable(self) -> None:
118 from musehub.db.musehub_models import MusehubSymbolHistoryEntry
119 assert MusehubSymbolHistoryEntry.__tablename__ == "musehub_symbol_history_entries"
120
121 def test_SI2_symbol_history_entry_columns(self) -> None:
122 from musehub.db.musehub_models import MusehubSymbolHistoryEntry
123 cols = {c.name for c in MusehubSymbolHistoryEntry.__table__.columns}
124 assert {"repo_id", "address", "commit_id", "committed_at",
125 "author", "op", "content_id"} <= cols
126
127 def test_SI3_symbol_history_entry_pk(self) -> None:
128 from musehub.db.musehub_models import MusehubSymbolHistoryEntry
129 pk_cols = {c.name for c in MusehubSymbolHistoryEntry.__table__.primary_key}
130 assert pk_cols == {"repo_id", "address", "commit_id"}
131
132 def test_SI4_symbol_intel_model_importable(self) -> None:
133 from musehub.db.musehub_models import MusehubSymbolIntel
134 assert MusehubSymbolIntel.__tablename__ == "musehub_symbol_intel"
135
136 def test_SI5_symbol_intel_columns(self) -> None:
137 from musehub.db.musehub_models import MusehubSymbolIntel
138 cols = {c.name for c in MusehubSymbolIntel.__table__.columns}
139 assert {"repo_id", "address", "churn", "churn_30d", "churn_90d",
140 "blast", "blast_direct", "blast_cross", "blast_top",
141 "last_changed", "last_author", "author_count",
142 "gravity", "weekly"} <= cols
143
144 def test_SI6_symbol_intel_pk(self) -> None:
145 from musehub.db.musehub_models import MusehubSymbolIntel
146 pk_cols = {c.name for c in MusehubSymbolIntel.__table__.primary_key}
147 assert pk_cols == {"repo_id", "address"}
148
149 def test_SI7_hash_occurrence_entry_model_importable(self) -> None:
150 from musehub.db.musehub_models import MusehubHashOccurrenceEntry
151 assert MusehubHashOccurrenceEntry.__tablename__ == "musehub_hash_occurrence_entries"
152
153 def test_SI8_hash_occurrence_entry_pk(self) -> None:
154 from musehub.db.musehub_models import MusehubHashOccurrenceEntry
155 pk_cols = {c.name for c in MusehubHashOccurrenceEntry.__table__.primary_key}
156 assert pk_cols == {"content_id", "repo_id", "address"}
157
158
159 # ─────────────────────────────────────────────────────────────────────────────
160 # Layer 2 — Write: build_symbol_index upserts normalized rows
161 # ─────────────────────────────────────────────────────────────────────────────
162
163 class TestBuildWritesNormalizedRows:
164 """SI9–SI16: build_symbol_index + persist_intel_results write to the
165 normalized tables, not just to intel_results blobs."""
166
167 @pytest.mark.asyncio
168 async def test_SI9_single_commit_writes_history_entry_rows(
169 self, db_session: AsyncSession,
170 ) -> None:
171 from musehub.db.musehub_models import MusehubSymbolHistoryEntry
172 repo = await create_repo(db_session)
173 commit_id = _uid()
174 await _commit_with_delta(db_session, repo.repo_id, commit_id, [
175 _insert_op("src/main.py::parse"),
176 _insert_op("src/main.py::render"),
177 ])
178 await _build_and_persist(db_session, repo.repo_id, commit_id)
179 rows = (await db_session.execute(
180 select(MusehubSymbolHistoryEntry).where(
181 MusehubSymbolHistoryEntry.repo_id == repo.repo_id
182 )
183 )).scalars().all()
184 addresses = {r.address for r in rows}
185 assert "src/main.py::parse" in addresses
186 assert "src/main.py::render" in addresses
187
188 @pytest.mark.asyncio
189 async def test_SI10_history_entry_commit_id_stored(
190 self, db_session: AsyncSession,
191 ) -> None:
192 from musehub.db.musehub_models import MusehubSymbolHistoryEntry
193 repo = await create_repo(db_session)
194 commit_id = _uid()
195 await _commit_with_delta(db_session, repo.repo_id, commit_id, [
196 _insert_op("src/auth.py::login"),
197 ])
198 await _build_and_persist(db_session, repo.repo_id, commit_id)
199 row = (await db_session.execute(
200 select(MusehubSymbolHistoryEntry).where(
201 MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
202 MusehubSymbolHistoryEntry.address == "src/auth.py::login",
203 )
204 )).scalar_one()
205 assert row.commit_id == commit_id
206 assert row.op in ("add", "insert", "modify")
207
208 @pytest.mark.asyncio
209 async def test_SI11_single_commit_writes_symbol_intel_rows(
210 self, db_session: AsyncSession,
211 ) -> None:
212 from musehub.db.musehub_models import MusehubSymbolIntel
213 repo = await create_repo(db_session)
214 commit_id = _uid()
215 await _commit_with_delta(db_session, repo.repo_id, commit_id, [
216 _insert_op("src/core.py::Engine"),
217 ])
218 await _build_and_persist(db_session, repo.repo_id, commit_id)
219 row = (await db_session.execute(
220 select(MusehubSymbolIntel).where(
221 MusehubSymbolIntel.repo_id == repo.repo_id,
222 MusehubSymbolIntel.address == "src/core.py::Engine",
223 )
224 )).scalar_one_or_none()
225 assert row is not None
226 assert row.churn >= 1
227
228 @pytest.mark.asyncio
229 async def test_SI12_hash_occurrence_rows_written(
230 self, db_session: AsyncSession,
231 ) -> None:
232 from musehub.db.musehub_models import MusehubHashOccurrenceEntry
233 repo = await create_repo(db_session)
234 commit_id = _uid()
235 shared_content_id = _cid()
236 await _commit_with_delta(db_session, repo.repo_id, commit_id, [
237 _insert_op("src/a.py::foo", shared_content_id),
238 _insert_op("src/b.py::bar", shared_content_id),
239 ])
240 await _build_and_persist(db_session, repo.repo_id, commit_id)
241 rows = (await db_session.execute(
242 select(MusehubHashOccurrenceEntry).where(
243 MusehubHashOccurrenceEntry.repo_id == repo.repo_id,
244 MusehubHashOccurrenceEntry.content_id == shared_content_id,
245 )
246 )).scalars().all()
247 addrs = {r.address for r in rows}
248 assert "src/a.py::foo" in addrs
249 assert "src/b.py::bar" in addrs
250
251 @pytest.mark.asyncio
252 async def test_SI13_intel_summary_still_written_to_intel_results(
253 self, db_session: AsyncSession,
254 ) -> None:
255 from musehub.db.musehub_models import MusehubIntelResult
256 repo = await create_repo(db_session)
257 commit_id = _uid()
258 await _commit_with_delta(db_session, repo.repo_id, commit_id, [
259 _insert_op("src/main.py::run"),
260 ])
261 await _build_and_persist(db_session, repo.repo_id, commit_id)
262 row = (await db_session.execute(
263 select(MusehubIntelResult).where(
264 MusehubIntelResult.repo_id == repo.repo_id,
265 MusehubIntelResult.intel_type == "code.intel_summary",
266 )
267 )).scalar_one_or_none()
268 assert row is not None
269 data = json.loads(row.data_json)
270 assert "health_score" in data
271
272 @pytest.mark.asyncio
273 async def test_SI14_intel_snapshot_still_written_to_intel_results(
274 self, db_session: AsyncSession,
275 ) -> None:
276 from musehub.db.musehub_models import MusehubIntelResult
277 repo = await create_repo(db_session)
278 commit_id = _uid()
279 await _commit_with_delta(db_session, repo.repo_id, commit_id, [
280 _insert_op("src/main.py::run"),
281 ])
282 await _build_and_persist(db_session, repo.repo_id, commit_id)
283 row = (await db_session.execute(
284 select(MusehubIntelResult).where(
285 MusehubIntelResult.repo_id == repo.repo_id,
286 MusehubIntelResult.intel_type == "code.intel_snapshot",
287 )
288 )).scalar_one_or_none()
289 assert row is not None
290
291 @pytest.mark.asyncio
292 async def test_SI15_blob_types_not_written_to_intel_results(
293 self, db_session: AsyncSession,
294 ) -> None:
295 """code.symbol_history, code.per_symbol_intel, code.hash_occurrence
296 must NOT be written as blobs anymore."""
297 from musehub.db.musehub_models import MusehubIntelResult
298 repo = await create_repo(db_session)
299 commit_id = _uid()
300 await _commit_with_delta(db_session, repo.repo_id, commit_id, [
301 _insert_op("src/main.py::run"),
302 ])
303 await _build_and_persist(db_session, repo.repo_id, commit_id)
304 blob_types = (await db_session.execute(
305 select(MusehubIntelResult.intel_type).where(
306 MusehubIntelResult.repo_id == repo.repo_id,
307 MusehubIntelResult.intel_type.in_([
308 "code.symbol_history",
309 "code.per_symbol_intel",
310 "code.hash_occurrence",
311 ])
312 )
313 )).scalars().all()
314 assert blob_types == [], f"blob types still written: {blob_types}"
315
316 @pytest.mark.asyncio
317 async def test_SI16_author_stored_in_history_entry(
318 self, db_session: AsyncSession,
319 ) -> None:
320 from musehub.db.musehub_models import MusehubSymbolHistoryEntry
321 repo = await create_repo(db_session)
322 commit_id = _uid()
323 await _commit_with_delta(
324 db_session, repo.repo_id, commit_id,
325 [_insert_op("src/auth.py::validate")],
326 author="gabriel",
327 )
328 await _build_and_persist(db_session, repo.repo_id, commit_id)
329 row = (await db_session.execute(
330 select(MusehubSymbolHistoryEntry).where(
331 MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
332 MusehubSymbolHistoryEntry.address == "src/auth.py::validate",
333 )
334 )).scalar_one()
335 assert row.author == "gabriel"
336
337
338 # ─────────────────────────────────────────────────────────────────────────────
339 # Layer 3 — Read: helpers return correct data from normalized tables
340 # ─────────────────────────────────────────────────────────────────────────────
341
342 class TestReadHelpers:
343 """SI17–SI24: read helpers query normalized tables, not blobs."""
344
345 @pytest.mark.asyncio
346 async def test_SI17_load_symbol_history_returns_entries_for_address(
347 self, db_session: AsyncSession,
348 ) -> None:
349 from musehub.services.musehub_symbol_indexer import load_symbol_history
350 repo = await create_repo(db_session)
351 commit_id = _uid()
352 await _commit_with_delta(db_session, repo.repo_id, commit_id, [
353 _insert_op("src/auth.py::login"),
354 _insert_op("src/core.py::Engine"),
355 ])
356 await _build_and_persist(db_session, repo.repo_id, commit_id)
357 history = await load_symbol_history(db_session, repo.repo_id)
358 assert "src/auth.py::login" in history
359 assert "src/core.py::Engine" in history
360
361 @pytest.mark.asyncio
362 async def test_SI18_load_symbol_history_file_path_filter(
363 self, db_session: AsyncSession,
364 ) -> None:
365 from musehub.services.musehub_symbol_indexer import load_symbol_history
366 repo = await create_repo(db_session)
367 commit_id = _uid()
368 await _commit_with_delta(db_session, repo.repo_id, commit_id, [
369 _insert_op("src/auth.py::login"),
370 _insert_op("src/auth.py::logout"),
371 _insert_op("src/core.py::Engine"),
372 ])
373 await _build_and_persist(db_session, repo.repo_id, commit_id)
374 history = await load_symbol_history(db_session, repo.repo_id, file_path="src/auth.py")
375 assert "src/auth.py::login" in history
376 assert "src/auth.py::logout" in history
377 assert "src/core.py::Engine" not in history
378
379 @pytest.mark.asyncio
380 async def test_SI19_load_symbol_history_empty_when_no_index(
381 self, db_session: AsyncSession,
382 ) -> None:
383 from musehub.services.musehub_symbol_indexer import load_symbol_history
384 repo = await create_repo(db_session)
385 history = await load_symbol_history(db_session, repo.repo_id)
386 assert history == {}
387
388 @pytest.mark.asyncio
389 async def test_SI20_lookup_symbol_intel_returns_metrics(
390 self, db_session: AsyncSession,
391 ) -> None:
392 from musehub.services.musehub_symbol_indexer import lookup_symbol_intel
393 repo = await create_repo(db_session)
394 commit_id = _uid()
395 await _commit_with_delta(db_session, repo.repo_id, commit_id, [
396 _insert_op("src/billing.py::compute_total"),
397 ])
398 await _build_and_persist(db_session, repo.repo_id, commit_id)
399 result = await lookup_symbol_intel(
400 db_session, repo.repo_id, ["src/billing.py::compute_total"]
401 )
402 assert "src/billing.py::compute_total" in result
403 intel = result["src/billing.py::compute_total"]
404 assert "churn" in intel
405 assert "gravity" in intel
406 assert "blast" in intel
407
408 @pytest.mark.asyncio
409 async def test_SI21_lookup_symbol_intel_missing_address_excluded(
410 self, db_session: AsyncSession,
411 ) -> None:
412 from musehub.services.musehub_symbol_indexer import lookup_symbol_intel
413 repo = await create_repo(db_session)
414 result = await lookup_symbol_intel(db_session, repo.repo_id, ["nonexistent::fn"])
415 assert result == {}
416
417 @pytest.mark.asyncio
418 async def test_SI22_load_hash_occurrence_returns_clone_pairs(
419 self, db_session: AsyncSession,
420 ) -> None:
421 from musehub.services.musehub_symbol_indexer import load_hash_occurrence
422 repo = await create_repo(db_session)
423 commit_id = _uid()
424 content_id = _cid()
425 await _commit_with_delta(db_session, repo.repo_id, commit_id, [
426 _insert_op("src/a.py::foo", content_id),
427 _insert_op("src/b.py::bar", content_id),
428 ])
429 await _build_and_persist(db_session, repo.repo_id, commit_id)
430 occurrence = await load_hash_occurrence(db_session, repo.repo_id)
431 assert content_id in occurrence
432 assert set(occurrence[content_id]) == {"src/a.py::foo", "src/b.py::bar"}
433
434 @pytest.mark.asyncio
435 async def test_SI23_load_intel_snapshot_still_works(
436 self, db_session: AsyncSession,
437 ) -> None:
438 from musehub.services.musehub_symbol_indexer import load_intel_snapshot
439 repo = await create_repo(db_session)
440 commit_id = _uid()
441 await _commit_with_delta(db_session, repo.repo_id, commit_id, [
442 _insert_op("src/main.py::run"),
443 ])
444 await _build_and_persist(db_session, repo.repo_id, commit_id)
445 snap = await load_intel_snapshot(db_session, repo.repo_id)
446 assert snap is not None
447
448 @pytest.mark.asyncio
449 async def test_SI24_get_index_meta_returns_correct_ref(
450 self, db_session: AsyncSession,
451 ) -> None:
452 from musehub.services.musehub_symbol_indexer import get_index_meta
453 repo = await create_repo(db_session)
454 commit_id = _uid()
455 await _commit_with_delta(db_session, repo.repo_id, commit_id, [
456 _insert_op("src/main.py::run"),
457 ])
458 await _build_and_persist(db_session, repo.repo_id, commit_id)
459 meta = await get_index_meta(db_session, repo.repo_id)
460 assert meta is not None
461 assert meta["ref"] == commit_id
462
463
464 # ─────────────────────────────────────────────────────────────────────────────
465 # Layer 4 — Incremental: second push merges without duplication
466 # ─────────────────────────────────────────────────────────────────────────────
467
468 class TestIncrementalUpdates:
469 """SI25–SI29: second push adds new rows, does not duplicate existing ones."""
470
471 @pytest.mark.asyncio
472 async def test_SI25_second_push_adds_new_history_entries(
473 self, db_session: AsyncSession,
474 ) -> None:
475 from musehub.db.musehub_models import MusehubSymbolHistoryEntry
476 repo = await create_repo(db_session)
477 c1 = _uid()
478 await _commit_with_delta(db_session, repo.repo_id, c1, [
479 _insert_op("src/auth.py::login"),
480 ])
481 await _build_and_persist(db_session, repo.repo_id, c1)
482
483 c2 = _uid()
484 await _commit_with_delta(db_session, repo.repo_id, c2, [
485 _insert_op("src/auth.py::logout"),
486 ], parent_ids=[c1])
487 await _build_and_persist(db_session, repo.repo_id, c2)
488
489 rows = (await db_session.execute(
490 select(MusehubSymbolHistoryEntry).where(
491 MusehubSymbolHistoryEntry.repo_id == repo.repo_id
492 )
493 )).scalars().all()
494 addresses = {r.address for r in rows}
495 assert "src/auth.py::login" in addresses
496 assert "src/auth.py::logout" in addresses
497
498 @pytest.mark.asyncio
499 async def test_SI26_second_push_does_not_duplicate_existing_entries(
500 self, db_session: AsyncSession,
501 ) -> None:
502 from musehub.db.musehub_models import MusehubSymbolHistoryEntry
503 repo = await create_repo(db_session)
504 c1 = _uid()
505 await _commit_with_delta(db_session, repo.repo_id, c1, [
506 _insert_op("src/core.py::Engine"),
507 ])
508 await _build_and_persist(db_session, repo.repo_id, c1)
509 # Re-build with same head — no new rows
510 await _build_and_persist(db_session, repo.repo_id, c1)
511
512 count = (await db_session.execute(
513 select(func.count()).select_from(MusehubSymbolHistoryEntry).where(
514 MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
515 MusehubSymbolHistoryEntry.address == "src/core.py::Engine",
516 )
517 )).scalar_one()
518 assert count == 1
519
520 @pytest.mark.asyncio
521 async def test_SI27_modify_op_updates_symbol_intel_churn(
522 self, db_session: AsyncSession,
523 ) -> None:
524 from musehub.services.musehub_symbol_indexer import lookup_symbol_intel
525 repo = await create_repo(db_session)
526 c1 = _uid()
527 await _commit_with_delta(db_session, repo.repo_id, c1, [
528 _insert_op("src/core.py::Engine"),
529 ])
530 await _build_and_persist(db_session, repo.repo_id, c1)
531
532 c2 = _uid()
533 await _commit_with_delta(db_session, repo.repo_id, c2, [
534 {"address": "src/core.py::Engine", "op": "replace",
535 "content_id": _cid()},
536 ], parent_ids=[c1])
537 await _build_and_persist(db_session, repo.repo_id, c2)
538
539 intel = await lookup_symbol_intel(db_session, repo.repo_id, ["src/core.py::Engine"])
540 assert intel["src/core.py::Engine"]["churn"] == 2
541
542 @pytest.mark.asyncio
543 async def test_SI28_second_push_history_has_both_commit_ids(
544 self, db_session: AsyncSession,
545 ) -> None:
546 from musehub.db.musehub_models import MusehubSymbolHistoryEntry
547 repo = await create_repo(db_session)
548 c1, c2 = _uid(), _uid()
549 await _commit_with_delta(db_session, repo.repo_id, c1, [
550 _insert_op("src/auth.py::login"),
551 ])
552 await _build_and_persist(db_session, repo.repo_id, c1)
553 await _commit_with_delta(db_session, repo.repo_id, c2, [
554 {"address": "src/auth.py::login", "op": "replace", "content_id": _cid()},
555 ], parent_ids=[c1])
556 await _build_and_persist(db_session, repo.repo_id, c2)
557
558 rows = (await db_session.execute(
559 select(MusehubSymbolHistoryEntry).where(
560 MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
561 MusehubSymbolHistoryEntry.address == "src/auth.py::login",
562 )
563 )).scalars().all()
564 commit_ids = {r.commit_id for r in rows}
565 assert c1 in commit_ids
566 assert c2 in commit_ids
567
568 @pytest.mark.asyncio
569 async def test_SI29_intel_summary_ref_advances_after_second_push(
570 self, db_session: AsyncSession,
571 ) -> None:
572 from musehub.services.musehub_symbol_indexer import get_index_meta
573 repo = await create_repo(db_session)
574 c1, c2 = _uid(), _uid()
575 await _commit_with_delta(db_session, repo.repo_id, c1, [_insert_op("src/a.py::f")])
576 await _build_and_persist(db_session, repo.repo_id, c1)
577 await _commit_with_delta(db_session, repo.repo_id, c2, [_insert_op("src/b.py::g")], parent_ids=[c1])
578 await _build_and_persist(db_session, repo.repo_id, c2)
579
580 meta = await get_index_meta(db_session, repo.repo_id)
581 assert meta is not None
582 assert meta["ref"] == c2
583
584
585 # ─────────────────────────────────────────────────────────────────────────────
586 # Layer 5 — Integrity: corrupt data, unknown refs, empty repos
587 # ─────────────────────────────────────────────────────────────────────────────
588
589 class TestDataIntegrity:
590 """SI30–SI33: edge cases that must not raise or corrupt state."""
591
592 @pytest.mark.asyncio
593 async def test_SI30_empty_repo_returns_empty_history(
594 self, db_session: AsyncSession,
595 ) -> None:
596 from musehub.services.musehub_symbol_indexer import load_symbol_history
597 repo = await create_repo(db_session)
598 assert await load_symbol_history(db_session, repo.repo_id) == {}
599
600 @pytest.mark.asyncio
601 async def test_SI31_unknown_head_commit_returns_empty_results(
602 self, db_session: AsyncSession,
603 ) -> None:
604 from musehub.services.musehub_symbol_indexer import build_symbol_index
605 repo = await create_repo(db_session)
606 results = await build_symbol_index(db_session, repo.repo_id, _uid())
607 assert results == []
608
609 @pytest.mark.asyncio
610 async def test_SI32_commit_with_no_structured_delta_skipped(
611 self, db_session: AsyncSession,
612 ) -> None:
613 from musehub.db import musehub_models as db
614 from musehub.db.musehub_models import MusehubSymbolHistoryEntry
615 repo = await create_repo(db_session)
616 commit_id = _uid()
617 c = db.MusehubCommit(
618 commit_id=commit_id, repo_id=repo.repo_id, branch="main",
619 parent_ids=[], message="no delta", author="gabriel",
620 timestamp=_now(), commit_meta={},
621 )
622 db_session.add(c)
623 await db_session.flush()
624 await _build_and_persist(db_session, repo.repo_id, commit_id)
625
626 count = (await db_session.execute(
627 select(func.count()).select_from(MusehubSymbolHistoryEntry).where(
628 MusehubSymbolHistoryEntry.repo_id == repo.repo_id
629 )
630 )).scalar_one()
631 assert count == 0
632
633 @pytest.mark.asyncio
634 async def test_SI33_lookup_symbol_intel_empty_address_list(
635 self, db_session: AsyncSession,
636 ) -> None:
637 from musehub.services.musehub_symbol_indexer import lookup_symbol_intel
638 repo = await create_repo(db_session)
639 result = await lookup_symbol_intel(db_session, repo.repo_id, [])
640 assert result == {}
641
642
643 # ─────────────────────────────────────────────────────────────────────────────
644 # Layer 6 — Performance: point lookups do not deserialize blobs
645 # ─────────────────────────────────────────────────────────────────────────────
646
647 class TestPerformance:
648 """SI34–SI36: normalized reads are fast regardless of repo size.
649 These budgets would be impossible with the blob approach at scale."""
650
651 @pytest.mark.asyncio
652 async def test_SI34_single_symbol_lookup_under_50ms(
653 self, db_session: AsyncSession,
654 ) -> None:
655 from musehub.services.musehub_symbol_indexer import lookup_symbol_intel
656 repo = await create_repo(db_session)
657 # Build a 50-commit index
658 parent: list[str] = []
659 last_id = ""
660 for i in range(50):
661 cid = _uid()
662 await _commit_with_delta(db_session, repo.repo_id, cid, [
663 _insert_op(f"src/file_{i}.py::fn_{i}"),
664 _insert_op(f"src/file_{i}.py::helper_{i}"),
665 ], parent_ids=parent)
666 parent = [cid]
667 last_id = cid
668 await _build_and_persist(db_session, repo.repo_id, last_id)
669
670 target = "src/file_25.py::fn_25"
671 t0 = time.perf_counter()
672 result = await lookup_symbol_intel(db_session, repo.repo_id, [target])
673 elapsed_ms = (time.perf_counter() - t0) * 1000
674 assert target in result
675 assert elapsed_ms < 50, f"point lookup took {elapsed_ms:.1f}ms — too slow"
676
677 @pytest.mark.asyncio
678 async def test_SI35_file_scoped_history_lookup_under_50ms(
679 self, db_session: AsyncSession,
680 ) -> None:
681 from musehub.services.musehub_symbol_indexer import load_symbol_history
682 repo = await create_repo(db_session)
683 parent: list[str] = []
684 last_id = ""
685 for i in range(50):
686 cid = _uid()
687 await _commit_with_delta(db_session, repo.repo_id, cid, [
688 _insert_op(f"src/other_{i}.py::fn"),
689 _insert_op("src/target.py::hot_fn"),
690 ], parent_ids=parent)
691 parent = [cid]
692 last_id = cid
693 await _build_and_persist(db_session, repo.repo_id, last_id)
694
695 t0 = time.perf_counter()
696 history = await load_symbol_history(db_session, repo.repo_id, file_path="src/target.py")
697 elapsed_ms = (time.perf_counter() - t0) * 1000
698 assert "src/target.py::hot_fn" in history
699 assert elapsed_ms < 50, f"file-scoped lookup took {elapsed_ms:.1f}ms"
700
701 @pytest.mark.asyncio
702 async def test_SI36_load_symbol_history_no_file_filter_returns_all(
703 self, db_session: AsyncSession,
704 ) -> None:
705 from musehub.services.musehub_symbol_indexer import load_symbol_history
706 repo = await create_repo(db_session)
707 cid = _uid()
708 await _commit_with_delta(db_session, repo.repo_id, cid, [
709 _insert_op("src/a.py::fn_a"),
710 _insert_op("src/b.py::fn_b"),
711 _insert_op("src/c.py::fn_c"),
712 ])
713 await _build_and_persist(db_session, repo.repo_id, cid)
714 history = await load_symbol_history(db_session, repo.repo_id)
715 assert {"src/a.py::fn_a", "src/b.py::fn_b", "src/c.py::fn_c"} <= set(history.keys())
716
717
718 # ─────────────────────────────────────────────────────────────────────────────
719 # Layer 7 — Stress: 500 symbols × realistic commit volume
720 # ─────────────────────────────────────────────────────────────────────────────
721
722 class TestStress:
723 """SI37–SI38: large repos index without timeout or corruption."""
724
725 @pytest.mark.asyncio
726 async def test_SI37_index_500_symbols_across_10_commits(
727 self, db_session: AsyncSession,
728 ) -> None:
729 from musehub.db.musehub_models import MusehubSymbolHistoryEntry
730 repo = await create_repo(db_session)
731 symbols = [f"src/module_{i // 10}.py::fn_{i}" for i in range(500)]
732 parent: list[str] = []
733 last_id = ""
734 chunk = len(symbols) // 10
735 for batch_idx in range(10):
736 cid = _uid()
737 ops = [_insert_op(s) for s in symbols[batch_idx * chunk:(batch_idx + 1) * chunk]]
738 await _commit_with_delta(db_session, repo.repo_id, cid, ops, parent_ids=parent)
739 parent = [cid]
740 last_id = cid
741
742 t0 = time.perf_counter()
743 await _build_and_persist(db_session, repo.repo_id, last_id)
744 elapsed = time.perf_counter() - t0
745
746 count = (await db_session.execute(
747 select(func.count()).select_from(MusehubSymbolHistoryEntry).where(
748 MusehubSymbolHistoryEntry.repo_id == repo.repo_id
749 )
750 )).scalar_one()
751 assert count == 500
752 assert elapsed < 10.0, f"500-symbol index took {elapsed:.1f}s"
753
754 @pytest.mark.asyncio
755 async def test_SI38_same_symbol_modified_50_times(
756 self, db_session: AsyncSession,
757 ) -> None:
758 from musehub.db.musehub_models import MusehubSymbolHistoryEntry
759 from musehub.services.musehub_symbol_indexer import lookup_symbol_intel
760 repo = await create_repo(db_session)
761 addr = "src/hot.py::hot_fn"
762 parent: list[str] = []
763 last_id = ""
764 for i in range(50):
765 cid = _uid()
766 op_type = "insert" if i == 0 else "replace"
767 await _commit_with_delta(db_session, repo.repo_id, cid, [
768 {"address": addr, "op": op_type, "content_id": _cid()},
769 ], parent_ids=parent)
770 parent = [cid]
771 last_id = cid
772 await _build_and_persist(db_session, repo.repo_id, last_id)
773
774 rows = (await db_session.execute(
775 select(MusehubSymbolHistoryEntry).where(
776 MusehubSymbolHistoryEntry.repo_id == repo.repo_id,
777 MusehubSymbolHistoryEntry.address == addr,
778 )
779 )).scalars().all()
780 assert len(rows) == 50
781
782 intel = await lookup_symbol_intel(db_session, repo.repo_id, [addr])
783 assert intel[addr]["churn"] == 50
784
785
786 # ─────────────────────────────────────────────────────────────────────────────
787 # Layer 8 — Aggregates: intel_summary and intel_snapshot still produced
788 # ─────────────────────────────────────────────────────────────────────────────
789
790 class TestAggregatesStillWork:
791 """SI39–SI40: aggregate outputs (summary, snapshot) are unaffected."""
792
793 @pytest.mark.asyncio
794 async def test_SI39_intel_summary_fields_correct(
795 self, db_session: AsyncSession,
796 ) -> None:
797 from musehub.db.musehub_models import MusehubIntelResult
798 repo = await create_repo(db_session)
799 cid = _uid()
800 await _commit_with_delta(db_session, repo.repo_id, cid, [
801 _insert_op("src/a.py::fn1"),
802 _insert_op("src/b.py::fn2"),
803 _insert_op("src/c.py::fn3"),
804 ])
805 await _build_and_persist(db_session, repo.repo_id, cid)
806 row = (await db_session.execute(
807 select(MusehubIntelResult).where(
808 MusehubIntelResult.repo_id == repo.repo_id,
809 MusehubIntelResult.intel_type == "code.intel_summary",
810 )
811 )).scalar_one()
812 data = json.loads(row.data_json)
813 assert data.get("symbol_count", 0) >= 3
814 assert "health_score" in data
815 assert "health_label" in data
816
817 @pytest.mark.asyncio
818 async def test_SI40_rebuild_updates_summary_symbol_count(
819 self, db_session: AsyncSession,
820 ) -> None:
821 from musehub.db.musehub_models import MusehubIntelResult
822 repo = await create_repo(db_session)
823 c1, c2 = _uid(), _uid()
824 await _commit_with_delta(db_session, repo.repo_id, c1, [_insert_op("src/a.py::fn1")])
825 await _build_and_persist(db_session, repo.repo_id, c1)
826 await _commit_with_delta(db_session, repo.repo_id, c2, [
827 _insert_op("src/b.py::fn2"),
828 _insert_op("src/c.py::fn3"),
829 ], parent_ids=[c1])
830 await _build_and_persist(db_session, repo.repo_id, c2)
831
832 row = (await db_session.execute(
833 select(MusehubIntelResult).where(
834 MusehubIntelResult.repo_id == repo.repo_id,
835 MusehubIntelResult.intel_type == "code.intel_summary",
836 )
837 )).scalar_one()
838 data = json.loads(row.data_json)
839 assert data.get("symbol_count", 0) >= 3
File History 1 commit
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor 142 days ago