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