gabriel / musehub public
test_phase2_intel_providers.py python
656 lines 31.4 KB
Raw
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ breaking 146 days ago
1 """TDD spec for Phase 2 — worker intel providers (issue #8).
2
3 11 new ``IntelProvider`` subclasses, one per normalized intel table, each
4 wrapping a ``muse code <command> --json`` subprocess call and upserting rows
5 into the corresponding DB table.
6
7 New job types (all in the ``intel.code.*`` namespace):
8 intel.code.coupling → MusehubIntelCoupling
9 intel.code.entangle → MusehubIntelEntangle
10 intel.code.dead → MusehubIntelDead
11 intel.code.blast_risk → MusehubIntelBlastRisk
12 intel.code.stable → MusehubIntelStable
13 intel.code.velocity → MusehubIntelVelocity
14 intel.code.clones → MusehubIntelClones
15 intel.code.type → MusehubIntelType
16 intel.code.api_surface → MusehubIntelApiSurface
17 intel.code.languages → MusehubIntelLanguages
18 intel.code.detect_refactor → MusehubIntelRefactorEvent
19
20 Contract each provider must satisfy:
21 1. Registered under its job-type key in ``_PROVIDER_REGISTRY``.
22 2. ``compute()`` calls ``muse -C <repo_root> code <cmd> --json`` (or the
23 equivalent runner) and upserts result rows.
24 3. ``compute()`` returns a non-empty ``IntelResults`` list on success.
25 4. ``compute()`` returns ``[]`` gracefully when the muse command yields no
26 results (empty repo, no symbols, etc.).
27 5. ``compute()`` returns ``[]`` gracefully when the subprocess exits non-zero.
28
29 Layers:
30 1. Registry — job types present in _PROVIDER_REGISTRY
31 2. Dispatch — job_types_for_push("code") includes all 11 new types
32 3. Coupling — provider upserts MusehubIntelCoupling rows
33 4. Entangle — provider upserts MusehubIntelEntangle rows
34 5. Dead — provider upserts MusehubIntelDead rows
35 6. BlastRisk — provider upserts MusehubIntelBlastRisk rows
36 7. Stable — provider upserts MusehubIntelStable rows
37 8. Velocity — provider upserts MusehubIntelVelocity rows
38 9. Clones — provider upserts MusehubIntelClones rows
39 10. Type — provider upserts MusehubIntelType rows
40 11. ApiSurface — provider upserts MusehubIntelApiSurface rows
41 12. Languages — provider upserts MusehubIntelLanguages rows
42 13. Refactor — provider upserts MusehubIntelRefactorEvent rows
43 14. Empty — all providers handle empty muse output gracefully
44 15. Error — all providers handle non-zero exit gracefully
45 """
46 from __future__ import annotations
47
48 import json
49 import secrets
50 from datetime import datetime, timezone
51 from unittest.mock import AsyncMock, MagicMock, patch
52
53 import pytest
54 from sqlalchemy import select
55 from sqlalchemy.ext.asyncio import AsyncSession
56
57 from muse.core.types import fake_id
58 from tests.factories import create_repo
59
60 _ALL_PHASE2_JOB_TYPES = [
61 "intel.code.coupling",
62 "intel.code.entangle",
63 "intel.code.dead",
64 "intel.code.blast_risk",
65 "intel.code.stable",
66 "intel.code.velocity",
67 "intel.code.clones",
68 "intel.code.type",
69 "intel.code.api_surface",
70 "intel.code.languages",
71 "intel.code.detect_refactor",
72 ]
73
74
75 def _uid() -> str:
76 return fake_id(secrets.token_hex(16))
77
78
79 def _now() -> datetime:
80 return datetime.now(tz=timezone.utc)
81
82
83 def _mock_process(stdout: str, returncode: int = 0) -> AsyncMock:
84 """Return an asyncio.subprocess.Process mock."""
85 proc = AsyncMock()
86 proc.returncode = returncode
87 proc.communicate = AsyncMock(return_value=(stdout.encode(), b""))
88 return proc
89
90
91 # ─────────────────────────────────────────────────────────────────────────────
92 # Layer 1 — Registry: all 11 providers registered
93 # ─────────────────────────────────────────────────────────────────────────────
94
95 class TestPhase2Registry:
96
97 def test_P2_01_all_phase2_job_types_in_registry(self) -> None:
98 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
99 missing = [jt for jt in _ALL_PHASE2_JOB_TYPES if jt not in _PROVIDER_REGISTRY]
100 assert not missing, f"Missing from _PROVIDER_REGISTRY: {missing}"
101
102 def test_P2_02_registry_providers_satisfy_protocol(self) -> None:
103 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY, IntelProvider
104 for jt in _ALL_PHASE2_JOB_TYPES:
105 provider = _PROVIDER_REGISTRY[jt]
106 assert isinstance(provider, IntelProvider), (
107 f"{jt} provider does not satisfy IntelProvider protocol"
108 )
109
110
111 # ─────────────────────────────────────────────────────────────────────────────
112 # Layer 2 — Dispatch: job_types_for_push includes all 11 new types
113 # ─────────────────────────────────────────────────────────────────────────────
114
115 class TestPhase2Dispatch:
116
117 def test_P2_03_job_types_for_push_code_includes_all_phase2_types(self) -> None:
118 from musehub.services.musehub_intel_providers import job_types_for_push
119 types = job_types_for_push("code")
120 missing = [jt for jt in _ALL_PHASE2_JOB_TYPES if jt not in types]
121 assert not missing, f"Missing from job_types_for_push('code'): {missing}"
122
123 def test_P2_04_job_types_for_push_code_still_includes_legacy_types(self) -> None:
124 from musehub.services.musehub_intel_providers import job_types_for_push
125 types = job_types_for_push("code")
126 assert "intel.structural" in types
127 assert "intel.code" in types
128 assert "gc" in types
129
130 def test_P2_05_job_types_for_push_midi_excludes_phase2_types(self) -> None:
131 from musehub.services.musehub_intel_providers import job_types_for_push
132 types = job_types_for_push("midi")
133 for jt in _ALL_PHASE2_JOB_TYPES:
134 assert jt not in types, f"{jt} should not run for midi repos"
135
136
137 # ─────────────────────────────────────────────────────────────────────────────
138 # Layer 3 — CouplingProvider
139 # ─────────────────────────────────────────────────────────────────────────────
140
141 class TestPhase2CouplingProvider:
142
143 @pytest.mark.asyncio
144 async def test_P2_06_coupling_upserts_rows(self, db_session: AsyncSession) -> None:
145 from musehub.db import musehub_models as db
146 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
147 repo = await create_repo(db_session)
148 ref = _uid()
149
150 muse_output = json.dumps({
151 "pairs": [
152 {"file_a": "a.py", "file_b": "b.py", "co_changes": 7},
153 {"file_a": "c.py", "file_b": "d.py", "co_changes": 3},
154 ]
155 })
156 with patch("asyncio.create_subprocess_exec", return_value=_mock_process(muse_output)):
157 results = await _PROVIDER_REGISTRY["intel.code.coupling"].compute(
158 db_session, repo.repo_id, ref, {"head": ref, "owner": repo.owner, "slug": repo.slug}
159 )
160
161 assert results
162 rows = (await db_session.execute(
163 select(db.MusehubIntelCoupling).where(db.MusehubIntelCoupling.repo_id == repo.repo_id)
164 )).scalars().all()
165 assert len(rows) == 2
166 assert any(r.file_a == "a.py" and r.co_changes == 7 for r in rows)
167
168 @pytest.mark.asyncio
169 async def test_P2_07_coupling_upsert_overwrites_on_re_run(self, db_session: AsyncSession) -> None:
170 from musehub.db import musehub_models as db
171 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
172 repo = await create_repo(db_session)
173 ref = _uid()
174
175 first = json.dumps({"pairs": [{"file_a": "a.py", "file_b": "b.py", "co_changes": 1}]})
176 second = json.dumps({"pairs": [{"file_a": "a.py", "file_b": "b.py", "co_changes": 99}]})
177
178 with patch("asyncio.create_subprocess_exec", return_value=_mock_process(first)):
179 await _PROVIDER_REGISTRY["intel.code.coupling"].compute(
180 db_session, repo.repo_id, ref, {"head": ref, "owner": repo.owner, "slug": repo.slug}
181 )
182 with patch("asyncio.create_subprocess_exec", return_value=_mock_process(second)):
183 await _PROVIDER_REGISTRY["intel.code.coupling"].compute(
184 db_session, repo.repo_id, ref, {"head": ref, "owner": repo.owner, "slug": repo.slug}
185 )
186
187 rows = (await db_session.execute(
188 select(db.MusehubIntelCoupling).where(db.MusehubIntelCoupling.repo_id == repo.repo_id)
189 )).scalars().all()
190 assert len(rows) == 1
191 assert rows[0].co_changes == 99
192
193
194 # ─────────────────────────────────────────────────────────────────────────────
195 # Layer 4 — EntangleProvider
196 # ─────────────────────────────────────────────────────────────────────────────
197
198 class TestPhase2EntangleProvider:
199
200 @pytest.mark.asyncio
201 async def test_P2_08_entangle_upserts_rows(self, db_session: AsyncSession) -> None:
202 from musehub.db import musehub_models as db
203 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
204 repo = await create_repo(db_session)
205 ref = _uid()
206
207 muse_output = json.dumps({
208 "pairs": [
209 {
210 "symbol_a": "a.py::fn_a",
211 "symbol_b": "b.py::fn_b",
212 "co_change_rate": 0.9,
213 "co_changes": 18,
214 "structurally_linked": False,
215 },
216 ]
217 })
218 with patch("asyncio.create_subprocess_exec", return_value=_mock_process(muse_output)):
219 results = await _PROVIDER_REGISTRY["intel.code.entangle"].compute(
220 db_session, repo.repo_id, ref, {"head": ref, "owner": repo.owner, "slug": repo.slug}
221 )
222
223 assert results
224 rows = (await db_session.execute(
225 select(db.MusehubIntelEntangle).where(db.MusehubIntelEntangle.repo_id == repo.repo_id)
226 )).scalars().all()
227 assert len(rows) == 1
228 assert rows[0].co_change_rate == pytest.approx(0.9)
229
230
231 # ─────────────────────────────────────────────────────────────────────────────
232 # Layer 5 — DeadProvider
233 # ─────────────────────────────────────────────────────────────────────────────
234
235 class TestPhase2DeadProvider:
236
237 @pytest.mark.asyncio
238 async def test_P2_09_dead_upserts_rows(self, db_session: AsyncSession) -> None:
239 from musehub.db import musehub_models as db
240 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
241 repo = await create_repo(db_session)
242 ref = _uid()
243
244 muse_output = json.dumps({
245 "candidates": [
246 {"address": "a.py::old_fn", "kind": "function", "confidence": "high", "reason": "no callers"},
247 {"address": "b.py::OldClass", "kind": "class", "confidence": "medium", "reason": None},
248 ]
249 })
250 with patch("asyncio.create_subprocess_exec", return_value=_mock_process(muse_output)):
251 results = await _PROVIDER_REGISTRY["intel.code.dead"].compute(
252 db_session, repo.repo_id, ref, {"head": ref, "owner": repo.owner, "slug": repo.slug}
253 )
254
255 assert results
256 rows = (await db_session.execute(
257 select(db.MusehubIntelDead).where(db.MusehubIntelDead.repo_id == repo.repo_id)
258 )).scalars().all()
259 assert len(rows) == 2
260 high = next(r for r in rows if r.address == "a.py::old_fn")
261 assert high.confidence == "high"
262
263
264 # ─────────────────────────────────────────────────────────────────────────────
265 # Layer 6 — BlastRiskProvider
266 # ─────────────────────────────────────────────────────────────────────────────
267
268 class TestPhase2BlastRiskProvider:
269
270 @pytest.mark.asyncio
271 async def test_P2_10_blast_risk_upserts_rows(self, db_session: AsyncSession) -> None:
272 from musehub.db import musehub_models as db
273 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
274 repo = await create_repo(db_session)
275 ref = _uid()
276
277 muse_output = json.dumps({
278 "symbols": [
279 {
280 "address": "a.py::risky",
281 "kind": "function",
282 "risk_label": "high",
283 "risk": 82,
284 "impact_score": 0.9,
285 "churn_score": 0.7,
286 "test_gap_score": 0.5,
287 "coupling_score": 0.6,
288 },
289 ]
290 })
291 with patch("asyncio.create_subprocess_exec", return_value=_mock_process(muse_output)):
292 results = await _PROVIDER_REGISTRY["intel.code.blast_risk"].compute(
293 db_session, repo.repo_id, ref, {"head": ref, "owner": repo.owner, "slug": repo.slug}
294 )
295
296 assert results
297 rows = (await db_session.execute(
298 select(db.MusehubIntelBlastRisk).where(db.MusehubIntelBlastRisk.repo_id == repo.repo_id)
299 )).scalars().all()
300 assert len(rows) == 1
301 assert rows[0].risk == "high"
302 assert rows[0].risk_score == 82
303
304
305 # ─────────────────────────────────────────────────────────────────────────────
306 # Layer 7 — StableProvider
307 # ─────────────────────────────────────────────────────────────────────────────
308
309 class TestPhase2StableProvider:
310
311 @pytest.mark.asyncio
312 async def test_P2_11_stable_upserts_rows(self, db_session: AsyncSession) -> None:
313 from musehub.db import musehub_models as db
314 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
315 repo = await create_repo(db_session)
316 ref = _uid()
317
318 muse_output = json.dumps({
319 "symbols": [
320 {"address": "core.py::compute", "days_stable": 120, "since_start": False},
321 {"address": "core.py::init", "days_stable": 999, "since_start": True},
322 ]
323 })
324 with patch("asyncio.create_subprocess_exec", return_value=_mock_process(muse_output)):
325 results = await _PROVIDER_REGISTRY["intel.code.stable"].compute(
326 db_session, repo.repo_id, ref, {"head": ref, "owner": repo.owner, "slug": repo.slug}
327 )
328
329 assert results
330 rows = (await db_session.execute(
331 select(db.MusehubIntelStable).where(db.MusehubIntelStable.repo_id == repo.repo_id)
332 )).scalars().all()
333 assert len(rows) == 2
334 since_start_row = next(r for r in rows if r.address == "core.py::init")
335 assert since_start_row.since_start is True
336 assert since_start_row.days_stable == 999
337
338
339 # ─────────────────────────────────────────────────────────────────────────────
340 # Layer 8 — VelocityProvider
341 # ─────────────────────────────────────────────────────────────────────────────
342
343 class TestPhase2VelocityProvider:
344
345 @pytest.mark.asyncio
346 async def test_P2_12_velocity_upserts_rows(self, db_session: AsyncSession) -> None:
347 from musehub.db import musehub_models as db
348 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
349 repo = await create_repo(db_session)
350 ref = _uid()
351
352 muse_output = json.dumps({
353 "modules": [
354 {
355 "module": "musehub/services",
356 "added": 10, "removed": 2, "net": 8,
357 "modified": 5, "active_commits": 15,
358 "prior_added": 6, "prior_net": 4,
359 "acceleration": 1.3, "stagnant_commits": 1,
360 },
361 ]
362 })
363 with patch("asyncio.create_subprocess_exec", return_value=_mock_process(muse_output)):
364 results = await _PROVIDER_REGISTRY["intel.code.velocity"].compute(
365 db_session, repo.repo_id, ref, {"head": ref, "owner": repo.owner, "slug": repo.slug}
366 )
367
368 assert results
369 rows = (await db_session.execute(
370 select(db.MusehubIntelVelocity).where(db.MusehubIntelVelocity.repo_id == repo.repo_id)
371 )).scalars().all()
372 assert len(rows) == 1
373 assert rows[0].net == 8
374 assert rows[0].acceleration == pytest.approx(1.3)
375
376
377 # ─────────────────────────────────────────────────────────────────────────────
378 # Layer 9 — ClonesProvider
379 # ─────────────────────────────────────────────────────────────────────────────
380
381 class TestPhase2ClonesProvider:
382
383 @pytest.mark.asyncio
384 async def test_P2_13_clones_upserts_rows(self, db_session: AsyncSession) -> None:
385 from musehub.db import musehub_models as db
386 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
387 repo = await create_repo(db_session)
388 ref = _uid()
389 cluster = _uid()
390
391 muse_output = json.dumps({
392 "clusters": [
393 {
394 "cluster_hash": cluster,
395 "tier": "exact",
396 "member_count": 3,
397 "members": ["a.py::fn", "b.py::fn", "c.py::fn"],
398 },
399 ]
400 })
401 with patch("asyncio.create_subprocess_exec", return_value=_mock_process(muse_output)):
402 results = await _PROVIDER_REGISTRY["intel.code.clones"].compute(
403 db_session, repo.repo_id, ref, {"head": ref, "owner": repo.owner, "slug": repo.slug}
404 )
405
406 assert results
407 rows = (await db_session.execute(
408 select(db.MusehubIntelClones).where(db.MusehubIntelClones.repo_id == repo.repo_id)
409 )).scalars().all()
410 assert len(rows) == 1
411 assert rows[0].cluster_hash == cluster
412 assert rows[0].member_count == 3
413
414
415 # ─────────────────────────────────────────────────────────────────────────────
416 # Layer 10 — TypeProvider
417 # ─────────────────────────────────────────────────────────────────────────────
418
419 class TestPhase2TypeProvider:
420
421 @pytest.mark.asyncio
422 async def test_P2_14_type_upserts_rows(self, db_session: AsyncSession) -> None:
423 from musehub.db import musehub_models as db
424 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
425 repo = await create_repo(db_session)
426 ref = _uid()
427
428 muse_output = json.dumps({
429 "symbols": [
430 {
431 "address": "a.py::fn",
432 "kind": "function",
433 "return_is_any": False,
434 "params_total": 3,
435 "params_annotated": 3,
436 "params_with_any": 0,
437 "type_score": 1.0,
438 },
439 ]
440 })
441 with patch("asyncio.create_subprocess_exec", return_value=_mock_process(muse_output)):
442 results = await _PROVIDER_REGISTRY["intel.code.type"].compute(
443 db_session, repo.repo_id, ref, {"head": ref, "owner": repo.owner, "slug": repo.slug}
444 )
445
446 assert results
447 rows = (await db_session.execute(
448 select(db.MusehubIntelType).where(db.MusehubIntelType.repo_id == repo.repo_id)
449 )).scalars().all()
450 assert len(rows) == 1
451 assert rows[0].type_score == pytest.approx(1.0)
452 assert rows[0].return_is_any is False
453
454
455 # ─────────────────────────────────────────────────────────────────────────────
456 # Layer 11 — ApiSurfaceProvider
457 # ─────────────────────────────────────────────────────────────────────────────
458
459 class TestPhase2ApiSurfaceProvider:
460
461 @pytest.mark.asyncio
462 async def test_P2_15_api_surface_upserts_rows(self, db_session: AsyncSession) -> None:
463 from musehub.db import musehub_models as db
464 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
465 repo = await create_repo(db_session)
466 ref = _uid()
467 sig_id = _uid()
468
469 muse_output = json.dumps({
470 "symbols": [
471 {
472 "address": "api/routes.py::get_repo",
473 "kind": "function",
474 "signature_id": sig_id,
475 "visibility": "public",
476 },
477 ]
478 })
479 with patch("asyncio.create_subprocess_exec", return_value=_mock_process(muse_output)):
480 results = await _PROVIDER_REGISTRY["intel.code.api_surface"].compute(
481 db_session, repo.repo_id, ref, {"head": ref, "owner": repo.owner, "slug": repo.slug}
482 )
483
484 assert results
485 rows = (await db_session.execute(
486 select(db.MusehubIntelApiSurface).where(db.MusehubIntelApiSurface.repo_id == repo.repo_id)
487 )).scalars().all()
488 assert len(rows) == 1
489 assert rows[0].signature_id == sig_id
490 assert rows[0].visibility == "public"
491
492
493 # ─────────────────────────────────────────────────────────────────────────────
494 # Layer 12 — LanguagesProvider
495 # ─────────────────────────────────────────────────────────────────────────────
496
497 class TestPhase2LanguagesProvider:
498
499 @pytest.mark.asyncio
500 async def test_P2_16_languages_upserts_rows(self, db_session: AsyncSession) -> None:
501 from musehub.db import musehub_models as db
502 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
503 repo = await create_repo(db_session)
504 ref = _uid()
505
506 muse_output = json.dumps({
507 "languages": [
508 {"language": "Python", "file_count": 88, "symbol_count": 1240, "pct": 97.5},
509 {"language": "TOML", "file_count": 3, "symbol_count": 0, "pct": 2.5},
510 ]
511 })
512 with patch("asyncio.create_subprocess_exec", return_value=_mock_process(muse_output)):
513 results = await _PROVIDER_REGISTRY["intel.code.languages"].compute(
514 db_session, repo.repo_id, ref, {"head": ref, "owner": repo.owner, "slug": repo.slug}
515 )
516
517 assert results
518 rows = (await db_session.execute(
519 select(db.MusehubIntelLanguages).where(db.MusehubIntelLanguages.repo_id == repo.repo_id)
520 )).scalars().all()
521 assert len(rows) == 2
522 py = next(r for r in rows if r.language == "Python")
523 assert py.file_count == 88
524 assert py.symbol_count == 1240
525
526
527 # ─────────────────────────────────────────────────────────────────────────────
528 # Layer 13 — DetectRefactorProvider
529 # ─────────────────────────────────────────────────────────────────────────────
530
531 class TestPhase2DetectRefactorProvider:
532
533 @pytest.mark.asyncio
534 async def test_P2_17_detect_refactor_upserts_rows(self, db_session: AsyncSession) -> None:
535 from musehub.db import musehub_models as db
536 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
537 repo = await create_repo(db_session)
538 ref = _uid()
539 commit_id = _uid()
540
541 muse_output = json.dumps({
542 "events": [
543 {
544 "kind": "rename",
545 "address": "a.py::old_name",
546 "detail": "→ new_name",
547 "commit_id": commit_id,
548 "commit_message": "refactor: rename",
549 "committed_at": _now().isoformat(),
550 },
551 ]
552 })
553 with patch("asyncio.create_subprocess_exec", return_value=_mock_process(muse_output)):
554 results = await _PROVIDER_REGISTRY["intel.code.detect_refactor"].compute(
555 db_session, repo.repo_id, ref, {"head": ref, "owner": repo.owner, "slug": repo.slug}
556 )
557
558 assert results
559 rows = (await db_session.execute(
560 select(db.MusehubIntelRefactorEvent).where(
561 db.MusehubIntelRefactorEvent.repo_id == repo.repo_id
562 )
563 )).scalars().all()
564 assert len(rows) == 1
565 assert rows[0].kind == "rename"
566 assert rows[0].address == "a.py::old_name"
567 assert rows[0].detail == "→ new_name"
568
569 @pytest.mark.asyncio
570 async def test_P2_18_detect_refactor_deduplicates_by_event_id(self, db_session: AsyncSession) -> None:
571 from musehub.db import musehub_models as db
572 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
573 repo = await create_repo(db_session)
574 ref = _uid()
575 commit_id = _uid()
576 committed_at = _now().isoformat()
577
578 event = {
579 "kind": "move",
580 "address": "a.py::fn",
581 "detail": "→ b.py",
582 "commit_id": commit_id,
583 "commit_message": "move fn",
584 "committed_at": committed_at,
585 }
586 muse_output = json.dumps({"events": [event]})
587
588 with patch("asyncio.create_subprocess_exec", return_value=_mock_process(muse_output)):
589 await _PROVIDER_REGISTRY["intel.code.detect_refactor"].compute(
590 db_session, repo.repo_id, ref, {"head": ref, "owner": repo.owner, "slug": repo.slug}
591 )
592 with patch("asyncio.create_subprocess_exec", return_value=_mock_process(muse_output)):
593 await _PROVIDER_REGISTRY["intel.code.detect_refactor"].compute(
594 db_session, repo.repo_id, ref, {"head": ref, "owner": repo.owner, "slug": repo.slug}
595 )
596
597 rows = (await db_session.execute(
598 select(db.MusehubIntelRefactorEvent).where(
599 db.MusehubIntelRefactorEvent.repo_id == repo.repo_id
600 )
601 )).scalars().all()
602 assert len(rows) == 1, "duplicate event inserted — event_id upsert is broken"
603
604
605 # ─────────────────────────────────────────────────────────────────────────────
606 # Layer 14 — Empty output: all providers return [] gracefully
607 # ─────────────────────────────────────────────────────────────────────────────
608
609 class TestPhase2EmptyOutput:
610
611 @pytest.mark.asyncio
612 @pytest.mark.parametrize("job_type,empty_key", [
613 ("intel.code.coupling", '{"pairs": []}'),
614 ("intel.code.entangle", '{"pairs": []}'),
615 ("intel.code.dead", '{"candidates": []}'),
616 ("intel.code.blast_risk", '{"symbols": []}'),
617 ("intel.code.stable", '{"symbols": []}'),
618 ("intel.code.velocity", '{"modules": []}'),
619 ("intel.code.clones", '{"clusters": []}'),
620 ("intel.code.type", '{"symbols": []}'),
621 ("intel.code.api_surface", '{"symbols": []}'),
622 ("intel.code.languages", '{"languages": []}'),
623 ("intel.code.detect_refactor",'{"events": []}'),
624 ])
625 async def test_P2_19_empty_muse_output_returns_empty_list(
626 self, job_type: str, empty_key: str, db_session: AsyncSession
627 ) -> None:
628 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
629 repo = await create_repo(db_session)
630 ref = _uid()
631 with patch("asyncio.create_subprocess_exec", return_value=_mock_process(empty_key)):
632 results = await _PROVIDER_REGISTRY[job_type].compute(
633 db_session, repo.repo_id, ref, {"head": ref, "owner": repo.owner, "slug": repo.slug}
634 )
635 assert results == []
636
637
638 # ─────────────────────────────────────────────────────────────────────────────
639 # Layer 15 — Non-zero exit: all providers return [] gracefully
640 # ─────────────────────────────────────────────────────────────────────────────
641
642 class TestPhase2ErrorHandling:
643
644 @pytest.mark.asyncio
645 @pytest.mark.parametrize("job_type", _ALL_PHASE2_JOB_TYPES)
646 async def test_P2_20_nonzero_exit_returns_empty_list(
647 self, job_type: str, db_session: AsyncSession
648 ) -> None:
649 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
650 repo = await create_repo(db_session)
651 ref = _uid()
652 with patch("asyncio.create_subprocess_exec", return_value=_mock_process("", returncode=1)):
653 results = await _PROVIDER_REGISTRY[job_type].compute(
654 db_session, repo.repo_id, ref, {"head": ref, "owner": repo.owner, "slug": repo.slug}
655 )
656 assert results == []
File History 1 commit
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ 146 days ago