gabriel / musehub public
test_phase2_intel_providers.py python
420 lines 19.8 KB
Raw
sha256:d8cbca3a06f39f82f66be6c29de3f41c3dec5f367722958fb5454dcbc007cc15 fix: rc11 test fixes — event→verdict rename, migration coun… Sonnet 4.6 patch 50 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 msgpack
54 import pytest
55 from sqlalchemy import select
56 from sqlalchemy.ext.asyncio import AsyncSession
57
58 from muse.core.types import fake_id
59 from tests.factories import create_repo
60
61 type _ContentMap = dict[str, bytes]
62
63 _ALL_PHASE2_JOB_TYPES = [
64 "intel.code.coupling",
65 "intel.code.entangle",
66 "intel.code.dead",
67 "intel.code.blast_risk",
68 "intel.code.stable",
69 "intel.code.velocity",
70 "intel.code.clones",
71 "intel.code.type",
72 "intel.code.api_surface",
73 "intel.code.languages",
74 "intel.code.detect_refactor",
75 ]
76
77
78 def _uid() -> str:
79 return fake_id(secrets.token_hex(16))
80
81
82 def _now() -> datetime:
83 return datetime.now(tz=timezone.utc)
84
85
86 def _mock_process(stdout: str, returncode: int = 0) -> AsyncMock:
87 """Return an asyncio.subprocess.Process mock."""
88 proc = AsyncMock()
89 proc.returncode = returncode
90 proc.communicate = AsyncMock(return_value=(stdout.encode(), b""))
91 return proc
92
93
94 async def _make_commit_and_snapshot(
95 session: AsyncSession,
96 repo_id: str,
97 manifest: dict[str, str],
98 parent_ids: list[str] | None = None,
99 ) -> tuple[str, str]:
100 """Insert MusehubSnapshot + MusehubCommit; return (commit_id, snapshot_id)."""
101 from musehub.db.musehub_repo_models import (
102 MusehubCommit, MusehubCommitRef, MusehubSnapshot, MusehubSnapshotRef,
103 )
104 snap_id = _uid()
105 commit_id = _uid()
106 session.add(MusehubSnapshot(
107 snapshot_id=snap_id,
108 manifest_blob=msgpack.packb(manifest, use_bin_type=True),
109 ))
110 session.add(MusehubSnapshotRef(repo_id=repo_id, snapshot_id=snap_id))
111 session.add(MusehubCommit(
112 commit_id=commit_id,
113 branch="main",
114 message="test",
115 author="tester",
116 timestamp=datetime.now(tz=timezone.utc),
117 snapshot_id=snap_id,
118 parent_ids=parent_ids or [],
119 ))
120 session.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id))
121 await session.flush()
122 return commit_id, snap_id
123
124
125 def _mock_backend(content_map: _ContentMap) -> AsyncMock:
126 backend = AsyncMock()
127 backend.get = AsyncMock(side_effect=lambda oid, **_: content_map.get(oid))
128 return backend
129
130
131 # ─────────────────────────────────────────────────────────────────────────────
132 # Layer 1 — Registry: all 11 providers registered
133 # ─────────────────────────────────────────────────────────────────────────────
134
135 class TestPhase2Registry:
136
137 def test_P2_01_all_phase2_job_types_in_registry(self) -> None:
138 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
139 missing = [jt for jt in _ALL_PHASE2_JOB_TYPES if jt not in _PROVIDER_REGISTRY]
140 assert not missing, f"Missing from _PROVIDER_REGISTRY: {missing}"
141
142 def test_P2_02_registry_providers_satisfy_protocol(self) -> None:
143 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY, IntelProvider
144 for jt in _ALL_PHASE2_JOB_TYPES:
145 provider = _PROVIDER_REGISTRY[jt]
146 assert isinstance(provider, IntelProvider), (
147 f"{jt} provider does not satisfy IntelProvider protocol"
148 )
149
150
151 # ─────────────────────────────────────────────────────────────────────────────
152 # Layer 2 — Dispatch: job_types_for_push includes all 11 new types
153 # ─────────────────────────────────────────────────────────────────────────────
154
155 class TestPhase2Dispatch:
156
157 def test_P2_03_job_types_for_push_code_includes_all_phase2_types(self) -> None:
158 from musehub.services.musehub_intel_providers import job_types_for_push
159 types = job_types_for_push("code")
160 missing = [jt for jt in _ALL_PHASE2_JOB_TYPES if jt not in types]
161 assert not missing, f"Missing from job_types_for_push('code'): {missing}"
162
163 def test_P2_04_job_types_for_push_code_still_includes_legacy_types(self) -> None:
164 from musehub.services.musehub_intel_providers import job_types_for_push
165 types = job_types_for_push("code")
166 assert "intel.structural" in types
167 assert "intel.code" in types
168 assert "gc" in types
169
170 def test_P2_05_job_types_for_push_midi_excludes_phase2_types(self) -> None:
171 from musehub.services.musehub_intel_providers import job_types_for_push
172 types = job_types_for_push("midi")
173 for jt in _ALL_PHASE2_JOB_TYPES:
174 assert jt not in types, f"{jt} should not run for midi repos"
175
176
177 # ─────────────────────────────────────────────────────────────────────────────
178 # Layer 10 — TypeProvider
179 # ─────────────────────────────────────────────────────────────────────────────
180
181 class TestPhase2TypeProvider:
182
183 @pytest.mark.asyncio
184 async def test_P2_14_type_upserts_rows(self, db_session: AsyncSession) -> None:
185 from musehub.db import musehub_intel_models as db
186 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
187 repo = await create_repo(db_session)
188
189 py_src = b"def fn(x: int, y: str) -> bool:\n pass\n"
190 obj_id = "obj-type-test"
191 backend = _mock_backend({obj_id: py_src})
192
193 commit_id, _ = await _make_commit_and_snapshot(
194 db_session, repo.repo_id, {"a.py": obj_id}
195 )
196
197 with patch("musehub.storage.backends.get_backend", return_value=backend), \
198 patch("musehub.services.musehub_intel_providers.get_backend", return_value=backend):
199 results = await _PROVIDER_REGISTRY["intel.code.type"].compute(
200 db_session, repo.repo_id, commit_id,
201 {"head": commit_id, "owner": repo.owner, "slug": repo.slug},
202 )
203
204 assert results
205 rows = (await db_session.execute(
206 select(db.MusehubIntelType).where(db.MusehubIntelType.repo_id == repo.repo_id)
207 )).scalars().all()
208 assert len(rows) == 1
209 assert rows[0].type_score == pytest.approx(1.0)
210 assert rows[0].return_is_any is False
211
212
213 # ─────────────────────────────────────────────────────────────────────────────
214 # Layer 11 — ApiSurfaceProvider
215 # ─────────────────────────────────────────────────────────────────────────────
216
217 class TestPhase2ApiSurfaceProvider:
218
219 @pytest.mark.asyncio
220 async def test_P2_15_api_surface_upserts_rows(self, db_session: AsyncSession) -> None:
221 from musehub.db import musehub_intel_models as db
222 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
223 repo = await create_repo(db_session)
224
225 py_src = b"def get_repo(repo_id: str) -> dict:\n pass\n"
226 obj_id = "obj-api-test"
227 backend = _mock_backend({obj_id: py_src})
228
229 commit_id, _ = await _make_commit_and_snapshot(
230 db_session, repo.repo_id, {"api/routes.py": obj_id}
231 )
232
233 with patch("musehub.services.musehub_intel_providers.get_backend", return_value=backend):
234 results = await _PROVIDER_REGISTRY["intel.code.api_surface"].compute(
235 db_session, repo.repo_id, commit_id,
236 {"head": commit_id, "owner": repo.owner, "slug": repo.slug},
237 )
238
239 assert results
240 rows = (await db_session.execute(
241 select(db.MusehubIntelApiSurface).where(db.MusehubIntelApiSurface.repo_id == repo.repo_id)
242 )).scalars().all()
243 assert len(rows) == 1
244 assert rows[0].signature_id is not None
245 assert rows[0].visibility == "public"
246
247
248 # ─────────────────────────────────────────────────────────────────────────────
249 # Layer 12 — LanguagesProvider
250 # ─────────────────────────────────────────────────────────────────────────────
251
252 class TestPhase2LanguagesProvider:
253
254 @pytest.mark.asyncio
255 async def test_P2_16_languages_upserts_rows(self, db_session: AsyncSession) -> None:
256 from musehub.db import musehub_intel_models as db
257 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
258 repo = await create_repo(db_session)
259
260 py_src = b"def fn(x: int) -> bool:\n pass\n"
261 py_oid = "obj-lang-py"
262 toml_src = b"[workspace]\nversion = 1\n"
263 toml_oid = "obj-lang-toml"
264 backend = _mock_backend({py_oid: py_src, toml_oid: toml_src})
265
266 commit_id, _ = await _make_commit_and_snapshot(
267 db_session, repo.repo_id, {"src/main.py": py_oid, "pyproject.toml": toml_oid}
268 )
269
270 with patch("musehub.services.musehub_intel_providers.get_backend", return_value=backend):
271 results = await _PROVIDER_REGISTRY["intel.code.languages"].compute(
272 db_session, repo.repo_id, commit_id,
273 {"head": commit_id, "owner": repo.owner, "slug": repo.slug},
274 )
275
276 assert results
277 rows = (await db_session.execute(
278 select(db.MusehubIntelLanguages).where(db.MusehubIntelLanguages.repo_id == repo.repo_id)
279 )).scalars().all()
280 assert len(rows) == 2
281 py = next(r for r in rows if r.language == "Python")
282 assert py.file_count == 1
283 assert py.symbol_count == 1
284
285
286 # ─────────────────────────────────────────────────────────────────────────────
287 # Layer 13 — DetectRefactorProvider
288 # ─────────────────────────────────────────────────────────────────────────────
289
290 class TestPhase2DetectRefactorProvider:
291
292 @pytest.mark.asyncio
293 async def test_P2_17_detect_refactor_upserts_rows(self, db_session: AsyncSession) -> None:
294 from musehub.db import musehub_intel_models as db
295 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
296 repo = await create_repo(db_session)
297
298 # parent snapshot: a.py has old_name
299 parent_src = b"def old_name():\n pass\n"
300 parent_oid = "obj-refactor-parent"
301 # head snapshot: a.py has new_name (same body → rename)
302 head_src = b"def new_name():\n pass\n"
303 head_oid = "obj-refactor-head"
304 backend = _mock_backend({parent_oid: parent_src, head_oid: head_src})
305
306 parent_commit_id, _ = await _make_commit_and_snapshot(
307 db_session, repo.repo_id, {"a.py": parent_oid}
308 )
309 head_commit_id, _ = await _make_commit_and_snapshot(
310 db_session, repo.repo_id, {"a.py": head_oid},
311 parent_ids=[parent_commit_id],
312 )
313
314 with patch("musehub.services.musehub_intel_providers.get_backend", return_value=backend):
315 results = await _PROVIDER_REGISTRY["intel.code.detect_refactor"].compute(
316 db_session, repo.repo_id, head_commit_id,
317 {"head": head_commit_id, "owner": repo.owner, "slug": repo.slug},
318 )
319
320 assert results
321 rows = (await db_session.execute(
322 select(db.MusehubIntelRefactorEvent).where(
323 db.MusehubIntelRefactorEvent.repo_id == repo.repo_id
324 )
325 )).scalars().all()
326 assert len(rows) == 1
327 assert rows[0].kind == "rename"
328 assert rows[0].address == "a.py::old_name"
329
330 @pytest.mark.asyncio
331 async def test_P2_18_detect_refactor_deduplicates_by_event_id(self, db_session: AsyncSession) -> None:
332 from musehub.db import musehub_intel_models as db
333 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
334 repo = await create_repo(db_session)
335
336 parent_src = b"def old_name():\n pass\n"
337 parent_oid = "obj-dedup-parent"
338 head_src = b"def new_name():\n pass\n"
339 head_oid = "obj-dedup-head"
340 backend = _mock_backend({parent_oid: parent_src, head_oid: head_src})
341
342 parent_commit_id, _ = await _make_commit_and_snapshot(
343 db_session, repo.repo_id, {"a.py": parent_oid}
344 )
345 head_commit_id, _ = await _make_commit_and_snapshot(
346 db_session, repo.repo_id, {"a.py": head_oid},
347 parent_ids=[parent_commit_id],
348 )
349
350 with patch("musehub.services.musehub_intel_providers.get_backend", return_value=backend):
351 await _PROVIDER_REGISTRY["intel.code.detect_refactor"].compute(
352 db_session, repo.repo_id, head_commit_id,
353 {"head": head_commit_id, "owner": repo.owner, "slug": repo.slug},
354 )
355 with patch("musehub.services.musehub_intel_providers.get_backend", return_value=backend):
356 await _PROVIDER_REGISTRY["intel.code.detect_refactor"].compute(
357 db_session, repo.repo_id, head_commit_id,
358 {"head": head_commit_id, "owner": repo.owner, "slug": repo.slug},
359 )
360
361 rows = (await db_session.execute(
362 select(db.MusehubIntelRefactorEvent).where(
363 db.MusehubIntelRefactorEvent.repo_id == repo.repo_id
364 )
365 )).scalars().all()
366 assert len(rows) == 1, "duplicate event inserted — event_id upsert is broken"
367
368
369 # ─────────────────────────────────────────────────────────────────────────────
370 # Layer 14 — Empty output: all providers return [] gracefully
371 # ─────────────────────────────────────────────────────────────────────────────
372
373 class TestPhase2EmptyOutput:
374
375 @pytest.mark.asyncio
376 @pytest.mark.parametrize("job_type,empty_key", [
377 ("intel.code.coupling", '{"pairs": []}'),
378 ("intel.code.entangle", '{"pairs": []}'),
379 ("intel.code.dead", '{"candidates": []}'),
380 ("intel.code.blast_risk", '{"symbols": []}'),
381 ("intel.code.stable", '{"symbols": []}'),
382 ("intel.code.velocity", '{"modules": []}'),
383 ("intel.code.clones", '{"clusters": []}'),
384 ("intel.code.type", '{"symbols": []}'),
385 ("intel.code.api_surface", '{"symbols": []}'),
386 ("intel.code.languages", '{"languages": []}'),
387 ("intel.code.detect_refactor",'{"events": []}'),
388 ])
389 async def test_P2_19_empty_muse_output_returns_empty_list(
390 self, job_type: str, empty_key: str, db_session: AsyncSession
391 ) -> None:
392 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
393 repo = await create_repo(db_session)
394 ref = _uid()
395 with patch("asyncio.create_subprocess_exec", return_value=_mock_process(empty_key)):
396 results = await _PROVIDER_REGISTRY[job_type].compute(
397 db_session, repo.repo_id, ref, {"head": ref, "owner": repo.owner, "slug": repo.slug}
398 )
399 assert results == []
400
401
402 # ─────────────────────────────────────────────────────────────────────────────
403 # Layer 15 — Non-zero exit: all providers return [] gracefully
404 # ─────────────────────────────────────────────────────────────────────────────
405
406 class TestPhase2ErrorHandling:
407
408 @pytest.mark.asyncio
409 @pytest.mark.parametrize("job_type", _ALL_PHASE2_JOB_TYPES)
410 async def test_P2_20_nonzero_exit_returns_empty_list(
411 self, job_type: str, db_session: AsyncSession
412 ) -> None:
413 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
414 repo = await create_repo(db_session)
415 ref = _uid()
416 with patch("asyncio.create_subprocess_exec", return_value=_mock_process("", returncode=1)):
417 results = await _PROVIDER_REGISTRY[job_type].compute(
418 db_session, repo.repo_id, ref, {"head": ref, "owner": repo.owner, "slug": repo.slug}
419 )
420 assert results == []
File History 1 commit
sha256:d8cbca3a06f39f82f66be6c29de3f41c3dec5f367722958fb5454dcbc007cc15 fix: rc11 test fixes — event→verdict rename, migration coun… Sonnet 4.6 patch 50 days ago