gabriel / musehub public
test_phase1_blast_risk_provider.py python
541 lines 19.2 KB
Raw
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ breaking 143 days ago
1 """TDD spec for Phase 1 — SQL-derived BlastRiskProvider (issue #11).
2
3 BlastRiskProvider replaces the muse-CLI subprocess approach with a pure SQL
4 derivation from `musehub_symbol_intel` blast and churn columns.
5
6 Risk score formula:
7 impact_score = min(blast / 50.0, 1.0) — normalized blast radius
8 churn_score = min(churn_30d / 20.0, 1.0) — normalized 30-day churn
9 test_gap_score = 1.0 — no coverage data → worst case
10 coupling_score = min(blast_cross / 10.0, 1.0) — normalized cross-domain blast
11
12 risk_score = round(
13 impact_score * 40 +
14 churn_score * 25 +
15 test_gap_score * 20 +
16 coupling_score * 15
17 )
18
19 Risk tiers:
20 critical → risk_score >= 75
21 high → risk_score >= 50
22 medium → risk_score >= 25
23 low → risk_score < 25
24
25 tracked_kinds = {function, async_function, method, async_method, class}
26 Only symbols with blast > 0 are candidates.
27
28 Layers:
29 Unit (no DB):
30 1. Registry — "intel.code.blast_risk" in _PROVIDER_REGISTRY
31 2. Protocol — satisfies IntelProvider
32 3. Dispatch — job_types_for_push("code") includes "intel.code.blast_risk"
33 job_types_for_push("midi") excludes "intel.code.blast_risk"
34 4. Tier thresholds — _risk_tier boundaries at 75/50/25
35 5. Score formula — weights sum correctly, all-max → 100, all-zero → 0
36
37 Integration (DB):
38 6. High blast+churn → critical tier
39 7. Zero blast → excluded
40 8. Untracked kind → excluded
41 9. risk_score capped at 100
42 10. Idempotent — run twice, one row per address
43 11. Return type — [("intel.code.blast_risk", {"count": N})]
44 12. Empty repo → []
45
46 State integrity:
47 13. Re-run updates risk_score in-place (upsert, not duplicate)
48 14. Upsert does not touch symbol_intel blast/churn columns
49 15. ref column updated to latest ref on each run
50
51 Performance:
52 16. 1000 symbol rows processed in < 5 seconds
53
54 No subprocess:
55 17. compute() never calls asyncio.create_subprocess_exec
56 """
57 from __future__ import annotations
58
59 import secrets
60 import time
61 from unittest.mock import patch
62
63 import pytest
64 import pytest_asyncio
65 from sqlalchemy import select
66 from sqlalchemy.dialects.postgresql import insert as pg_insert
67 from sqlalchemy.ext.asyncio import AsyncSession
68
69 from muse.core.types import fake_id
70 from musehub.db import musehub_models as db
71 from tests.factories import create_repo
72
73
74 def _uid() -> str:
75 return fake_id(secrets.token_hex(16))
76
77
78 _TRACKED_KINDS = ("function", "async_function", "method", "async_method", "class")
79
80 _REF_A = "sha256:" + "a" * 64
81 _REF_B = "sha256:" + "b" * 64
82
83
84 # ---------------------------------------------------------------------------
85 # Helpers
86 # ---------------------------------------------------------------------------
87
88 async def _seed_symbol(
89 session: AsyncSession,
90 repo_id: str,
91 *,
92 address: str,
93 kind: str = "function",
94 blast: int = 10,
95 blast_direct: int = 5,
96 blast_cross: int = 2,
97 churn: int = 5,
98 churn_30d: int = 3,
99 churn_90d: int = 4,
100 ) -> None:
101 stmt = (
102 pg_insert(db.MusehubSymbolIntel)
103 .values(
104 repo_id=repo_id,
105 address=address,
106 symbol_kind=kind,
107 blast=blast,
108 blast_direct=blast_direct,
109 blast_cross=blast_cross,
110 churn=churn,
111 churn_30d=churn_30d,
112 churn_90d=churn_90d,
113 author_count=1,
114 gravity=0.0,
115 weekly=[0] * 12,
116 blast_top=[],
117 )
118 .on_conflict_do_update(
119 index_elements=["repo_id", "address"],
120 set_={
121 "symbol_kind": kind,
122 "blast": blast,
123 "blast_direct": blast_direct,
124 "blast_cross": blast_cross,
125 "churn": churn,
126 "churn_30d": churn_30d,
127 "churn_90d": churn_90d,
128 },
129 )
130 )
131 await session.execute(stmt)
132 await session.flush()
133
134
135 async def _get_risk_row(
136 session: AsyncSession, repo_id: str, address: str
137 ) -> db.MusehubIntelBlastRisk | None:
138 result = await session.execute(
139 select(db.MusehubIntelBlastRisk).where(
140 db.MusehubIntelBlastRisk.repo_id == repo_id,
141 db.MusehubIntelBlastRisk.address == address,
142 )
143 )
144 return result.scalar_one_or_none()
145
146
147 async def _run_provider(session: AsyncSession, repo_id: str, ref: str = _REF_A) -> list:
148 from musehub.services.musehub_intel_providers import BlastRiskProvider
149 provider = BlastRiskProvider()
150 return await provider.compute(session, repo_id, ref, {})
151
152
153 # ---------------------------------------------------------------------------
154 # Layer 1 — Registry
155 # ---------------------------------------------------------------------------
156
157 class TestBlastRiskRegistry:
158
159 def test_P1_01_blast_risk_in_provider_registry(self) -> None:
160 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
161 assert "intel.code.blast_risk" in _PROVIDER_REGISTRY
162
163 def test_P1_02_blast_risk_satisfies_intel_provider_protocol(self) -> None:
164 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY, IntelProvider
165 provider = _PROVIDER_REGISTRY["intel.code.blast_risk"]
166 assert isinstance(provider, IntelProvider)
167
168
169 # ---------------------------------------------------------------------------
170 # Layer 2 — Dispatch
171 # ---------------------------------------------------------------------------
172
173 class TestBlastRiskDispatch:
174
175 def test_P1_03_job_types_for_push_code_includes_blast_risk(self) -> None:
176 from musehub.services.musehub_intel_providers import job_types_for_push
177 assert "intel.code.blast_risk" in job_types_for_push("code")
178
179 def test_P1_04_job_types_for_push_midi_excludes_blast_risk(self) -> None:
180 from musehub.services.musehub_intel_providers import job_types_for_push
181 assert "intel.code.blast_risk" not in job_types_for_push("midi")
182
183
184 # ---------------------------------------------------------------------------
185 # Layer 3 — Tier thresholds (unit, no DB)
186 # ---------------------------------------------------------------------------
187
188 class TestRiskTierThresholds:
189
190 def test_P1_05_score_75_is_critical(self) -> None:
191 from musehub.services.musehub_intel_providers import _risk_tier
192 assert _risk_tier(75) == "critical"
193
194 def test_P1_06_score_100_is_critical(self) -> None:
195 from musehub.services.musehub_intel_providers import _risk_tier
196 assert _risk_tier(100) == "critical"
197
198 def test_P1_07_score_50_is_high(self) -> None:
199 from musehub.services.musehub_intel_providers import _risk_tier
200 assert _risk_tier(50) == "high"
201
202 def test_P1_08_score_74_is_high(self) -> None:
203 from musehub.services.musehub_intel_providers import _risk_tier
204 assert _risk_tier(74) == "high"
205
206 def test_P1_09_score_25_is_medium(self) -> None:
207 from musehub.services.musehub_intel_providers import _risk_tier
208 assert _risk_tier(25) == "medium"
209
210 def test_P1_10_score_49_is_medium(self) -> None:
211 from musehub.services.musehub_intel_providers import _risk_tier
212 assert _risk_tier(49) == "medium"
213
214 def test_P1_11_score_24_is_low(self) -> None:
215 from musehub.services.musehub_intel_providers import _risk_tier
216 assert _risk_tier(24) == "low"
217
218 def test_P1_11b_score_0_is_low(self) -> None:
219 from musehub.services.musehub_intel_providers import _risk_tier
220 assert _risk_tier(0) == "low"
221
222
223 # ---------------------------------------------------------------------------
224 # Layer 4 — Score formula (unit, no DB)
225 # ---------------------------------------------------------------------------
226
227 class TestRiskScoreFormula:
228
229 def test_P1_12_all_max_inputs_yield_100(self) -> None:
230 from musehub.services.musehub_intel_providers import _compute_risk_score
231 score = _compute_risk_score(
232 impact_score=1.0,
233 churn_score=1.0,
234 test_gap_score=1.0,
235 coupling_score=1.0,
236 )
237 assert score == 100
238
239 def test_P1_13_all_zero_inputs_yield_0(self) -> None:
240 from musehub.services.musehub_intel_providers import _compute_risk_score
241 score = _compute_risk_score(
242 impact_score=0.0,
243 churn_score=0.0,
244 test_gap_score=0.0,
245 coupling_score=0.0,
246 )
247 assert score == 0
248
249 def test_P1_14_impact_weight_is_40(self) -> None:
250 from musehub.services.musehub_intel_providers import _compute_risk_score
251 score = _compute_risk_score(
252 impact_score=1.0,
253 churn_score=0.0,
254 test_gap_score=0.0,
255 coupling_score=0.0,
256 )
257 assert score == 40
258
259 def test_P1_15_churn_weight_is_25(self) -> None:
260 from musehub.services.musehub_intel_providers import _compute_risk_score
261 score = _compute_risk_score(
262 impact_score=0.0,
263 churn_score=1.0,
264 test_gap_score=0.0,
265 coupling_score=0.0,
266 )
267 assert score == 25
268
269 def test_P1_16_test_gap_weight_is_20(self) -> None:
270 from musehub.services.musehub_intel_providers import _compute_risk_score
271 score = _compute_risk_score(
272 impact_score=0.0,
273 churn_score=0.0,
274 test_gap_score=1.0,
275 coupling_score=0.0,
276 )
277 assert score == 20
278
279 def test_P1_17_coupling_weight_is_15(self) -> None:
280 from musehub.services.musehub_intel_providers import _compute_risk_score
281 score = _compute_risk_score(
282 impact_score=0.0,
283 churn_score=0.0,
284 test_gap_score=0.0,
285 coupling_score=1.0,
286 )
287 assert score == 15
288
289
290 # ---------------------------------------------------------------------------
291 # Layer 5 — Integration: confidence tiers via DB
292 # ---------------------------------------------------------------------------
293
294 class TestBlastRiskIntegration:
295
296 @pytest.mark.asyncio
297 async def test_P1_18_high_blast_high_churn_yields_critical(
298 self, db_session: AsyncSession
299 ) -> None:
300 repo = await create_repo(db_session)
301 # blast=50 → impact=1.0, churn_30d=20 → churn=1.0 → score=100 → critical
302 await _seed_symbol(
303 db_session, repo.repo_id,
304 address="pkg/a.py::risky_fn",
305 blast=50, blast_cross=10, churn_30d=20,
306 )
307 await _run_provider(db_session, repo.repo_id)
308 row = await _get_risk_row(db_session, repo.repo_id, "pkg/a.py::risky_fn")
309 assert row is not None
310 assert row.risk == "critical"
311
312 @pytest.mark.asyncio
313 async def test_P1_19_zero_blast_excluded(
314 self, db_session: AsyncSession
315 ) -> None:
316 repo = await create_repo(db_session)
317 await _seed_symbol(
318 db_session, repo.repo_id,
319 address="pkg/b.py::no_blast",
320 blast=0, churn_30d=10,
321 )
322 await _run_provider(db_session, repo.repo_id)
323 row = await _get_risk_row(db_session, repo.repo_id, "pkg/b.py::no_blast")
324 assert row is None
325
326 @pytest.mark.asyncio
327 async def test_P1_20_untracked_kind_excluded(
328 self, db_session: AsyncSession
329 ) -> None:
330 repo = await create_repo(db_session)
331 await _seed_symbol(
332 db_session, repo.repo_id,
333 address="pkg/c.py::some_import",
334 kind="import",
335 blast=20, churn_30d=5,
336 )
337 await _run_provider(db_session, repo.repo_id)
338 row = await _get_risk_row(db_session, repo.repo_id, "pkg/c.py::some_import")
339 assert row is None
340
341 @pytest.mark.asyncio
342 async def test_P1_21_risk_score_capped_at_100(
343 self, db_session: AsyncSession
344 ) -> None:
345 repo = await create_repo(db_session)
346 # Extreme inputs — all scores at 1.0 → should be exactly 100, never over
347 await _seed_symbol(
348 db_session, repo.repo_id,
349 address="pkg/d.py::overflow_fn",
350 blast=9999, blast_cross=9999, churn_30d=9999,
351 )
352 await _run_provider(db_session, repo.repo_id)
353 row = await _get_risk_row(db_session, repo.repo_id, "pkg/d.py::overflow_fn")
354 assert row is not None
355 assert row.risk_score <= 100
356
357 @pytest.mark.asyncio
358 async def test_P1_22_idempotent_run_twice_one_row(
359 self, db_session: AsyncSession
360 ) -> None:
361 from sqlalchemy import func
362 repo = await create_repo(db_session)
363 await _seed_symbol(
364 db_session, repo.repo_id,
365 address="pkg/e.py::idem_fn",
366 blast=10, churn_30d=5,
367 )
368 await _run_provider(db_session, repo.repo_id)
369 await _run_provider(db_session, repo.repo_id)
370 count = (await db_session.execute(
371 select(func.count()).select_from(db.MusehubIntelBlastRisk).where(
372 db.MusehubIntelBlastRisk.repo_id == repo.repo_id,
373 db.MusehubIntelBlastRisk.address == "pkg/e.py::idem_fn",
374 )
375 )).scalar_one()
376 assert count == 1
377
378 @pytest.mark.asyncio
379 async def test_P1_23_return_type(
380 self, db_session: AsyncSession
381 ) -> None:
382 repo = await create_repo(db_session)
383 await _seed_symbol(
384 db_session, repo.repo_id,
385 address="pkg/f.py::ret_fn",
386 blast=10, churn_30d=3,
387 )
388 result = await _run_provider(db_session, repo.repo_id)
389 assert len(result) == 1
390 intel_type, data = result[0]
391 assert intel_type == "intel.code.blast_risk"
392 assert data["count"] == 1
393
394 @pytest.mark.asyncio
395 async def test_P1_24_empty_repo_returns_empty_list(
396 self, db_session: AsyncSession
397 ) -> None:
398 repo = await create_repo(db_session)
399 result = await _run_provider(db_session, repo.repo_id)
400 assert result == []
401
402
403 # ---------------------------------------------------------------------------
404 # Layer 6 — State integrity
405 # ---------------------------------------------------------------------------
406
407 class TestBlastRiskStateIntegrity:
408
409 @pytest.mark.asyncio
410 async def test_P1_25_rerun_updates_risk_score_in_place(
411 self, db_session: AsyncSession
412 ) -> None:
413 repo = await create_repo(db_session)
414 repo_id = repo.repo_id # capture before any expire_all invalidates the ORM object
415 # First run: low churn → lower score
416 await _seed_symbol(db_session, repo_id, address="pkg/g.py::update_fn",
417 blast=10, blast_cross=0, churn_30d=0)
418 await _run_provider(db_session, repo_id)
419 row_first = await _get_risk_row(db_session, repo_id, "pkg/g.py::update_fn")
420 score_first = row_first.risk_score
421
422 # Update symbol to high churn — raw SQL upsert, bypasses ORM identity map
423 await _seed_symbol(db_session, repo_id, address="pkg/g.py::update_fn",
424 blast=10, blast_cross=0, churn_30d=20)
425 await _run_provider(db_session, repo_id)
426 db_session.expire_all() # invalidate cached blast_risk row so _get_risk_row re-fetches
427 row_second = await _get_risk_row(db_session, repo_id, "pkg/g.py::update_fn")
428 assert row_second.risk_score > score_first
429
430 # Still one row
431 from sqlalchemy import func
432 count = (await db_session.execute(
433 select(func.count()).select_from(db.MusehubIntelBlastRisk).where(
434 db.MusehubIntelBlastRisk.repo_id == repo_id,
435 db.MusehubIntelBlastRisk.address == "pkg/g.py::update_fn",
436 )
437 )).scalar_one()
438 assert count == 1
439
440 @pytest.mark.asyncio
441 async def test_P1_26_upsert_does_not_touch_symbol_intel_blast_churn(
442 self, db_session: AsyncSession
443 ) -> None:
444 repo = await create_repo(db_session)
445 await _seed_symbol(
446 db_session, repo.repo_id,
447 address="pkg/h.py::no_touch_fn",
448 blast=7, churn=3, churn_30d=2,
449 )
450 await _run_provider(db_session, repo.repo_id)
451
452 intel_row = (await db_session.execute(
453 select(db.MusehubSymbolIntel).where(
454 db.MusehubSymbolIntel.repo_id == repo.repo_id,
455 db.MusehubSymbolIntel.address == "pkg/h.py::no_touch_fn",
456 )
457 )).scalar_one()
458 assert intel_row.blast == 7
459 assert intel_row.churn == 3
460 assert intel_row.churn_30d == 2
461
462 @pytest.mark.asyncio
463 async def test_P1_27_ref_column_updated_on_rerun(
464 self, db_session: AsyncSession
465 ) -> None:
466 repo = await create_repo(db_session)
467 repo_id = repo.repo_id # capture before any expire_all invalidates the ORM object
468 await _seed_symbol(db_session, repo_id, address="pkg/i.py::ref_fn",
469 blast=10, churn_30d=3)
470 await _run_provider(db_session, repo_id, ref=_REF_A)
471 row = await _get_risk_row(db_session, repo_id, "pkg/i.py::ref_fn")
472 assert row.ref == _REF_A
473
474 await _run_provider(db_session, repo_id, ref=_REF_B)
475 db_session.expire_all() # invalidate cached blast_risk row so _get_risk_row re-fetches
476 row = await _get_risk_row(db_session, repo_id, "pkg/i.py::ref_fn")
477 assert row.ref == _REF_B
478
479
480 # ---------------------------------------------------------------------------
481 # Layer 7 — Performance
482 # ---------------------------------------------------------------------------
483
484 class TestBlastRiskPerformance:
485
486 @pytest.mark.asyncio
487 async def test_P1_28_1000_symbols_processed_under_5_seconds(
488 self, db_session: AsyncSession
489 ) -> None:
490 repo = await create_repo(db_session)
491 # Bulk insert via executemany-style
492 from sqlalchemy import text
493 rows = []
494 for i in range(1000):
495 rows.append({
496 "repo_id": repo.repo_id,
497 "address": f"pkg/perf_{i}.py::fn_{i}",
498 "symbol_kind": _TRACKED_KINDS[i % len(_TRACKED_KINDS)],
499 "blast": (i % 50) + 1,
500 "blast_direct": i % 10,
501 "blast_cross": i % 10,
502 "churn": (i % 20) + 1,
503 "churn_30d": i % 20,
504 "churn_90d": i % 20,
505 "author_count": 1,
506 "gravity": 0.0,
507 "weekly": [0] * 12,
508 "blast_top": [],
509 })
510 await db_session.execute(
511 pg_insert(db.MusehubSymbolIntel)
512 .values(rows)
513 .on_conflict_do_nothing()
514 )
515 await db_session.flush()
516
517 start = time.monotonic()
518 await _run_provider(db_session, repo.repo_id)
519 elapsed = time.monotonic() - start
520 assert elapsed < 5.0, f"1000 symbols took {elapsed:.2f}s (limit: 5s)"
521
522
523 # ---------------------------------------------------------------------------
524 # Layer 8 — No subprocess
525 # ---------------------------------------------------------------------------
526
527 class TestBlastRiskNoSubprocess:
528
529 @pytest.mark.asyncio
530 async def test_P1_29_no_subprocess_spawned(
531 self, db_session: AsyncSession
532 ) -> None:
533 repo = await create_repo(db_session)
534 await _seed_symbol(
535 db_session, repo.repo_id,
536 address="pkg/j.py::no_proc_fn",
537 blast=10, churn_30d=3,
538 )
539 with patch("asyncio.create_subprocess_exec") as mock_exec:
540 await _run_provider(db_session, repo.repo_id)
541 mock_exec.assert_not_called()
File History 1 commit
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor 143 days ago