gabriel / musehub public
test_phase1_dead_provider.py python
417 lines 14.7 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 DeadProvider (issue #10).
2
3 DeadProvider replaces the muse-CLI subprocess approach with a pure SQL
4 derivation from `musehub_symbol_intel` blast and churn columns.
5
6 Confidence formula:
7 HIGH → symbol_kind IN tracked_kinds AND blast == 0 AND churn == 1
8 MEDIUM → symbol_kind IN tracked_kinds AND blast == 0 AND churn > 1
9 AND churn_30d == 0
10 LOW → symbol_kind IN tracked_kinds AND blast == 0 AND churn_30d > 0
11
12 tracked_kinds = {function, async_function, method, async_method, class}
13
14 Reason strings:
15 HIGH: "Added once, never modified. Zero blast radius in full history."
16 MEDIUM: "Modified in past but zero blast radius for ≥ 30 days."
17 LOW: "Zero blast radius. Recently active — verify before deleting."
18
19 Dismiss preservation:
20 On upsert, dismissed=True is NEVER overwritten by a re-run.
21 New rows always start with dismissed=False.
22
23 Layers:
24 1. Registry — "intel.code.dead" in _PROVIDER_REGISTRY
25 2. Protocol — satisfies IntelProvider
26 3. Dispatch — job_types_for_push("code") includes "intel.code.dead"
27 job_types_for_push("midi") excludes "intel.code.dead"
28 4. High conf — blast=0, churn=1 → confidence="high"
29 5. Medium conf — blast=0, churn>1, churn_30d=0 → confidence="medium"
30 6. Low conf — blast=0, churn_30d>0 → confidence="low"
31 7. Excluded — blast>0 → not a candidate
32 8. Kind filter — kind="import" (untracked) → excluded
33 9. Reasons — reason string correct per tier
34 10. Dismissed — new rows get dismissed=False; existing dismissed=True preserved
35 11. Empty — no symbol_intel rows → returns []
36 12. Idempotent — run twice, one row per address
37 13. Return type — returns [("intel.code.dead", {"count": N})]
38 14. No subprocess — compute() never calls asyncio.create_subprocess_exec
39 """
40 from __future__ import annotations
41
42 import secrets
43 from unittest.mock import patch
44
45 import pytest
46 import pytest_asyncio
47 from sqlalchemy import select
48 from sqlalchemy.dialects.postgresql import insert as pg_insert
49 from sqlalchemy.ext.asyncio import AsyncSession
50
51 from muse.core.types import fake_id
52 from musehub.db import musehub_models as db
53 from tests.factories import create_repo
54
55
56 def _uid() -> str:
57 return fake_id(secrets.token_hex(16))
58
59
60 _TRACKED_KINDS = ("function", "async_function", "method", "async_method", "class")
61
62
63 # ---------------------------------------------------------------------------
64 # Helpers
65 # ---------------------------------------------------------------------------
66
67 async def _seed_symbol(
68 session: AsyncSession,
69 repo_id: str,
70 *,
71 address: str,
72 kind: str = "function",
73 blast: int = 0,
74 blast_direct: int = 0,
75 blast_cross: int = 0,
76 churn: int = 1,
77 churn_30d: int = 0,
78 churn_90d: int = 0,
79 ) -> None:
80 stmt = (
81 pg_insert(db.MusehubSymbolIntel)
82 .values(
83 repo_id=repo_id,
84 address=address,
85 symbol_kind=kind,
86 blast=blast,
87 blast_direct=blast_direct,
88 blast_cross=blast_cross,
89 churn=churn,
90 churn_30d=churn_30d,
91 churn_90d=churn_90d,
92 author_count=1,
93 gravity=0.0,
94 weekly=[0] * 12,
95 blast_top=[],
96 )
97 .on_conflict_do_update(
98 index_elements=["repo_id", "address"],
99 set_={
100 "symbol_kind": kind,
101 "blast": blast,
102 "blast_direct": blast_direct,
103 "blast_cross": blast_cross,
104 "churn": churn,
105 "churn_30d": churn_30d,
106 "churn_90d": churn_90d,
107 },
108 )
109 )
110 await session.execute(stmt)
111 await session.flush()
112
113
114 async def _get_dead(
115 session: AsyncSession, repo_id: str, address: str
116 ) -> db.MusehubIntelDead | None:
117 result = await session.execute(
118 select(db.MusehubIntelDead).where(
119 db.MusehubIntelDead.repo_id == repo_id,
120 db.MusehubIntelDead.address == address,
121 )
122 )
123 return result.scalar_one_or_none()
124
125
126 async def _run_provider(session: AsyncSession, repo_id: str) -> list:
127 from musehub.services.musehub_intel_providers import DeadProvider
128 provider = DeadProvider()
129 return await provider.compute(session, repo_id, "—", {})
130
131
132 # ---------------------------------------------------------------------------
133 # Layer 1 — Registry
134 # ---------------------------------------------------------------------------
135
136 class TestDeadProviderRegistry:
137
138 def test_P1_01_dead_in_provider_registry(self) -> None:
139 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
140 assert "intel.code.dead" in _PROVIDER_REGISTRY
141
142 def test_P1_02_dead_satisfies_intel_provider_protocol(self) -> None:
143 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY, IntelProvider
144 provider = _PROVIDER_REGISTRY["intel.code.dead"]
145 assert isinstance(provider, IntelProvider)
146
147
148 # ---------------------------------------------------------------------------
149 # Layer 2 — Dispatch
150 # ---------------------------------------------------------------------------
151
152 class TestDeadProviderDispatch:
153
154 def test_P1_03_job_types_for_push_code_includes_dead(self) -> None:
155 from musehub.services.musehub_intel_providers import job_types_for_push
156 assert "intel.code.dead" in job_types_for_push("code")
157
158 def test_P1_04_job_types_for_push_midi_excludes_dead(self) -> None:
159 from musehub.services.musehub_intel_providers import job_types_for_push
160 assert "intel.code.dead" not in job_types_for_push("midi")
161
162
163 # ---------------------------------------------------------------------------
164 # Layer 3 — Confidence tiers
165 # ---------------------------------------------------------------------------
166
167 class TestDeadProviderConfidence:
168
169 @pytest.mark.asyncio
170 async def test_P1_05_blast0_churn1_yields_high(
171 self, db_session: AsyncSession
172 ) -> None:
173 repo = await create_repo(db_session)
174 await _seed_symbol(
175 db_session, repo.repo_id,
176 address="pkg/a.py::only_added",
177 blast=0, churn=1, churn_30d=0,
178 )
179 await _run_provider(db_session, repo.repo_id)
180 row = await _get_dead(db_session, repo.repo_id, "pkg/a.py::only_added")
181 assert row is not None
182 assert row.confidence == "high"
183
184 @pytest.mark.asyncio
185 async def test_P1_06_blast0_churn_gt1_churn30d0_yields_medium(
186 self, db_session: AsyncSession
187 ) -> None:
188 repo = await create_repo(db_session)
189 await _seed_symbol(
190 db_session, repo.repo_id,
191 address="pkg/b.py::touched_but_quiet",
192 blast=0, churn=5, churn_30d=0,
193 )
194 await _run_provider(db_session, repo.repo_id)
195 row = await _get_dead(db_session, repo.repo_id, "pkg/b.py::touched_but_quiet")
196 assert row is not None
197 assert row.confidence == "medium"
198
199 @pytest.mark.asyncio
200 async def test_P1_07_blast0_churn30d_gt0_yields_low(
201 self, db_session: AsyncSession
202 ) -> None:
203 repo = await create_repo(db_session)
204 await _seed_symbol(
205 db_session, repo.repo_id,
206 address="pkg/c.py::recently_active",
207 blast=0, churn=3, churn_30d=2,
208 )
209 await _run_provider(db_session, repo.repo_id)
210 row = await _get_dead(db_session, repo.repo_id, "pkg/c.py::recently_active")
211 assert row is not None
212 assert row.confidence == "low"
213
214
215 # ---------------------------------------------------------------------------
216 # Layer 4 — Exclusion rules
217 # ---------------------------------------------------------------------------
218
219 class TestDeadProviderExclusion:
220
221 @pytest.mark.asyncio
222 async def test_P1_08_blast_gt0_excluded(
223 self, db_session: AsyncSession
224 ) -> None:
225 repo = await create_repo(db_session)
226 await _seed_symbol(
227 db_session, repo.repo_id,
228 address="pkg/d.py::has_dependents",
229 blast=3, churn=1, churn_30d=0,
230 )
231 await _run_provider(db_session, repo.repo_id)
232 row = await _get_dead(db_session, repo.repo_id, "pkg/d.py::has_dependents")
233 assert row is None
234
235 @pytest.mark.asyncio
236 async def test_P1_09_untracked_kind_excluded(
237 self, db_session: AsyncSession
238 ) -> None:
239 repo = await create_repo(db_session)
240 await _seed_symbol(
241 db_session, repo.repo_id,
242 address="pkg/e.py::some_import",
243 kind="import",
244 blast=0, churn=1, churn_30d=0,
245 )
246 await _run_provider(db_session, repo.repo_id)
247 row = await _get_dead(db_session, repo.repo_id, "pkg/e.py::some_import")
248 assert row is None
249
250
251 # ---------------------------------------------------------------------------
252 # Layer 5 — Reason strings
253 # ---------------------------------------------------------------------------
254
255 class TestDeadProviderReasons:
256
257 @pytest.mark.asyncio
258 async def test_P1_10_high_reason_string(
259 self, db_session: AsyncSession
260 ) -> None:
261 repo = await create_repo(db_session)
262 await _seed_symbol(
263 db_session, repo.repo_id,
264 address="pkg/f.py::high_sym",
265 blast=0, churn=1, churn_30d=0,
266 )
267 await _run_provider(db_session, repo.repo_id)
268 row = await _get_dead(db_session, repo.repo_id, "pkg/f.py::high_sym")
269 assert row is not None
270 assert row.reason == "Added once, never modified. Zero blast radius in full history."
271
272 @pytest.mark.asyncio
273 async def test_P1_10b_medium_reason_string(
274 self, db_session: AsyncSession
275 ) -> None:
276 repo = await create_repo(db_session)
277 await _seed_symbol(
278 db_session, repo.repo_id,
279 address="pkg/g.py::medium_sym",
280 blast=0, churn=3, churn_30d=0,
281 )
282 await _run_provider(db_session, repo.repo_id)
283 row = await _get_dead(db_session, repo.repo_id, "pkg/g.py::medium_sym")
284 assert row is not None
285 assert row.reason == "Modified in past but zero blast radius for ≥ 30 days."
286
287 @pytest.mark.asyncio
288 async def test_P1_10c_low_reason_string(
289 self, db_session: AsyncSession
290 ) -> None:
291 repo = await create_repo(db_session)
292 await _seed_symbol(
293 db_session, repo.repo_id,
294 address="pkg/h.py::low_sym",
295 blast=0, churn=2, churn_30d=1,
296 )
297 await _run_provider(db_session, repo.repo_id)
298 row = await _get_dead(db_session, repo.repo_id, "pkg/h.py::low_sym")
299 assert row is not None
300 assert row.reason == "Zero blast radius. Recently active — verify before deleting."
301
302
303 # ---------------------------------------------------------------------------
304 # Layer 6 — Dismiss preservation
305 # ---------------------------------------------------------------------------
306
307 class TestDeadProviderDismiss:
308
309 @pytest.mark.asyncio
310 async def test_P1_11_new_rows_start_not_dismissed(
311 self, db_session: AsyncSession
312 ) -> None:
313 repo = await create_repo(db_session)
314 await _seed_symbol(
315 db_session, repo.repo_id,
316 address="pkg/i.py::new_sym",
317 blast=0, churn=1, churn_30d=0,
318 )
319 await _run_provider(db_session, repo.repo_id)
320 row = await _get_dead(db_session, repo.repo_id, "pkg/i.py::new_sym")
321 assert row is not None
322 assert row.dismissed is False
323
324 @pytest.mark.asyncio
325 async def test_P1_11b_existing_dismissed_preserved_on_rerun(
326 self, db_session: AsyncSession
327 ) -> None:
328 repo = await create_repo(db_session)
329 await _seed_symbol(
330 db_session, repo.repo_id,
331 address="pkg/j.py::dismissed_sym",
332 blast=0, churn=1, churn_30d=0,
333 )
334 # First run — creates the row
335 await _run_provider(db_session, repo.repo_id)
336 # Manually dismiss it
337 row = await _get_dead(db_session, repo.repo_id, "pkg/j.py::dismissed_sym")
338 row.dismissed = True
339 await db_session.flush()
340 # Second run — must NOT reset dismissed to False
341 await _run_provider(db_session, repo.repo_id)
342 row = await _get_dead(db_session, repo.repo_id, "pkg/j.py::dismissed_sym")
343 assert row is not None
344 assert row.dismissed is True
345
346
347 # ---------------------------------------------------------------------------
348 # Layer 7 — Edge cases
349 # ---------------------------------------------------------------------------
350
351 class TestDeadProviderEdgeCases:
352
353 @pytest.mark.asyncio
354 async def test_P1_12_empty_repo_returns_empty_list(
355 self, db_session: AsyncSession
356 ) -> None:
357 repo = await create_repo(db_session)
358 result = await _run_provider(db_session, repo.repo_id)
359 assert result == []
360
361 @pytest.mark.asyncio
362 async def test_P1_13_idempotent_run_twice_one_row(
363 self, db_session: AsyncSession
364 ) -> None:
365 repo = await create_repo(db_session)
366 await _seed_symbol(
367 db_session, repo.repo_id,
368 address="pkg/k.py::stable_sym",
369 blast=0, churn=1, churn_30d=0,
370 )
371 await _run_provider(db_session, repo.repo_id)
372 await _run_provider(db_session, repo.repo_id)
373 from sqlalchemy import func
374 count = (await db_session.execute(
375 select(func.count()).select_from(db.MusehubIntelDead).where(
376 db.MusehubIntelDead.repo_id == repo.repo_id,
377 db.MusehubIntelDead.address == "pkg/k.py::stable_sym",
378 )
379 )).scalar_one()
380 assert count == 1
381
382 @pytest.mark.asyncio
383 async def test_P1_14_return_type(
384 self, db_session: AsyncSession
385 ) -> None:
386 repo = await create_repo(db_session)
387 await _seed_symbol(
388 db_session, repo.repo_id,
389 address="pkg/l.py::ret_sym",
390 blast=0, churn=1, churn_30d=0,
391 )
392 result = await _run_provider(db_session, repo.repo_id)
393 assert len(result) == 1
394 intel_type, data = result[0]
395 assert intel_type == "intel.code.dead"
396 assert data["count"] == 1
397
398
399 # ---------------------------------------------------------------------------
400 # Layer 8 — No subprocess
401 # ---------------------------------------------------------------------------
402
403 class TestDeadProviderNoSubprocess:
404
405 @pytest.mark.asyncio
406 async def test_P1_15_no_subprocess_spawned(
407 self, db_session: AsyncSession
408 ) -> None:
409 repo = await create_repo(db_session)
410 await _seed_symbol(
411 db_session, repo.repo_id,
412 address="pkg/m.py::no_proc_sym",
413 blast=0, churn=1, churn_30d=0,
414 )
415 with patch("asyncio.create_subprocess_exec") as mock_exec:
416 await _run_provider(db_session, repo.repo_id)
417 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