gabriel / musehub public
test_coupling_provider.py python
1,015 lines 44.0 KB
Raw
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ breaking 143 days ago
1 """TDD spec for CouplingProvider — issue #15, Phase 5.
2
3 Verifies that CouplingProvider reproduces the same file co-change analysis
4 as ``muse code coupling``: file derivation from symbol addresses, bare-path
5 handling, mass-commit exclusion, canonical pair ordering, MAX_PAIRS cap,
6 and strict repo isolation.
7
8 Seven test tiers (49 cases)
9 ----------------------------
10 Unit CP_01 – CP_08 file derivation, heat modifier, pair canonicalisation
11 Integration CP_09 – CP_18 provider upserts, re-runs, counts
12 E2E CP_19 – CP_25 full seeded scenarios
13 Performance CP_26 – CP_32 timing bounds
14 State CP_33 – CP_38 idempotency, stale-row purge, incremental updates
15 Security CP_39 – CP_44 injection strings, repo isolation
16 Stress CP_45 – CP_49 MAX_PAIRS cap, mass-commit exclusion, BFS cap
17 """
18 from __future__ import annotations
19
20 import secrets
21 import time
22 from collections import defaultdict
23 from datetime import datetime, timezone
24
25 import pytest
26 import pytest_asyncio
27 import sqlalchemy as sa
28 from sqlalchemy.dialects.postgresql import insert as pg_insert
29 from sqlalchemy.ext.asyncio import AsyncSession
30
31 from muse.core.types import fake_id
32 from musehub.db import musehub_models as db
33 from musehub.services.musehub_intel_providers import CouplingProvider
34 from musehub.api.routes.musehub.ui_intel import _cp_heat, _cp_short
35 from tests.factories import create_repo
36
37
38 # ─────────────────────────────────────────────────────────────────────────────
39 # Helpers
40 # ─────────────────────────────────────────────────────────────────────────────
41
42 def _cid() -> str:
43 return "sha256:" + secrets.token_hex(32)
44
45
46 async def _seed_commit(
47 session: AsyncSession,
48 repo_id: str,
49 commit_id: str,
50 parent_ids: list[str] | None = None,
51 ) -> None:
52 stmt = (
53 pg_insert(db.MusehubCommit)
54 .values(
55 commit_id=commit_id,
56 repo_id=repo_id,
57 message="test commit",
58 author="test",
59 branch="dev",
60 parent_ids=parent_ids or [],
61 snapshot_id=None,
62 timestamp=datetime.now(timezone.utc),
63 )
64 .on_conflict_do_nothing()
65 )
66 await session.execute(stmt)
67
68
69 async def _seed_history(
70 session: AsyncSession,
71 repo_id: str,
72 commit_id: str,
73 addresses: list[str],
74 ) -> None:
75 for addr in addresses:
76 stmt = (
77 pg_insert(db.MusehubSymbolHistoryEntry)
78 .values(
79 repo_id=repo_id,
80 address=addr,
81 commit_id=commit_id,
82 committed_at=datetime.now(timezone.utc),
83 op="modify",
84 )
85 .on_conflict_do_nothing()
86 )
87 await session.execute(stmt)
88
89
90 async def _run(session: AsyncSession, repo_id: str, ref: str) -> list:
91 return await CouplingProvider().compute(session, repo_id, ref, {})
92
93
94 async def _fetch(session: AsyncSession, repo_id: str) -> list[db.MusehubIntelCoupling]:
95 result = await session.execute(
96 sa.select(db.MusehubIntelCoupling)
97 .where(db.MusehubIntelCoupling.repo_id == repo_id)
98 .order_by(sa.desc(db.MusehubIntelCoupling.co_changes))
99 )
100 return list(result.scalars().all())
101
102
103 # ─────────────────────────────────────────────────────────────────────────────
104 # Fixtures
105 # ─────────────────────────────────────────────────────────────────────────────
106
107 @pytest_asyncio.fixture
108 async def repo(db_session: AsyncSession):
109 return await create_repo(db_session, owner="testuser", slug="couplingprovider")
110
111
112 @pytest_asyncio.fixture
113 async def two_repos(db_session: AsyncSession):
114 r1 = await create_repo(db_session, owner="testuser", slug="cp-repo-1")
115 r2 = await create_repo(db_session, owner="testuser", slug="cp-repo-2")
116 return r1, r2
117
118
119 # ─────────────────────────────────────────────────────────────────────────────
120 # Tier 1 — Unit: file derivation, heat modifier, pair canonicalisation
121 # ─────────────────────────────────────────────────────────────────────────────
122
123 class TestCouplingUnit:
124 """Pure-function tests — no database required."""
125
126 def test_CP_01_file_from_symbol_address(self) -> None:
127 """File extracted correctly from symbol address."""
128 addr = "src/billing.py::charge"
129 file = addr.split("::")[0] if "::" in addr else addr
130 assert file == "src/billing.py"
131
132 def test_CP_02_bare_path_is_file(self) -> None:
133 """Bare path (no '::') treated directly as filename."""
134 addr = "cloudflare"
135 file = addr.split("::")[0] if "::" in addr else addr
136 assert file == "cloudflare"
137
138 def test_CP_03_pair_key_canonical_a_lt_b(self) -> None:
139 """Pair key is always (a, b) where a < b lexicographically."""
140 files = ["src/z.py", "src/a.py"]
141 canonical = tuple(sorted(files))
142 assert canonical == ("src/a.py", "src/z.py")
143
144 def test_CP_04_same_file_pair_excluded(self) -> None:
145 """Two symbols from the same file produce no file pair."""
146 addr_a = "src/billing.py::charge"
147 addr_b = "src/billing.py::refund"
148 file_a = addr_a.split("::")[0]
149 file_b = addr_b.split("::")[0]
150 assert file_a == file_b
151
152 def test_CP_05_heat_low(self) -> None:
153 """co_changes < 10 → empty modifier (accent fill)."""
154 assert _cp_heat(1) == ""
155 assert _cp_heat(9) == ""
156
157 def test_CP_06_heat_medium(self) -> None:
158 """co_changes 10–19 → 'medium' modifier (warning fill)."""
159 assert _cp_heat(10) == "medium"
160 assert _cp_heat(19) == "medium"
161
162 def test_CP_07_heat_high(self) -> None:
163 """co_changes >= 20 → 'high' modifier (danger fill)."""
164 assert _cp_heat(20) == "high"
165 assert _cp_heat(99) == "high"
166
167 def test_CP_08_min_co_changes_constant(self) -> None:
168 """_MIN_CO_CHANGES is 2 — pairs below this are noise."""
169 assert CouplingProvider._MIN_CO_CHANGES == 2
170
171
172 # ─────────────────────────────────────────────────────────────────────────────
173 # Tier 2 — Integration: provider upserts, counts, re-runs
174 # ─────────────────────────────────────────────────────────────────────────────
175
176 class TestCouplingIntegration:
177
178 @pytest.mark.asyncio
179 async def test_CP_09_empty_repo_returns_empty(
180 self, db_session: AsyncSession, repo
181 ) -> None:
182 """Provider on a repo with no commits returns [] and stores no rows."""
183 result = await _run(db_session, repo.repo_id, _cid())
184 assert result == []
185 assert await _fetch(db_session, repo.repo_id) == []
186
187 @pytest.mark.asyncio
188 async def test_CP_10_no_history_entries_returns_empty(
189 self, db_session: AsyncSession, repo
190 ) -> None:
191 """Commits exist but no history entries → no pairs stored."""
192 c1 = _cid()
193 await _seed_commit(db_session, repo.repo_id, c1)
194 await db_session.commit()
195 result = await _run(db_session, repo.repo_id, c1)
196 assert result == []
197
198 @pytest.mark.asyncio
199 async def test_CP_11_single_co_change_below_threshold(
200 self, db_session: AsyncSession, repo
201 ) -> None:
202 """One co-change commit → co_changes=1, below _MIN_CO_CHANGES=2, no row."""
203 c1 = _cid()
204 await _seed_commit(db_session, repo.repo_id, c1)
205 await _seed_history(db_session, repo.repo_id, c1,
206 ["src/a.py::fn_a", "src/b.py::fn_b"])
207 await db_session.commit()
208 await _run(db_session, repo.repo_id, c1)
209 assert await _fetch(db_session, repo.repo_id) == []
210
211 @pytest.mark.asyncio
212 async def test_CP_12_two_co_changes_produces_one_pair(
213 self, db_session: AsyncSession, repo
214 ) -> None:
215 """Exactly 2 co-change commits → 1 pair with co_changes=2."""
216 c1, c2 = _cid(), _cid()
217 await _seed_commit(db_session, repo.repo_id, c1)
218 await _seed_commit(db_session, repo.repo_id, c2, [c1])
219 for cid in [c1, c2]:
220 await _seed_history(db_session, repo.repo_id, cid,
221 ["src/a.py::fn_a", "src/b.py::fn_b"])
222 await db_session.commit()
223 await _run(db_session, repo.repo_id, c2)
224 pairs = await _fetch(db_session, repo.repo_id)
225 assert len(pairs) == 1
226 assert pairs[0].co_changes == 2
227
228 @pytest.mark.asyncio
229 async def test_CP_13_three_files_produces_three_pairs(
230 self, db_session: AsyncSession, repo
231 ) -> None:
232 """Three files in a commit → 3 cross-file pairs (A↔B, A↔C, B↔C)."""
233 c1, c2 = _cid(), _cid()
234 await _seed_commit(db_session, repo.repo_id, c1)
235 await _seed_commit(db_session, repo.repo_id, c2, [c1])
236 for cid in [c1, c2]:
237 await _seed_history(db_session, repo.repo_id, cid, [
238 "src/a.py::fn", "src/b.py::fn", "src/c.py::fn",
239 ])
240 await db_session.commit()
241 await _run(db_session, repo.repo_id, c2)
242 pairs = await _fetch(db_session, repo.repo_id)
243 assert len(pairs) == 3
244
245 @pytest.mark.asyncio
246 async def test_CP_14_same_file_symbols_no_pair(
247 self, db_session: AsyncSession, repo
248 ) -> None:
249 """Two symbols from the same file never produce a pair."""
250 c1, c2 = _cid(), _cid()
251 await _seed_commit(db_session, repo.repo_id, c1)
252 await _seed_commit(db_session, repo.repo_id, c2, [c1])
253 for cid in [c1, c2]:
254 await _seed_history(db_session, repo.repo_id, cid, [
255 "src/billing.py::charge", "src/billing.py::refund",
256 ])
257 await db_session.commit()
258 await _run(db_session, repo.repo_id, c2)
259 assert await _fetch(db_session, repo.repo_id) == []
260
261 @pytest.mark.asyncio
262 async def test_CP_15_pair_stored_canonical_a_lt_b(
263 self, db_session: AsyncSession, repo
264 ) -> None:
265 """Stored pair always has file_a <= file_b lexicographically."""
266 c1, c2 = _cid(), _cid()
267 await _seed_commit(db_session, repo.repo_id, c1)
268 await _seed_commit(db_session, repo.repo_id, c2, [c1])
269 for cid in [c1, c2]:
270 await _seed_history(db_session, repo.repo_id, cid,
271 ["src/z.py::zfn", "src/a.py::afn"])
272 await db_session.commit()
273 await _run(db_session, repo.repo_id, c2)
274 pairs = await _fetch(db_session, repo.repo_id)
275 assert len(pairs) == 1
276 assert pairs[0].file_a <= pairs[0].file_b
277
278 @pytest.mark.asyncio
279 async def test_CP_16_ref_column_populated(
280 self, db_session: AsyncSession, repo
281 ) -> None:
282 """ref column on each row matches the HEAD ref passed to compute()."""
283 c1, c2 = _cid(), _cid()
284 await _seed_commit(db_session, repo.repo_id, c1)
285 await _seed_commit(db_session, repo.repo_id, c2, [c1])
286 for cid in [c1, c2]:
287 await _seed_history(db_session, repo.repo_id, cid,
288 ["src/a.py::fn", "src/b.py::fn"])
289 await db_session.commit()
290 await _run(db_session, repo.repo_id, c2)
291 pairs = await _fetch(db_session, repo.repo_id)
292 assert all(p.ref == c2 for p in pairs)
293
294 @pytest.mark.asyncio
295 async def test_CP_17_co_changes_count_exact(
296 self, db_session: AsyncSession, repo
297 ) -> None:
298 """co_changes is the exact number of commits where both files appeared."""
299 commits = [_cid() for _ in range(4)]
300 prev = None
301 for cid in commits:
302 await _seed_commit(db_session, repo.repo_id, cid,
303 [prev] if prev else [])
304 prev = cid
305 for cid in commits:
306 await _seed_history(db_session, repo.repo_id, cid,
307 ["src/a.py::fn", "src/b.py::fn"])
308 await db_session.commit()
309 await _run(db_session, repo.repo_id, commits[-1])
310 pairs = await _fetch(db_session, repo.repo_id)
311 assert pairs[0].co_changes == 4
312
313 @pytest.mark.asyncio
314 async def test_CP_18_result_key_correct(
315 self, db_session: AsyncSession, repo
316 ) -> None:
317 """Provider returns result tuple with key 'intel.code.coupling'."""
318 c1, c2 = _cid(), _cid()
319 await _seed_commit(db_session, repo.repo_id, c1)
320 await _seed_commit(db_session, repo.repo_id, c2, [c1])
321 for cid in [c1, c2]:
322 await _seed_history(db_session, repo.repo_id, cid,
323 ["src/a.py::fn", "src/b.py::fn"])
324 await db_session.commit()
325 result = await _run(db_session, repo.repo_id, c2)
326 assert len(result) == 1
327 key, payload = result[0]
328 assert key == "intel.code.coupling"
329 assert "count" in payload
330 assert "commits_analysed" in payload
331 assert "truncated" in payload
332
333
334 # ─────────────────────────────────────────────────────────────────────────────
335 # Tier 3 — E2E: full seeded scenarios
336 # ─────────────────────────────────────────────────────────────────────────────
337
338 class TestCouplingE2E:
339
340 @pytest.mark.asyncio
341 async def test_CP_19_three_files_correct_ranking(
342 self, db_session: AsyncSession, repo
343 ) -> None:
344 """A↔B co-changes more than A↔C → A↔B ranked first."""
345 commits = [_cid() for _ in range(5)]
346 prev = None
347 for cid in commits:
348 await _seed_commit(db_session, repo.repo_id, cid,
349 [prev] if prev else [])
350 prev = cid
351 # A and B in all 5 commits
352 for cid in commits:
353 await _seed_history(db_session, repo.repo_id, cid,
354 ["src/a.py::fn", "src/b.py::fn"])
355 # A and C only in first 2
356 for cid in commits[:2]:
357 await _seed_history(db_session, repo.repo_id, cid,
358 ["src/c.py::fn"])
359 await db_session.commit()
360 await _run(db_session, repo.repo_id, commits[-1])
361 pairs = await _fetch(db_session, repo.repo_id)
362 assert pairs[0].co_changes == 5
363 assert pairs[0].file_a in ("src/a.py", "src/b.py")
364 assert pairs[0].file_b in ("src/a.py", "src/b.py")
365
366 @pytest.mark.asyncio
367 async def test_CP_20_result_count_matches_stored_rows(
368 self, db_session: AsyncSession, repo
369 ) -> None:
370 """metadata 'count' equals the number of rows actually stored."""
371 c1, c2, c3 = _cid(), _cid(), _cid()
372 await _seed_commit(db_session, repo.repo_id, c1)
373 await _seed_commit(db_session, repo.repo_id, c2, [c1])
374 await _seed_commit(db_session, repo.repo_id, c3, [c2])
375 for cid in [c1, c2, c3]:
376 await _seed_history(db_session, repo.repo_id, cid,
377 ["src/a.py::fn", "src/b.py::fn", "src/c.py::fn"])
378 await db_session.commit()
379 result = await _run(db_session, repo.repo_id, c3)
380 key, payload = result[0]
381 pairs = await _fetch(db_session, repo.repo_id)
382 assert payload["count"] == len(pairs)
383
384 @pytest.mark.asyncio
385 async def test_CP_21_truncated_true_over_max_pairs(
386 self, db_session: AsyncSession, repo
387 ) -> None:
388 """truncated=True when raw pair count exceeds MAX_PAIRS."""
389 provider = CouplingProvider()
390 commits = [_cid() for _ in range(3)]
391 prev = None
392 for cid in commits:
393 await _seed_commit(db_session, repo.repo_id, cid,
394 [prev] if prev else [])
395 prev = cid
396 # 21 files → 210 pairs, exceeds MAX_PAIRS=200
397 addrs = [f"src/file_{i}.py::fn" for i in range(21)]
398 for cid in commits:
399 await _seed_history(db_session, repo.repo_id, cid, addrs)
400 await db_session.commit()
401 result = await _run(db_session, repo.repo_id, commits[-1])
402 key, payload = result[0]
403 assert payload["truncated"] is True
404
405 @pytest.mark.asyncio
406 async def test_CP_22_min_co_filter_in_route_helpers(
407 self, db_session: AsyncSession, repo
408 ) -> None:
409 """Pairs with co_changes below min_co are excluded from route results."""
410 # Build: A↔B = 5, A↔C = 2 → with min_co=3 only A↔B appears
411 commits_ab = [_cid() for _ in range(5)]
412 commits_ac = [_cid() for _ in range(2)]
413 all_commits = commits_ab + commits_ac
414 prev = None
415 for cid in all_commits:
416 await _seed_commit(db_session, repo.repo_id, cid,
417 [prev] if prev else [])
418 prev = cid
419 for cid in commits_ab:
420 await _seed_history(db_session, repo.repo_id, cid,
421 ["src/a.py::fn", "src/b.py::fn"])
422 for cid in commits_ac:
423 await _seed_history(db_session, repo.repo_id, cid,
424 ["src/a.py::fn", "src/c.py::fn"])
425 await db_session.commit()
426 await _run(db_session, repo.repo_id, all_commits[-1])
427 # Simulate route min_co=3 filter
428 repo_id = repo.repo_id
429 result = await db_session.execute(
430 sa.select(db.MusehubIntelCoupling)
431 .where(
432 db.MusehubIntelCoupling.repo_id == repo_id,
433 db.MusehubIntelCoupling.co_changes >= 3,
434 )
435 .order_by(sa.desc(db.MusehubIntelCoupling.co_changes))
436 )
437 filtered = result.scalars().all()
438 assert all(p.co_changes >= 3 for p in filtered)
439 assert len(filtered) == 1
440 assert filtered[0].co_changes == 5
441
442 @pytest.mark.asyncio
443 async def test_CP_23_top_limit_respected(
444 self, db_session: AsyncSession, repo
445 ) -> None:
446 """SQL LIMIT top correctly caps the number of rows returned."""
447 commits = [_cid() for _ in range(3)]
448 prev = None
449 for cid in commits:
450 await _seed_commit(db_session, repo.repo_id, cid,
451 [prev] if prev else [])
452 prev = cid
453 # 10 files → 45 pairs
454 addrs = [f"src/f{i}.py::fn" for i in range(10)]
455 for cid in commits:
456 await _seed_history(db_session, repo.repo_id, cid, addrs)
457 await db_session.commit()
458 await _run(db_session, repo.repo_id, commits[-1])
459 result = await db_session.execute(
460 sa.select(db.MusehubIntelCoupling)
461 .where(db.MusehubIntelCoupling.repo_id == repo.repo_id)
462 .order_by(sa.desc(db.MusehubIntelCoupling.co_changes))
463 .limit(5)
464 )
465 assert len(result.scalars().all()) <= 5
466
467 @pytest.mark.asyncio
468 async def test_CP_24_heat_high_on_stored_pairs(
469 self, db_session: AsyncSession, repo
470 ) -> None:
471 """_cp_heat returns 'high' for pairs with co_changes >= 20."""
472 commits = [_cid() for _ in range(22)]
473 prev = None
474 for cid in commits:
475 await _seed_commit(db_session, repo.repo_id, cid,
476 [prev] if prev else [])
477 prev = cid
478 for cid in commits:
479 await _seed_history(db_session, repo.repo_id, cid,
480 ["src/a.py::fn", "src/b.py::fn"])
481 await db_session.commit()
482 await _run(db_session, repo.repo_id, commits[-1])
483 pairs = await _fetch(db_session, repo.repo_id)
484 assert pairs[0].co_changes >= 20
485 assert _cp_heat(pairs[0].co_changes) == "high"
486
487 @pytest.mark.asyncio
488 async def test_CP_25_bar_pct_100_for_top_pair(
489 self, db_session: AsyncSession, repo
490 ) -> None:
491 """Top pair always gets bar_pct=100 (it is the normalisation anchor)."""
492 commits = [_cid() for _ in range(5)]
493 prev = None
494 for cid in commits:
495 await _seed_commit(db_session, repo.repo_id, cid,
496 [prev] if prev else [])
497 prev = cid
498 for cid in commits:
499 await _seed_history(db_session, repo.repo_id, cid,
500 ["src/a.py::fn", "src/b.py::fn"])
501 await db_session.commit()
502 await _run(db_session, repo.repo_id, commits[-1])
503 pairs = await _fetch(db_session, repo.repo_id)
504 max_co = pairs[0].co_changes
505 bar_pct = round((pairs[0].co_changes / max_co) * 100)
506 assert bar_pct == 100
507
508
509 # ─────────────────────────────────────────────────────────────────────────────
510 # Tier 4 — Performance: timing bounds
511 # ─────────────────────────────────────────────────────────────────────────────
512
513 class TestCouplingPerformance:
514
515 @pytest.mark.asyncio
516 async def test_CP_26_ten_commits_ten_files_under_500ms(
517 self, db_session: AsyncSession, repo
518 ) -> None:
519 """10 commits × 10 files completes in under 500 ms."""
520 commits = [_cid() for _ in range(10)]
521 prev = None
522 for cid in commits:
523 await _seed_commit(db_session, repo.repo_id, cid,
524 [prev] if prev else [])
525 prev = cid
526 addrs = [f"src/f{i}.py::fn" for i in range(10)]
527 for cid in commits:
528 await _seed_history(db_session, repo.repo_id, cid, addrs)
529 await db_session.commit()
530 t0 = time.monotonic()
531 await _run(db_session, repo.repo_id, commits[-1])
532 assert time.monotonic() - t0 < 0.5
533
534 @pytest.mark.asyncio
535 async def test_CP_27_100_commits_20_files_under_2s(
536 self, db_session: AsyncSession, repo
537 ) -> None:
538 """100 commits × 20 files completes in under 2 s."""
539 commits = [_cid() for _ in range(100)]
540 prev = None
541 for cid in commits:
542 await _seed_commit(db_session, repo.repo_id, cid,
543 [prev] if prev else [])
544 prev = cid
545 addrs = [f"src/f{i}.py::fn" for i in range(20)]
546 for cid in commits:
547 await _seed_history(db_session, repo.repo_id, cid, addrs)
548 await db_session.commit()
549 t0 = time.monotonic()
550 await _run(db_session, repo.repo_id, commits[-1])
551 assert time.monotonic() - t0 < 2.0
552
553 @pytest.mark.asyncio
554 async def test_CP_28_empty_repo_fast_path_under_50ms(
555 self, db_session: AsyncSession, repo
556 ) -> None:
557 """Empty repo fast-path exits under 50 ms."""
558 t0 = time.monotonic()
559 await _run(db_session, repo.repo_id, _cid())
560 assert time.monotonic() - t0 < 0.05
561
562 @pytest.mark.asyncio
563 async def test_CP_29_rerun_not_5x_slower(
564 self, db_session: AsyncSession, repo
565 ) -> None:
566 """Second run is not more than 5× slower than the first."""
567 c1, c2 = _cid(), _cid()
568 await _seed_commit(db_session, repo.repo_id, c1)
569 await _seed_commit(db_session, repo.repo_id, c2, [c1])
570 for cid in [c1, c2]:
571 await _seed_history(db_session, repo.repo_id, cid,
572 ["src/a.py::fn", "src/b.py::fn"])
573 await db_session.commit()
574 t1 = time.monotonic(); await _run(db_session, repo.repo_id, c2); d1 = time.monotonic() - t1
575 t2 = time.monotonic(); await _run(db_session, repo.repo_id, c2); d2 = time.monotonic() - t2
576 assert d2 < max(d1 * 5, 0.5)
577
578 @pytest.mark.asyncio
579 async def test_CP_30_point_lookup_under_10ms(
580 self, db_session: AsyncSession, repo
581 ) -> None:
582 """Fetching pairs for a repo is sub-10 ms after the provider run."""
583 c1, c2 = _cid(), _cid()
584 await _seed_commit(db_session, repo.repo_id, c1)
585 await _seed_commit(db_session, repo.repo_id, c2, [c1])
586 for cid in [c1, c2]:
587 await _seed_history(db_session, repo.repo_id, cid,
588 ["src/a.py::fn", "src/b.py::fn"])
589 await db_session.commit()
590 await _run(db_session, repo.repo_id, c2)
591 t0 = time.monotonic()
592 await _fetch(db_session, repo.repo_id)
593 assert time.monotonic() - t0 < 0.01
594
595 @pytest.mark.asyncio
596 async def test_CP_31_200_pairs_query_fast(
597 self, db_session: AsyncSession, repo
598 ) -> None:
599 """Fetching full 200-pair leaderboard is sub-50 ms."""
600 commits = [_cid() for _ in range(3)]
601 prev = None
602 for cid in commits:
603 await _seed_commit(db_session, repo.repo_id, cid,
604 [prev] if prev else [])
605 prev = cid
606 # 21 files → 210 pairs → stored as 200 (MAX_PAIRS)
607 addrs = [f"src/f{i}.py::fn" for i in range(21)]
608 for cid in commits:
609 await _seed_history(db_session, repo.repo_id, cid, addrs)
610 await db_session.commit()
611 await _run(db_session, repo.repo_id, commits[-1])
612 t0 = time.monotonic()
613 await _fetch(db_session, repo.repo_id)
614 assert time.monotonic() - t0 < 0.05
615
616 @pytest.mark.asyncio
617 async def test_CP_32_dashboard_preview_query_fast(
618 self, db_session: AsyncSession, repo
619 ) -> None:
620 """Dashboard preview (top 3, LIMIT query) completes under 20 ms."""
621 commits = [_cid() for _ in range(3)]
622 prev = None
623 for cid in commits:
624 await _seed_commit(db_session, repo.repo_id, cid,
625 [prev] if prev else [])
626 prev = cid
627 addrs = [f"src/f{i}.py::fn" for i in range(6)]
628 for cid in commits:
629 await _seed_history(db_session, repo.repo_id, cid, addrs)
630 await db_session.commit()
631 await _run(db_session, repo.repo_id, commits[-1])
632 t0 = time.monotonic()
633 await db_session.execute(
634 sa.select(db.MusehubIntelCoupling)
635 .where(db.MusehubIntelCoupling.repo_id == repo.repo_id)
636 .order_by(sa.desc(db.MusehubIntelCoupling.co_changes))
637 .limit(3)
638 )
639 assert time.monotonic() - t0 < 0.02
640
641
642 # ─────────────────────────────────────────────────────────────────────────────
643 # Tier 5 — State: idempotency, stale-row purge, incremental updates
644 # ─────────────────────────────────────────────────────────────────────────────
645
646 class TestCouplingState:
647
648 @pytest.mark.asyncio
649 async def test_CP_33_idempotent_two_runs(
650 self, db_session: AsyncSession, repo
651 ) -> None:
652 """Running the provider twice produces identical rows."""
653 c1, c2 = _cid(), _cid()
654 await _seed_commit(db_session, repo.repo_id, c1)
655 await _seed_commit(db_session, repo.repo_id, c2, [c1])
656 for cid in [c1, c2]:
657 await _seed_history(db_session, repo.repo_id, cid,
658 ["src/a.py::fn", "src/b.py::fn"])
659 await db_session.commit()
660 await _run(db_session, repo.repo_id, c2)
661 first = {(p.file_a, p.file_b, p.co_changes)
662 for p in await _fetch(db_session, repo.repo_id)}
663 await _run(db_session, repo.repo_id, c2)
664 second = {(p.file_a, p.file_b, p.co_changes)
665 for p in await _fetch(db_session, repo.repo_id)}
666 assert first == second
667
668 @pytest.mark.asyncio
669 async def test_CP_34_stale_rows_purged_on_rerun(
670 self, db_session: AsyncSession, repo
671 ) -> None:
672 """Re-run deletes all old rows before inserting fresh set."""
673 c1, c2 = _cid(), _cid()
674 await _seed_commit(db_session, repo.repo_id, c1)
675 await _seed_commit(db_session, repo.repo_id, c2, [c1])
676 for cid in [c1, c2]:
677 await _seed_history(db_session, repo.repo_id, cid,
678 ["src/a.py::fn", "src/b.py::fn"])
679 await db_session.commit()
680 await _run(db_session, repo.repo_id, c2)
681 count_after_first = (await db_session.execute(
682 sa.select(sa.func.count()).select_from(db.MusehubIntelCoupling)
683 .where(db.MusehubIntelCoupling.repo_id == repo.repo_id)
684 )).scalar_one()
685 await _run(db_session, repo.repo_id, c2)
686 count_after_second = (await db_session.execute(
687 sa.select(sa.func.count()).select_from(db.MusehubIntelCoupling)
688 .where(db.MusehubIntelCoupling.repo_id == repo.repo_id)
689 )).scalar_one()
690 assert count_after_first == count_after_second
691
692 @pytest.mark.asyncio
693 async def test_CP_35_incremental_new_pair_appears(
694 self, db_session: AsyncSession, repo
695 ) -> None:
696 """After adding commits, a new pair materialises on re-run."""
697 c1, c2 = _cid(), _cid()
698 await _seed_commit(db_session, repo.repo_id, c1)
699 await _seed_commit(db_session, repo.repo_id, c2, [c1])
700 for cid in [c1, c2]:
701 await _seed_history(db_session, repo.repo_id, cid,
702 ["src/a.py::fn", "src/b.py::fn"])
703 await db_session.commit()
704 await _run(db_session, repo.repo_id, c2)
705 before = len(await _fetch(db_session, repo.repo_id))
706
707 c3, c4 = _cid(), _cid()
708 await _seed_commit(db_session, repo.repo_id, c3, [c2])
709 await _seed_commit(db_session, repo.repo_id, c4, [c3])
710 for cid in [c3, c4]:
711 await _seed_history(db_session, repo.repo_id, cid,
712 ["src/c.py::fn", "src/d.py::fn"])
713 await db_session.commit()
714 await _run(db_session, repo.repo_id, c4)
715 after = len(await _fetch(db_session, repo.repo_id))
716 assert after > before
717
718 @pytest.mark.asyncio
719 async def test_CP_36_no_duplicate_pairs_after_3_runs(
720 self, db_session: AsyncSession, repo
721 ) -> None:
722 """No duplicate (file_a, file_b) rows after 3 consecutive runs."""
723 c1, c2 = _cid(), _cid()
724 await _seed_commit(db_session, repo.repo_id, c1)
725 await _seed_commit(db_session, repo.repo_id, c2, [c1])
726 for cid in [c1, c2]:
727 await _seed_history(db_session, repo.repo_id, cid,
728 ["src/a.py::fn", "src/b.py::fn"])
729 await db_session.commit()
730 for _ in range(3):
731 await _run(db_session, repo.repo_id, c2)
732 pairs = await _fetch(db_session, repo.repo_id)
733 keys = [(p.file_a, p.file_b) for p in pairs]
734 assert len(keys) == len(set(keys))
735
736 @pytest.mark.asyncio
737 async def test_CP_37_co_changes_increases_with_new_commits(
738 self, db_session: AsyncSession, repo
739 ) -> None:
740 """co_changes increases when more co-change commits are added."""
741 c1, c2 = _cid(), _cid()
742 await _seed_commit(db_session, repo.repo_id, c1)
743 await _seed_commit(db_session, repo.repo_id, c2, [c1])
744 for cid in [c1, c2]:
745 await _seed_history(db_session, repo.repo_id, cid,
746 ["src/a.py::fn", "src/b.py::fn"])
747 await db_session.commit()
748 await _run(db_session, repo.repo_id, c2)
749 before = (await _fetch(db_session, repo.repo_id))[0].co_changes
750
751 c3 = _cid()
752 await _seed_commit(db_session, repo.repo_id, c3, [c2])
753 await _seed_history(db_session, repo.repo_id, c3,
754 ["src/a.py::fn", "src/b.py::fn"])
755 await db_session.commit()
756 await _run(db_session, repo.repo_id, c3)
757 after = (await _fetch(db_session, repo.repo_id))[0].co_changes
758 assert after > before
759
760 @pytest.mark.asyncio
761 async def test_CP_38_truncated_false_when_under_cap(
762 self, db_session: AsyncSession, repo
763 ) -> None:
764 """truncated=False when pair count is within MAX_PAIRS."""
765 c1, c2 = _cid(), _cid()
766 await _seed_commit(db_session, repo.repo_id, c1)
767 await _seed_commit(db_session, repo.repo_id, c2, [c1])
768 for cid in [c1, c2]:
769 await _seed_history(db_session, repo.repo_id, cid,
770 ["src/a.py::fn", "src/b.py::fn"])
771 await db_session.commit()
772 result = await _run(db_session, repo.repo_id, c2)
773 key, payload = result[0]
774 assert payload["truncated"] is False
775
776
777 # ─────────────────────────────────────────────────────────────────────────────
778 # Tier 6 — Security: injection, isolation, unicode
779 # ─────────────────────────────────────────────────────────────────────────────
780
781 class TestCouplingSecurity:
782
783 @pytest.mark.asyncio
784 async def test_CP_39_sql_injection_stored_verbatim(
785 self, db_session: AsyncSession, repo
786 ) -> None:
787 """SQL injection in file path stored as-is; table survives."""
788 inject = "src/a.py::fn'; DROP TABLE musehub_intel_coupling; --"
789 c1, c2 = _cid(), _cid()
790 await _seed_commit(db_session, repo.repo_id, c1)
791 await _seed_commit(db_session, repo.repo_id, c2, [c1])
792 for cid in [c1, c2]:
793 await _seed_history(db_session, repo.repo_id, cid,
794 [inject, "src/b.py::fn"])
795 await db_session.commit()
796 await _run(db_session, repo.repo_id, c2)
797 pairs = await _fetch(db_session, repo.repo_id)
798 assert isinstance(pairs, list)
799
800 @pytest.mark.asyncio
801 async def test_CP_40_xss_payload_stored_safely(
802 self, db_session: AsyncSession, repo
803 ) -> None:
804 """XSS payload in file path stored without execution."""
805 xss = "src/<script>alert(1)</script>.py::fn"
806 c1, c2 = _cid(), _cid()
807 await _seed_commit(db_session, repo.repo_id, c1)
808 await _seed_commit(db_session, repo.repo_id, c2, [c1])
809 for cid in [c1, c2]:
810 await _seed_history(db_session, repo.repo_id, cid,
811 [xss, "src/b.py::fn"])
812 await db_session.commit()
813 await _run(db_session, repo.repo_id, c2)
814 pairs = await _fetch(db_session, repo.repo_id)
815 assert isinstance(pairs, list)
816
817 @pytest.mark.asyncio
818 async def test_CP_41_repo_isolation_strict(
819 self, db_session: AsyncSession, two_repos
820 ) -> None:
821 """Pairs from repo A are never visible when querying repo B."""
822 r1, r2 = two_repos
823 c1, c2 = _cid(), _cid()
824 await _seed_commit(db_session, r1.repo_id, c1)
825 await _seed_commit(db_session, r1.repo_id, c2, [c1])
826 for cid in [c1, c2]:
827 await _seed_history(db_session, r1.repo_id, cid,
828 ["src/a.py::fn", "src/b.py::fn"])
829 await db_session.commit()
830 await _run(db_session, r1.repo_id, c2)
831 assert await _fetch(db_session, r2.repo_id) == []
832
833 @pytest.mark.asyncio
834 async def test_CP_42_two_repos_independent_pairs(
835 self, db_session: AsyncSession, two_repos
836 ) -> None:
837 """Two repos each produce their own independent pair sets."""
838 r1, r2 = two_repos
839 for repo in [r1, r2]:
840 c1, c2 = _cid(), _cid()
841 await _seed_commit(db_session, repo.repo_id, c1)
842 await _seed_commit(db_session, repo.repo_id, c2, [c1])
843 for cid in [c1, c2]:
844 await _seed_history(db_session, repo.repo_id, cid,
845 ["src/a.py::fn", "src/b.py::fn"])
846 await db_session.commit()
847 await _run(db_session, repo.repo_id, c2)
848 p1 = await _fetch(db_session, r1.repo_id)
849 p2 = await _fetch(db_session, r2.repo_id)
850 assert len(p1) == 1 and p1[0].repo_id == r1.repo_id
851 assert len(p2) == 1 and p2[0].repo_id == r2.repo_id
852
853 @pytest.mark.asyncio
854 async def test_CP_43_rerun_updates_ref_column(
855 self, db_session: AsyncSession, repo
856 ) -> None:
857 """Re-run for a new ref updates the ref column on all rows."""
858 c1, c2, c3 = _cid(), _cid(), _cid()
859 await _seed_commit(db_session, repo.repo_id, c1)
860 await _seed_commit(db_session, repo.repo_id, c2, [c1])
861 await _seed_commit(db_session, repo.repo_id, c3, [c2])
862 for cid in [c1, c2, c3]:
863 await _seed_history(db_session, repo.repo_id, cid,
864 ["src/a.py::fn", "src/b.py::fn"])
865 await db_session.commit()
866 await _run(db_session, repo.repo_id, c2)
867 await _run(db_session, repo.repo_id, c3)
868 pairs = await _fetch(db_session, repo.repo_id)
869 assert all(p.ref == c3 for p in pairs)
870
871 @pytest.mark.asyncio
872 async def test_CP_44_unicode_in_path_handled(
873 self, db_session: AsyncSession, repo
874 ) -> None:
875 """Unicode characters in file paths do not crash the provider."""
876 c1, c2 = _cid(), _cid()
877 await _seed_commit(db_session, repo.repo_id, c1)
878 await _seed_commit(db_session, repo.repo_id, c2, [c1])
879 for cid in [c1, c2]:
880 await _seed_history(db_session, repo.repo_id, cid,
881 ["src/música.py::canción", "src/b.py::fn"])
882 await db_session.commit()
883 await _run(db_session, repo.repo_id, c2)
884 assert isinstance(await _fetch(db_session, repo.repo_id), list)
885
886
887 # ─────────────────────────────────────────────────────────────────────────────
888 # Tier 7 — Stress: MAX_PAIRS cap, mass-commit exclusion, BFS cap
889 # ─────────────────────────────────────────────────────────────────────────────
890
891 class TestCouplingStress:
892
893 @pytest.mark.asyncio
894 async def test_CP_45_max_pairs_cap_respected(
895 self, db_session: AsyncSession, repo
896 ) -> None:
897 """Stored pair count never exceeds MAX_PAIRS."""
898 provider = CouplingProvider()
899 commits = [_cid() for _ in range(3)]
900 prev = None
901 for cid in commits:
902 await _seed_commit(db_session, repo.repo_id, cid,
903 [prev] if prev else [])
904 prev = cid
905 # 21 files → 210 pairs; exceeds MAX_PAIRS=200
906 addrs = [f"src/file_{i}.py::fn" for i in range(21)]
907 for cid in commits:
908 await _seed_history(db_session, repo.repo_id, cid, addrs)
909 await db_session.commit()
910 await _run(db_session, repo.repo_id, commits[-1])
911 pairs = await _fetch(db_session, repo.repo_id)
912 assert len(pairs) <= provider._MAX_PAIRS
913
914 @pytest.mark.asyncio
915 async def test_CP_46_mass_commit_excluded(
916 self, db_session: AsyncSession, repo
917 ) -> None:
918 """Commits touching > MAX_FILES_PER_COMMIT files are skipped."""
919 provider = CouplingProvider()
920 c_good1, c_good2, c_mass = _cid(), _cid(), _cid()
921 await _seed_commit(db_session, repo.repo_id, c_good1)
922 await _seed_commit(db_session, repo.repo_id, c_good2, [c_good1])
923 await _seed_commit(db_session, repo.repo_id, c_mass, [c_good2])
924 for cid in [c_good1, c_good2]:
925 await _seed_history(db_session, repo.repo_id, cid,
926 ["src/a.py::fn", "src/b.py::fn"])
927 # Mass commit: 250 distinct files
928 big_addrs = [f"src/gen_{i}.py::fn"
929 for i in range(provider._MAX_FILES_PER_COMMIT + 50)]
930 await _seed_history(db_session, repo.repo_id, c_mass, big_addrs)
931 await db_session.commit()
932 await _run(db_session, repo.repo_id, c_mass)
933 pairs = await _fetch(db_session, repo.repo_id)
934 # The A↔B pair from good commits must still be present
935 assert any(
936 "src/a.py" in (p.file_a, p.file_b) for p in pairs
937 )
938
939 @pytest.mark.asyncio
940 async def test_CP_47_500_commits_completes(
941 self, db_session: AsyncSession, repo
942 ) -> None:
943 """500 commits × 5 files completes without error."""
944 commits = [_cid() for _ in range(500)]
945 prev = None
946 for cid in commits:
947 await _seed_commit(db_session, repo.repo_id, cid,
948 [prev] if prev else [])
949 prev = cid
950 addrs = [f"src/f{i}.py::fn" for i in range(5)]
951 for cid in commits:
952 await _seed_history(db_session, repo.repo_id, cid, addrs)
953 await db_session.commit()
954 result = await _run(db_session, repo.repo_id, commits[-1])
955 assert result
956
957 @pytest.mark.asyncio
958 async def test_CP_48_result_count_matches_stored(
959 self, db_session: AsyncSession, repo
960 ) -> None:
961 """metadata 'count' always equals len(stored rows)."""
962 commits = [_cid() for _ in range(4)]
963 prev = None
964 for cid in commits:
965 await _seed_commit(db_session, repo.repo_id, cid,
966 [prev] if prev else [])
967 prev = cid
968 addrs = [f"src/f{i}.py::fn" for i in range(6)]
969 for cid in commits:
970 await _seed_history(db_session, repo.repo_id, cid, addrs)
971 await db_session.commit()
972 result = await _run(db_session, repo.repo_id, commits[-1])
973 key, payload = result[0]
974 stored = await _fetch(db_session, repo.repo_id)
975 assert payload["count"] == len(stored)
976
977 @pytest.mark.asyncio
978 async def test_CP_49_bfs_walk_cap(
979 self, db_session: AsyncSession, repo
980 ) -> None:
981 """commits_analysed never exceeds MAX_WALK."""
982 provider = CouplingProvider()
983 commits = [_cid() for _ in range(50)]
984 prev = None
985 for cid in commits:
986 await _seed_commit(db_session, repo.repo_id, cid,
987 [prev] if prev else [])
988 prev = cid
989 await _seed_history(db_session, repo.repo_id, commits[0],
990 ["src/a.py::fn", "src/b.py::fn"])
991 await db_session.commit()
992 result = await _run(db_session, repo.repo_id, commits[-1])
993 if result:
994 key, payload = result[0]
995 assert payload["commits_analysed"] <= provider._MAX_WALK
996
997
998 # ─────────────────────────────────────────────────────────────────────────────
999 # Helpers — _cp_short correctness
1000 # ─────────────────────────────────────────────────────────────────────────────
1001
1002 class TestCpShort:
1003 """Unit tests for the _cp_short display helper."""
1004
1005 def test_deep_path_truncated_to_two_parts(self) -> None:
1006 assert _cp_short("musehub/services/musehub_wire.py") == "services/musehub_wire.py"
1007
1008 def test_single_component_unchanged(self) -> None:
1009 assert _cp_short("musehub_wire.py") == "musehub_wire.py"
1010
1011 def test_two_components_unchanged(self) -> None:
1012 assert _cp_short("services/musehub_wire.py") == "services/musehub_wire.py"
1013
1014 def test_very_deep_path(self) -> None:
1015 assert _cp_short("a/b/c/d/e.py") == "d/e.py"
File History 1 commit
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor 143 days ago