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