gabriel / musehub public
test_intel_type.py python
640 lines 26.4 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago
1 """Type Health dashboard — full 7-tier test suite (issue #18).
2
3 Tests are written TDD-first: all tests in this file must be RED before
4 Phase 3–5 implementation begins, then GREEN after.
5
6 Tiers:
7 T01–T03 Layer T1 — DB extension (return_annotation column)
8 T04–T05 Layer T2 — Provider batch performance
9 T06–T14 Layer T3 — Route (unit / integration)
10 T15–T19 Layer T4 — E2E (HTML body assertions)
11 T20–T22 Layer T5 — State integrity
12 T23–T25 Layer T6 — Performance
13 T26–T30 Layer T7 — Security
14 """
15 from __future__ import annotations
16
17 import time
18 from unittest.mock import AsyncMock, patch
19
20 import pytest
21 import pytest_asyncio
22 import sqlalchemy as sa
23 from httpx import AsyncClient
24 from sqlalchemy.dialects.postgresql import insert as pg_insert
25 from sqlalchemy.ext.asyncio import AsyncSession
26
27 from musehub.db import musehub_models as dbm
28 from tests.factories import create_repo
29 from muse.core.types import long_id
30
31 _REF = long_id("a" * 64)
32
33
34 # ---------------------------------------------------------------------------
35 # Helpers
36 # ---------------------------------------------------------------------------
37
38 async def _insert_type_row(
39 session: AsyncSession,
40 repo_id: str,
41 address: str,
42 kind: str = "function",
43 type_score: float = 1.0,
44 return_is_any: bool = False,
45 params_total: int = 2,
46 params_annotated: int = 2,
47 params_with_any: int = 0,
48 return_annotation: str | None = "str",
49 ) -> None:
50 await session.execute(
51 pg_insert(dbm.MusehubIntelType)
52 .values(
53 repo_id=repo_id,
54 address=address,
55 kind=kind,
56 type_score=type_score,
57 return_is_any=return_is_any,
58 params_total=params_total,
59 params_annotated=params_annotated,
60 params_with_any=params_with_any,
61 return_annotation=return_annotation,
62 ref=_REF,
63 )
64 .on_conflict_do_update(
65 index_elements=["repo_id", "address"],
66 set_={
67 "type_score": type_score,
68 "return_annotation": return_annotation,
69 },
70 )
71 )
72
73
74 @pytest_asyncio.fixture
75 async def type_repo(db_session: AsyncSession):
76 """Repo with a mix of fully-typed, partial, untyped, and any-polluted symbols."""
77 repo = await create_repo(db_session, owner="typeuser", slug="type-e2e")
78 rid = str(repo.repo_id)
79
80 # fully typed (score=1.0)
81 await _insert_type_row(db_session, rid, "src/a.py::fn_full",
82 type_score=1.0, return_annotation="str")
83 # partial (score=0.75)
84 await _insert_type_row(db_session, rid, "src/b.py::fn_partial",
85 kind="method", type_score=0.75,
86 params_total=4, params_annotated=3,
87 return_annotation="None")
88 # untyped (score=0.0)
89 await _insert_type_row(db_session, rid, "src/c.py::fn_untyped",
90 type_score=0.0, params_annotated=0,
91 return_annotation=None)
92 # any-polluted (has params_with_any)
93 await _insert_type_row(db_session, rid, "src/d.py::fn_any",
94 type_score=0.75, return_is_any=False,
95 params_with_any=1, return_annotation="Any")
96
97 await db_session.commit()
98 return repo
99
100
101 # ─────────────────────────────────────────────────────────────────────────────
102 # Layer T1 — DB extension
103 # ─────────────────────────────────────────────────────────────────────────────
104
105 class TestDBExtension:
106
107 def test_T01_return_annotation_column_exists_on_model(self) -> None:
108 """MusehubIntelType must have a return_annotation mapped column."""
109 cols = {c.key for c in sa.inspect(dbm.MusehubIntelType).mapper.column_attrs}
110 assert "return_annotation" in cols, (
111 "return_annotation column missing from MusehubIntelType"
112 )
113
114 def test_T02_return_annotation_is_nullable(self) -> None:
115 """return_annotation must be nullable (existing rows have no value)."""
116 col = dbm.MusehubIntelType.__table__.c["return_annotation"]
117 assert col.nullable, "return_annotation must be nullable"
118
119 @pytest.mark.asyncio
120 async def test_T03_return_annotation_stored_and_retrieved(
121 self, db_session: AsyncSession
122 ) -> None:
123 """Inserting a row with return_annotation persists and round-trips."""
124 repo = await create_repo(db_session, owner="typeuser", slug="t03")
125 await _insert_type_row(db_session, str(repo.repo_id),
126 "src/x.py::fn", return_annotation="list[str]")
127 await db_session.commit()
128
129 row = await db_session.scalar(
130 sa.select(dbm.MusehubIntelType).where(
131 dbm.MusehubIntelType.repo_id == str(repo.repo_id),
132 dbm.MusehubIntelType.address == "src/x.py::fn",
133 )
134 )
135 assert row is not None
136 assert row.return_annotation == "list[str]"
137
138
139 # ─────────────────────────────────────────────────────────────────────────────
140 # Layer T2 — Provider batch performance
141 # ─────────────────────────────────────────────────────────────────────────────
142
143 class TestProviderBatch:
144
145 @pytest.mark.asyncio
146 async def test_T04_type_provider_issues_one_sql_per_chunk(
147 self, db_session: AsyncSession
148 ) -> None:
149 """TypeProvider must use batch upsert, not one execute per symbol."""
150 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
151
152 repo = await create_repo(db_session, owner="typeuser", slug="t04")
153 ref = _REF
154
155 symbols = [
156 {
157 "address": f"src/m{i}.py::fn",
158 "kind": "function",
159 "return_annotation": "str",
160 "return_is_any": False,
161 "params_total": 1,
162 "params_annotated": 1,
163 "params_with_any": 0,
164 "type_score": 1.0,
165 }
166 for i in range(50)
167 ]
168 muse_out = __import__("json").dumps({"symbols": symbols})
169
170 execute_calls: list[sa.Executable] = []
171 original_execute = db_session.execute
172
173 async def counting_execute(stmt, *args, **kwargs):
174 execute_calls.append(stmt)
175 return await original_execute(stmt, *args, **kwargs)
176
177 with patch("asyncio.create_subprocess_exec",
178 return_value=_mock_process(muse_out)):
179 db_session.execute = counting_execute # type: ignore[method-assign]
180 await _PROVIDER_REGISTRY["intel.code.type"].compute(
181 db_session, repo.repo_id, ref,
182 {"head": ref, "owner": repo.owner, "slug": repo.slug},
183 )
184 db_session.execute = original_execute # type: ignore[method-assign]
185
186 # 50 symbols fit in one chunk of 1000 — expect exactly 1 upsert execute
187 upsert_calls = [
188 c for c in execute_calls
189 if hasattr(c, "is_dml") or "INSERT" in str(type(c).__name__).upper()
190 or "insert" in str(c).lower()
191 ]
192 assert len(upsert_calls) == 1, (
193 f"Expected 1 batch upsert execute for 50 symbols, got {len(upsert_calls)}"
194 )
195
196 @pytest.mark.asyncio
197 async def test_T05_upsert_500_symbols_under_500ms(
198 self, db_session: AsyncSession
199 ) -> None:
200 """Batch-upserting 500 symbols must complete in under 500ms."""
201 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
202
203 repo = await create_repo(db_session, owner="typeuser", slug="t05")
204 symbols = [
205 {
206 "address": f"src/file{i}.py::fn_{i}",
207 "kind": "function",
208 "return_annotation": "int",
209 "return_is_any": False,
210 "params_total": 2,
211 "params_annotated": 2,
212 "params_with_any": 0,
213 "type_score": 1.0,
214 }
215 for i in range(500)
216 ]
217 muse_out = __import__("json").dumps({"symbols": symbols})
218
219 t0 = time.monotonic()
220 with patch("asyncio.create_subprocess_exec",
221 return_value=_mock_process(muse_out)):
222 await _PROVIDER_REGISTRY["intel.code.type"].compute(
223 db_session, repo.repo_id, _REF,
224 {"head": _REF, "owner": repo.owner, "slug": repo.slug},
225 )
226 elapsed = time.monotonic() - t0
227 assert elapsed < 0.5, f"500-symbol batch took {elapsed:.3f}s (limit: 0.5s)"
228
229
230 # ─────────────────────────────────────────────────────────────────────────────
231 # Layer T3 — Route (unit / integration)
232 # ─────────────────────────────────────────────────────────────────────────────
233
234 class TestRoute:
235
236 @pytest.mark.asyncio
237 async def test_T06_returns_200_with_empty_repo(
238 self, client: AsyncClient, db_session: AsyncSession
239 ) -> None:
240 """Route must return 200 even when musehub_intel_type has no rows."""
241 await create_repo(db_session, owner="typeuser", slug="t06-empty")
242 await db_session.commit()
243 r = await client.get("/typeuser/t06-empty/intel/type")
244 assert r.status_code == 200
245
246 @pytest.mark.asyncio
247 async def test_T07_returns_200_with_data(
248 self, client: AsyncClient, type_repo
249 ) -> None:
250 """Route returns 200 when rows exist."""
251 r = await client.get(f"/typeuser/type-e2e/intel/type")
252 assert r.status_code == 200
253
254 @pytest.mark.asyncio
255 async def test_T08_summary_stats_match_db_counts(
256 self, client: AsyncClient, type_repo
257 ) -> None:
258 """Coverage fraction and tier counts must be derived from DB, not hardcoded."""
259 r = await client.get("/typeuser/type-e2e/intel/type")
260 assert r.status_code == 200
261 body = r.text
262 # 1 fully typed out of 4 total → 25.0%
263 assert "25" in body, "coverage_pct (25%) not found in response"
264 # 1 untyped symbol
265 assert "fn_untyped" in body or "1" in body
266
267 @pytest.mark.asyncio
268 async def test_T09_filter_tier_untyped(
269 self, client: AsyncClient, type_repo
270 ) -> None:
271 """?tier=untyped returns only symbols with type_score < 0.5."""
272 r = await client.get("/typeuser/type-e2e/intel/type?tier=untyped")
273 assert r.status_code == 200
274 assert "fn_untyped" in r.text
275 assert "fn_full" not in r.text
276
277 @pytest.mark.asyncio
278 async def test_T10_filter_tier_partial(
279 self, client: AsyncClient, type_repo
280 ) -> None:
281 """?tier=partial returns only symbols with 0.5 ≤ type_score < 1.0."""
282 r = await client.get("/typeuser/type-e2e/intel/type?tier=partial")
283 assert r.status_code == 200
284 assert "fn_partial" in r.text
285 assert "fn_untyped" not in r.text
286 assert "fn_full" not in r.text
287
288 @pytest.mark.asyncio
289 async def test_T11_filter_tier_any(
290 self, client: AsyncClient, type_repo
291 ) -> None:
292 """?tier=any returns only symbols with return_is_any or params_with_any > 0."""
293 r = await client.get("/typeuser/type-e2e/intel/type?tier=any")
294 assert r.status_code == 200
295 assert "fn_any" in r.text
296 assert "fn_full" not in r.text
297
298 @pytest.mark.asyncio
299 async def test_T12_filter_kind_function(
300 self, client: AsyncClient, type_repo
301 ) -> None:
302 """?kind=function returns only function-kind symbols."""
303 r = await client.get("/typeuser/type-e2e/intel/type?kind=function")
304 assert r.status_code == 200
305 # fn_full is kind=function; fn_partial is kind=method
306 assert "fn_full" in r.text
307 assert "fn_partial" not in r.text
308
309 @pytest.mark.asyncio
310 async def test_T13_default_sort_score_ascending(
311 self, client: AsyncClient, type_repo
312 ) -> None:
313 """Default sort is type_score ASC (worst-typed first)."""
314 r = await client.get("/typeuser/type-e2e/intel/type")
315 assert r.status_code == 200
316 body = r.text
317 pos_untyped = body.find("fn_untyped")
318 pos_full = body.find("fn_full")
319 assert pos_untyped != -1 and pos_full != -1
320 assert pos_untyped < pos_full, "Untyped symbol must appear before fully-typed"
321
322 @pytest.mark.asyncio
323 async def test_T14_top_param_limits_results(
324 self, client: AsyncClient, db_session: AsyncSession
325 ) -> None:
326 """?top=20 returns at most 20 symbols even when 25 exist."""
327 repo = await create_repo(db_session, owner="typeuser", slug="t14-top")
328 rid = str(repo.repo_id)
329 for i in range(25):
330 await _insert_type_row(db_session, rid,
331 f"src/f{i}.py::fn_{i}", type_score=float(i) / 24)
332 await db_session.commit()
333
334 r = await client.get("/typeuser/t14-top/intel/type?top=20")
335 assert r.status_code == 200
336 count = sum(1 for i in range(25) if f"src/f{i}.py::fn_{i}" in r.text)
337 assert count <= 20, f"Expected ≤20 results for ?top=20, got {count}"
338
339
340 # ─────────────────────────────────────────────────────────────────────────────
341 # Layer T4 — E2E (HTML body assertions)
342 # ─────────────────────────────────────────────────────────────────────────────
343
344 class TestE2E:
345
346 @pytest.mark.asyncio
347 async def test_T15_coverage_fraction_rendered_as_pct(
348 self, client: AsyncClient, type_repo
349 ) -> None:
350 """Coverage fraction (0.25) must be rendered as a percentage string."""
351 r = await client.get("/typeuser/type-e2e/intel/type")
352 assert r.status_code == 200
353 # 1/4 fully typed = 25%
354 assert "25" in r.text
355
356 @pytest.mark.asyncio
357 async def test_T16_symbol_address_in_html(
358 self, client: AsyncClient, type_repo
359 ) -> None:
360 """Symbol addresses must appear verbatim in the HTML body."""
361 r = await client.get("/typeuser/type-e2e/intel/type")
362 assert r.status_code == 200
363 assert "src/c.py::fn_untyped" in r.text
364
365 @pytest.mark.asyncio
366 async def test_T17_return_annotation_in_html(
367 self, client: AsyncClient, type_repo
368 ) -> None:
369 """Non-null return_annotation must appear in the rendered HTML."""
370 r = await client.get("/typeuser/type-e2e/intel/type")
371 assert r.status_code == 200
372 assert "list[str]" in r.text or "str" in r.text
373
374 @pytest.mark.asyncio
375 async def test_T18_any_badge_rendered_for_any_polluted_symbol(
376 self, client: AsyncClient, type_repo
377 ) -> None:
378 """Any-pollution indicator must appear for symbols with params_with_any > 0."""
379 r = await client.get("/typeuser/type-e2e/intel/type")
380 assert r.status_code == 200
381 # The any-badge or warning marker must be in the HTML
382 assert "any" in r.text.lower() or "⚠" in r.text
383
384 @pytest.mark.asyncio
385 async def test_T19_dashboard_card_links_to_type_page(
386 self, client: AsyncClient, type_repo
387 ) -> None:
388 """Intel dashboard card must include a link to /intel/type."""
389 r = await client.get("/typeuser/type-e2e/intel")
390 assert r.status_code == 200
391 assert b"/intel/type" in r.content
392
393
394 # ─────────────────────────────────────────────────────────────────────────────
395 # Layer T5 — State integrity
396 # ─────────────────────────────────────────────────────────────────────────────
397
398 class TestStateIntegrity:
399
400 @pytest.mark.asyncio
401 async def test_T20_push_twice_produces_one_row_per_symbol(
402 self, db_session: AsyncSession
403 ) -> None:
404 """Upserting the same address twice must not create duplicate rows."""
405 repo = await create_repo(db_session, owner="typeuser", slug="t20-dup")
406 rid = str(repo.repo_id)
407 addr = "src/a.py::fn"
408
409 for _ in range(2):
410 await _insert_type_row(db_session, rid, addr, type_score=1.0)
411 await db_session.commit()
412
413 rows = (await db_session.execute(
414 sa.select(dbm.MusehubIntelType).where(
415 dbm.MusehubIntelType.repo_id == rid
416 )
417 )).scalars().all()
418 assert len(rows) == 1, f"Expected 1 row, got {len(rows)} — upsert broken"
419
420 @pytest.mark.asyncio
421 async def test_T21_second_push_overwrites_type_score(
422 self, db_session: AsyncSession
423 ) -> None:
424 """A second push with different type_score must overwrite the first."""
425 repo = await create_repo(db_session, owner="typeuser", slug="t21-overwrite")
426 rid = str(repo.repo_id)
427 addr = "src/a.py::fn"
428
429 await _insert_type_row(db_session, rid, addr, type_score=0.5)
430 await _insert_type_row(db_session, rid, addr, type_score=1.0)
431 await db_session.commit()
432
433 row = await db_session.scalar(
434 sa.select(dbm.MusehubIntelType).where(
435 dbm.MusehubIntelType.repo_id == rid,
436 dbm.MusehubIntelType.address == addr,
437 )
438 )
439 assert row is not None
440 assert row.type_score == pytest.approx(1.0), (
441 f"Expected score 1.0 after second push, got {row.type_score}"
442 )
443
444 @pytest.mark.asyncio
445 async def test_T22_repo_delete_cascades_type_rows(
446 self, db_session: AsyncSession
447 ) -> None:
448 """Deleting the repo must cascade-delete all musehub_intel_type rows."""
449 from musehub.db.musehub_models import MusehubRepo
450
451 repo = await create_repo(db_session, owner="typeuser", slug="t22-cascade")
452 rid = str(repo.repo_id)
453 await _insert_type_row(db_session, rid, "src/a.py::fn")
454 await db_session.commit()
455
456 await db_session.delete(repo)
457 await db_session.commit()
458
459 remaining = (await db_session.execute(
460 sa.select(dbm.MusehubIntelType).where(
461 dbm.MusehubIntelType.repo_id == rid
462 )
463 )).scalars().all()
464 assert not remaining, "Cascade delete failed — type rows remain after repo delete"
465
466
467 # ─────────────────────────────────────────────────────────────────────────────
468 # Layer T6 — Performance
469 # ─────────────────────────────────────────────────────────────────────────────
470
471 class TestPerformance:
472
473 @pytest.mark.asyncio
474 async def test_T23_route_responds_under_200ms_for_10k_symbols(
475 self, client: AsyncClient, db_session: AsyncSession
476 ) -> None:
477 """Route must respond in < 200ms for a repo with 10,000 symbol rows."""
478 repo = await create_repo(db_session, owner="typeuser", slug="t23-perf")
479 rid = str(repo.repo_id)
480
481 # Insert 10k rows via direct batch insert
482 chunk = 1000
483 for start in range(0, 10_000, chunk):
484 rows = [
485 {
486 "repo_id": rid,
487 "address": f"src/file{i}.py::fn_{i}",
488 "kind": "function",
489 "type_score": 1.0 if i % 3 != 0 else 0.5,
490 "return_is_any": False,
491 "params_total": 2,
492 "params_annotated": 2,
493 "params_with_any": 0,
494 "return_annotation": "str",
495 "ref": _REF,
496 }
497 for i in range(start, start + chunk)
498 ]
499 await db_session.execute(
500 pg_insert(dbm.MusehubIntelType)
501 .values(rows)
502 .on_conflict_do_nothing()
503 )
504 await db_session.commit()
505
506 t0 = time.monotonic()
507 r = await client.get(f"/typeuser/t23-perf/intel/type")
508 elapsed = time.monotonic() - t0
509
510 assert r.status_code == 200
511 assert elapsed < 0.2, f"Route took {elapsed:.3f}s for 10k symbols (limit: 0.2s)"
512
513 @pytest.mark.asyncio
514 async def test_T24_db_query_uses_repo_index(
515 self, db_session: AsyncSession
516 ) -> None:
517 """SELECT on musehub_intel_type must use ix_intel_type_repo index."""
518 explain = await db_session.execute(
519 sa.text(
520 "EXPLAIN SELECT * FROM musehub_intel_type WHERE repo_id = 'x'"
521 )
522 )
523 plan = " ".join(row[0] for row in explain.all())
524 assert "ix_intel_type_repo" in plan or "Index" in plan, (
525 f"Query plan does not use ix_intel_type_repo:\n{plan}"
526 )
527
528 @pytest.mark.asyncio
529 async def test_T25_batch_upsert_500_symbols_under_500ms(
530 self, db_session: AsyncSession
531 ) -> None:
532 """Direct batch upsert of 500 rows must complete in < 500ms wall time."""
533 repo = await create_repo(db_session, owner="typeuser", slug="t25-batch")
534 rid = str(repo.repo_id)
535 rows = [
536 {
537 "repo_id": rid,
538 "address": f"src/f{i}.py::fn",
539 "kind": "function",
540 "type_score": 0.9,
541 "return_is_any": False,
542 "params_total": 1,
543 "params_annotated": 1,
544 "params_with_any": 0,
545 "return_annotation": None,
546 "ref": _REF,
547 }
548 for i in range(500)
549 ]
550 t0 = time.monotonic()
551 await db_session.execute(
552 pg_insert(dbm.MusehubIntelType)
553 .values(rows)
554 .on_conflict_do_nothing()
555 )
556 await db_session.commit()
557 elapsed = time.monotonic() - t0
558 assert elapsed < 0.5, f"500-row batch took {elapsed:.3f}s (limit: 0.5s)"
559
560
561 # ─────────────────────────────────────────────────────────────────────────────
562 # Layer T7 — Security
563 # ─────────────────────────────────────────────────────────────────────────────
564
565 class TestSecurity:
566
567 @pytest.mark.asyncio
568 async def test_T26_xss_in_address_is_escaped(
569 self, client: AsyncClient, db_session: AsyncSession
570 ) -> None:
571 """XSS payload in address must be HTML-escaped in the response."""
572 repo = await create_repo(db_session, owner="typeuser", slug="t26-xss")
573 rid = str(repo.repo_id)
574 xss = '<script>alert(1)</script>'
575 # Truncate to fit VARCHAR(512) and make it a valid-ish address
576 await _insert_type_row(db_session, rid,
577 f"src/x.py::{xss[:40]}", type_score=0.0)
578 await db_session.commit()
579
580 r = await client.get("/typeuser/t26-xss/intel/type")
581 assert r.status_code == 200
582 # Jinja2 autoescape must convert <script> to &lt;script&gt;.
583 # The raw executable tag must not appear unescaped.
584 assert "<script>alert" not in r.text, "XSS in address not escaped"
585
586 @pytest.mark.asyncio
587 async def test_T27_xss_in_return_annotation_is_escaped(
588 self, client: AsyncClient, db_session: AsyncSession
589 ) -> None:
590 """XSS payload in return_annotation must be HTML-escaped."""
591 repo = await create_repo(db_session, owner="typeuser", slug="t27-xss-ret")
592 rid = str(repo.repo_id)
593 await _insert_type_row(db_session, rid, "src/x.py::fn",
594 return_annotation='<img src=x onerror=alert(1)>')
595 await db_session.commit()
596
597 r = await client.get("/typeuser/t27-xss-ret/intel/type")
598 assert r.status_code == 200
599 # Raw unescaped tag must not appear; &lt;img ... is safe.
600 assert "<img src=x onerror" not in r.text, "XSS in return_annotation not escaped"
601
602 @pytest.mark.asyncio
603 async def test_T28_unknown_tier_param_treated_as_all(
604 self, client: AsyncClient, type_repo
605 ) -> None:
606 """?tier=unknown must return 200 (treated as 'all'), not 400/500."""
607 r = await client.get("/typeuser/type-e2e/intel/type?tier=garbage")
608 assert r.status_code == 200
609
610 @pytest.mark.asyncio
611 async def test_T29_non_integer_top_param_returns_422(
612 self, client: AsyncClient, type_repo
613 ) -> None:
614 """?top=notanumber must be rejected with 422 (FastAPI type validation)."""
615 r = await client.get("/typeuser/type-e2e/intel/type?top=notanumber")
616 assert r.status_code == 422
617
618 @pytest.mark.asyncio
619 async def test_T30_private_repo_returns_403_or_404_unauthenticated(
620 self, client: AsyncClient
621 ) -> None:
622 """A non-existent repo path must not return 200 or 500."""
623 r = await client.get("/nobody/no-such-repo/intel/type")
624 assert r.status_code in (403, 404)
625
626
627 # ---------------------------------------------------------------------------
628 # Internal helpers
629 # ---------------------------------------------------------------------------
630
631 def _mock_process(stdout: str, returncode: int = 0) -> AsyncMock:
632 proc = AsyncMock()
633 proc.returncode = returncode
634 proc.communicate = AsyncMock(return_value=(stdout.encode(), b""))
635 return proc
636
637
638 async def _make_repo_via_client(client: AsyncClient, owner: str, slug: str) -> str:
639 """Return slug — the caller constructs the path externally."""
640 return slug
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago