gabriel / muse public
test_core_doc_extractor.py python
552 lines 19.6 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Unit and integration tests for ``muse.core.doc_extractor``.
2
3 Coverage:
4 - :func:`_build_lineno_docstring_map` with valid and invalid Python source.
5 - :func:`_get_docstring` with object-store hits, file fallback, and caching.
6 - :func:`_extract_signature` with functions, classes, and edge cases.
7 - :func:`_compute_health` for each health dimension.
8 - :func:`build_symbol_test_map` BFS logic.
9 - :func:`extract_docs` integration with a synthetic repository.
10 - :func:`_is_public` naming convention.
11 - DocSummary aggregation (avg_health, debt_score, counts).
12 """
13
14 from __future__ import annotations
15
16 import ast
17 import datetime
18 import hashlib
19 import pathlib
20 import uuid
21
22 import pytest
23
24 from muse.core.doc_extractor import (
25 DocHealthReason,
26 DocReport,
27 DocSummary,
28 MissingDocEntry,
29 StaleDocEntry,
30 SymbolDoc,
31 _build_lineno_docstring_map,
32 _compute_health,
33 _extract_signature,
34 _get_docstring,
35 _is_public,
36 build_symbol_test_map,
37 extract_docs,
38 )
39 from muse.core.doc_history import StaleInfo
40 from muse.core.object_store import write_object
41 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
42 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
43 from muse.plugins.code._callgraph import ForwardGraph, ReverseGraph
44 from muse.plugins.code.ast_parser import SymbolKind, SymbolRecord
45 from muse.core._types import Manifest, blob_id, fake_id
46
47
48 # ---------------------------------------------------------------------------
49 # Helpers
50 # ---------------------------------------------------------------------------
51
52
53 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
54 muse = tmp_path / ".muse"
55 muse.mkdir()
56 (muse / "repo.json").write_text('{"repo_id": "test-repo-123", "name": "test"}')
57 refs = muse / "refs" / "heads"
58 refs.mkdir(parents=True)
59 (muse / "HEAD").write_text("ref: refs/heads/main\n")
60 return tmp_path
61
62
63 def _write_commit_with_snapshot(
64 root: pathlib.Path,
65 manifest: Manifest,
66 ) -> str:
67 snap_id = compute_snapshot_id(manifest)
68 snap = SnapshotRecord(snapshot_id=snap_id, manifest=manifest)
69 write_snapshot(root, snap)
70
71 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
72 commit_id = compute_commit_id(
73 repo_id="test-repo-123",
74 parent_ids=[],
75 snapshot_id=snap_id,
76 message="init",
77 committed_at_iso=committed_at.isoformat(),
78 author="test",)
79 commit = CommitRecord(
80 commit_id=commit_id,
81 repo_id="test-repo-123",
82 created_on_branch="main",
83 snapshot_id=snap_id,
84 message="init",
85 committed_at=committed_at,
86 author="test",
87 )
88 write_commit(root, commit)
89 (root / ".muse" / "refs" / "heads" / "main").write_text(commit_id)
90 return commit_id
91
92
93 def _make_sym(
94 name: str,
95 lineno: int = 1,
96 end_lineno: int = 5,
97 kind: SymbolKind = "function",
98 ) -> SymbolRecord:
99 return SymbolRecord(
100 kind=kind,
101 name=name,
102 qualified_name=name,
103 content_id=fake_id(name),
104 body_hash=blob_id(b"body"),
105 signature_id=blob_id(b"sig"),
106 metadata_id="",
107 canonical_key=f"f.py##{kind}#{name}#{lineno}",
108 lineno=lineno,
109 end_lineno=end_lineno,
110 )
111
112
113 def _stale(is_stale: bool = False) -> StaleInfo:
114 return StaleInfo(
115 is_stale=is_stale,
116 last_doc_commit=None,
117 last_impl_commit=None,
118 signature_changed=False,
119 body_changed=False,
120 )
121
122
123 # ---------------------------------------------------------------------------
124 # Tests: _build_lineno_docstring_map
125 # ---------------------------------------------------------------------------
126
127
128 class TestBuildLinnoDocstringMap:
129 def test_function_with_docstring(self) -> None:
130 src = b'def foo():\n """My docstring."""\n pass\n'
131 m = _build_lineno_docstring_map(src)
132 assert m.get(1) == "My docstring."
133
134 def test_function_without_docstring(self) -> None:
135 src = b"def foo():\n pass\n"
136 m = _build_lineno_docstring_map(src)
137 assert m.get(1) is None
138
139 def test_class_with_docstring(self) -> None:
140 src = b'class Foo:\n """Class doc."""\n pass\n'
141 m = _build_lineno_docstring_map(src)
142 assert m.get(1) == "Class doc."
143
144 def test_nested_method_lineno(self) -> None:
145 src = (
146 b"class Foo:\n"
147 b" def bar(self):\n"
148 b' """Bar doc."""\n'
149 b" pass\n"
150 )
151 m = _build_lineno_docstring_map(src)
152 assert m.get(2) == "Bar doc."
153
154 def test_syntax_error_returns_empty(self) -> None:
155 src = b"def foo(:\n pass\n"
156 m = _build_lineno_docstring_map(src)
157 assert m == {}
158
159 def test_multiline_docstring(self) -> None:
160 src = (
161 b'def foo():\n'
162 b' """First line.\n'
163 b'\n'
164 b' Second paragraph.\n'
165 b' """\n'
166 b' pass\n'
167 )
168 m = _build_lineno_docstring_map(src)
169 doc = m.get(1)
170 assert doc is not None
171 assert "First line" in doc
172 assert "Second paragraph" in doc
173
174
175 # ---------------------------------------------------------------------------
176 # Tests: _get_docstring
177 # ---------------------------------------------------------------------------
178
179
180 class TestGetDocstring:
181 def test_from_object_store(self, tmp_path: pathlib.Path) -> None:
182 root = _make_repo(tmp_path)
183 src = b'def foo():\n """Object store doc."""\n pass\n'
184 content_hash = blob_id(src)
185 write_object(root, content_hash, src)
186
187 cache: dict[tuple[str, str], dict[int, str | None]] = {}
188 result = _get_docstring(root, "foo.py", 1, content_hash, cache)
189 assert result == "Object store doc."
190 # Cache should be populated.
191 assert ("foo.py", content_hash) in cache
192
193 def test_from_file_fallback(self, tmp_path: pathlib.Path) -> None:
194 root = _make_repo(tmp_path)
195 src = b'def bar():\n """File fallback doc."""\n pass\n'
196 (tmp_path / "bar.py").write_bytes(src)
197 # Use a fake hash so object store misses.
198 fake_hash = "sha256:" + "0" * 64
199
200 cache: dict[tuple[str, str], dict[int, str | None]] = {}
201 result = _get_docstring(root, "bar.py", 1, fake_hash, cache)
202 assert result == "File fallback doc."
203
204 def test_no_docstring_returns_none(self, tmp_path: pathlib.Path) -> None:
205 root = _make_repo(tmp_path)
206 src = b"def baz():\n pass\n"
207 h = blob_id(src)
208 write_object(root, h, src)
209 cache: dict[tuple[str, str], dict[int, str | None]] = {}
210 result = _get_docstring(root, "baz.py", 1, h, cache)
211 assert result is None
212
213 def test_cache_avoids_reparse(self, tmp_path: pathlib.Path) -> None:
214 """Once a (file, hash) is in cache, subsequent calls return the cached result."""
215 root = _make_repo(tmp_path)
216 src = b'def fn():\n """Cached doc."""\n pass\n'
217 h = blob_id(src)
218 write_object(root, h, src)
219 cache: dict[tuple[str, str], dict[int, str | None]] = {}
220 # First call — populates cache.
221 result1 = _get_docstring(root, "fn.py", 1, h, cache)
222 assert result1 == "Cached doc."
223 # Manually corrupt the cached map to verify cache is used on second call.
224 cache[("fn.py", h)][1] = "INJECTED"
225 result2 = _get_docstring(root, "fn.py", 1, h, cache)
226 assert result2 == "INJECTED" # cache hit — object store not re-read
227
228
229 # ---------------------------------------------------------------------------
230 # Tests: _extract_signature
231 # ---------------------------------------------------------------------------
232
233
234 class TestExtractSignature:
235 def test_function(self) -> None:
236 src = b"def my_func(x: int) -> str:\n return str(x)\n"
237 sig = _extract_signature(src, 1, 2)
238 assert "def my_func" in sig
239
240 def test_class(self) -> None:
241 src = b"class MyClass(Base):\n pass\n"
242 sig = _extract_signature(src, 1, 2)
243 assert "class MyClass" in sig
244
245 def test_async_function(self) -> None:
246 src = b"async def fetch():\n pass\n"
247 sig = _extract_signature(src, 1, 2)
248 assert "async def fetch" in sig
249
250 def test_decorator_skipped(self) -> None:
251 src = b"@property\ndef value(self) -> int:\n return 0\n"
252 sig = _extract_signature(src, 1, 3)
253 # The first line is a decorator — should still return something.
254 assert sig # non-empty
255
256 def test_out_of_range_fallback(self) -> None:
257 src = b"x = 1\n"
258 sig = _extract_signature(src, 100, 110)
259 assert sig == ""
260
261
262 # ---------------------------------------------------------------------------
263 # Tests: _compute_health
264 # ---------------------------------------------------------------------------
265
266
267 class TestComputeHealth:
268 def test_all_zero(self) -> None:
269 # No docstring = 0, no tests = 0, no version = 0, not stale = +0.15
270 score, reasons = _compute_health(None, [], None, _stale(False))
271 assert score == pytest.approx(0.15)
272 assert "no_docstring" in reasons
273 assert "no_tests" in reasons
274 assert "no_version_annotation" in reasons
275 assert "stale_impl" not in reasons
276
277 def test_stale_penalty(self) -> None:
278 score, reasons = _compute_health(None, [], None, _stale(True))
279 assert score == pytest.approx(0.0)
280 assert "stale_impl" in reasons
281
282 def test_full_score(self) -> None:
283 long_doc = "A" * 50
284 score, reasons = _compute_health(long_doc, ["test1"], "v1.0", _stale(False))
285 assert score == pytest.approx(1.0)
286 assert reasons == []
287
288 def test_short_docstring_penalty(self) -> None:
289 short_doc = "Short." # < 40 chars
290 score, reasons = _compute_health(short_doc, ["t1"], "v1.0", _stale(False))
291 # has doc: 0.30, short: no +0.20, has test: 0.20, has version: 0.15, not stale: 0.15
292 assert score == pytest.approx(0.80)
293 assert "docstring_too_short" in reasons
294
295 def test_capped_at_one(self) -> None:
296 long_doc = "A" * 100
297 score, _ = _compute_health(long_doc, ["t1", "t2"], "v1.0", _stale(False))
298 assert score <= 1.0
299
300 def test_no_tests(self) -> None:
301 long_doc = "A" * 50
302 score, reasons = _compute_health(long_doc, [], "v1.0", _stale(False))
303 # 0.30 + 0.20 (long) + 0 (no tests) + 0.15 (version) + 0.15 (not stale) = 0.80
304 assert score == pytest.approx(0.80)
305 assert "no_tests" in reasons
306
307
308 # ---------------------------------------------------------------------------
309 # Tests: build_symbol_test_map
310 # ---------------------------------------------------------------------------
311
312
313 class TestBuildSymbolTestMap:
314 def test_empty_symbols(self) -> None:
315 result = build_symbol_test_map({}, {})
316 assert result == {}
317
318 def test_test_not_linked_to_non_test(self) -> None:
319 """Test functions should not appear as callers of themselves."""
320 sym: SymbolRecord = _make_sym("test_foo", kind="function")
321 all_syms = {"tests/test_a.py::test_foo": sym}
322 fg: ForwardGraph = {"tests/test_a.py::test_foo": frozenset({"bar"})}
323 result = build_symbol_test_map(fg, all_syms)
324 # "bar" is in callees but has no SymbolRecord — map should be empty.
325 assert result == {}
326
327 def test_single_test_links_to_production(self) -> None:
328 test_sym: SymbolRecord = _make_sym("test_foo", kind="function")
329 prod_sym: SymbolRecord = _make_sym("bar", kind="function")
330 all_syms = {
331 "tests/test_a.py::test_foo": test_sym,
332 "muse/core/a.py::bar": prod_sym,
333 }
334 fg: ForwardGraph = {
335 "tests/test_a.py::test_foo": frozenset({"bar"}),
336 "muse/core/a.py::bar": frozenset(),
337 }
338 result = build_symbol_test_map(fg, all_syms)
339 assert "muse/core/a.py::bar" in result
340 assert "tests/test_a.py::test_foo" in result["muse/core/a.py::bar"]
341
342 def test_depth_limit_respected(self) -> None:
343 """BFS stops at max_depth hops."""
344 all_syms = {
345 "tests/t.py::test_x": _make_sym("test_x", kind="function"),
346 "a.py::a": _make_sym("a", kind="function"),
347 "b.py::b": _make_sym("b", kind="function"),
348 "c.py::c": _make_sym("c", kind="function"),
349 "d.py::d": _make_sym("d", kind="function"),
350 }
351 fg: ForwardGraph = {
352 "tests/t.py::test_x": frozenset({"a"}),
353 "a.py::a": frozenset({"b"}),
354 "b.py::b": frozenset({"c"}),
355 "c.py::c": frozenset({"d"}),
356 }
357 # max_depth=2 → test_x → a (depth 1) → b (depth 2), stop.
358 result = build_symbol_test_map(fg, all_syms, max_depth=2)
359 assert "a.py::a" in result
360 assert "b.py::b" in result
361 assert "c.py::c" not in result
362 assert "d.py::d" not in result
363
364 def test_no_infinite_loop(self) -> None:
365 """Cyclic call graph does not cause infinite loop."""
366 all_syms = {
367 "tests/t.py::test_cycle": _make_sym("test_cycle", kind="function"),
368 "a.py::alpha": _make_sym("alpha", kind="function"),
369 "b.py::beta": _make_sym("beta", kind="function"),
370 }
371 fg: ForwardGraph = {
372 "tests/t.py::test_cycle": frozenset({"alpha"}),
373 "a.py::alpha": frozenset({"beta"}),
374 "b.py::beta": frozenset({"alpha"}), # cycle
375 }
376 result = build_symbol_test_map(fg, all_syms)
377 # Should complete without recursion limit.
378 assert isinstance(result, dict)
379
380
381 # ---------------------------------------------------------------------------
382 # Tests: _is_public
383 # ---------------------------------------------------------------------------
384
385
386 class TestIsPublic:
387 def test_public_name(self) -> None:
388 assert _is_public("my_function") is True
389
390 def test_private_name(self) -> None:
391 assert _is_public("_private") is False
392
393 def test_dunder(self) -> None:
394 assert _is_public("__init__") is False
395
396 def test_empty_string(self) -> None:
397 assert _is_public("") is True
398
399
400 # ---------------------------------------------------------------------------
401 # Tests: extract_docs integration
402 # ---------------------------------------------------------------------------
403
404
405 class TestExtractDocs:
406 def test_empty_repo_no_commit(self, tmp_path: pathlib.Path) -> None:
407 """When there's no HEAD commit, returns an empty report."""
408 root = _make_repo(tmp_path)
409 report = extract_docs(root, "test-repo-123")
410 assert report["commit_id"] == ""
411 assert report["symbols"] == []
412 assert report["summary"]["total_symbols"] == 0
413
414 def test_repo_with_python_file(self, tmp_path: pathlib.Path) -> None:
415 """A repo with one documented Python file produces at least one SymbolDoc."""
416 root = _make_repo(tmp_path)
417
418 src = (
419 b"def documented_fn(x: int) -> str:\n"
420 b' """Return x as a string."""\n'
421 b" return str(x)\n"
422 )
423 content_hash = blob_id(src)
424 write_object(root, content_hash, src)
425 (tmp_path / "documented.py").write_bytes(src)
426
427 manifest = {"documented.py": content_hash}
428 _write_commit_with_snapshot(root, manifest)
429
430 report = extract_docs(root, "test-repo-123")
431 assert report["summary"]["total_symbols"] >= 1
432
433 addrs = [d["address"] for d in report["symbols"]]
434 assert any("documented_fn" in a for a in addrs)
435
436 def test_missing_list_populated(self, tmp_path: pathlib.Path) -> None:
437 """Public functions without docstrings appear in 'missing'."""
438 root = _make_repo(tmp_path)
439
440 src = b"def undocumented() -> None:\n pass\n"
441 h = blob_id(src)
442 write_object(root, h, src)
443 (tmp_path / "nodoc.py").write_bytes(src)
444
445 manifest = {"nodoc.py": h}
446 _write_commit_with_snapshot(root, manifest)
447
448 report = extract_docs(root, "test-repo-123")
449 missing_addrs = [m["address"] for m in report["missing"]]
450 assert any("undocumented" in a for a in missing_addrs)
451
452 def test_targets_filter(self, tmp_path: pathlib.Path) -> None:
453 """When targets is set, only those symbols appear in the report."""
454 root = _make_repo(tmp_path)
455
456 src = (
457 b"def alpha() -> None:\n pass\n"
458 b"def beta() -> None:\n pass\n"
459 )
460 h = blob_id(src)
461 write_object(root, h, src)
462 (tmp_path / "ab.py").write_bytes(src)
463
464 manifest = {"ab.py": h}
465 _write_commit_with_snapshot(root, manifest)
466
467 # Find the alpha address.
468 full_report = extract_docs(root, "test-repo-123")
469 alpha_addrs = [
470 d["address"] for d in full_report["symbols"] if "alpha" in d["address"]
471 ]
472 if not alpha_addrs:
473 pytest.skip("alpha not found in snapshot — symbol cache not populated")
474
475 targeted = extract_docs(root, "test-repo-123", targets=[alpha_addrs[0]])
476 addrs = [d["address"] for d in targeted["symbols"]]
477 assert any("alpha" in a for a in addrs)
478 assert not any("beta" in a for a in addrs)
479
480 def test_summary_aggregation(self, tmp_path: pathlib.Path) -> None:
481 """DocSummary counts are consistent with the symbols list."""
482 root = _make_repo(tmp_path)
483
484 src = (
485 b"def with_doc():\n"
486 b' """Has doc."""\n'
487 b" pass\n"
488 b"def without_doc():\n"
489 b" pass\n"
490 )
491 h = blob_id(src)
492 write_object(root, h, src)
493 (tmp_path / "mixed.py").write_bytes(src)
494
495 manifest = {"mixed.py": h}
496 _write_commit_with_snapshot(root, manifest)
497
498 report = extract_docs(root, "test-repo-123")
499 s = report["summary"]
500 assert s["total_symbols"] == len(report["symbols"])
501 assert s["documented"] + s["undocumented"] <= s["total_symbols"]
502 assert 0.0 <= s["avg_health"] <= 1.0
503 assert 0.0 <= s["doc_debt_score"] <= 1.0
504
505 def test_at_commit_param(self, tmp_path: pathlib.Path) -> None:
506 """Passing commit_id uses that commit rather than HEAD."""
507 root = _make_repo(tmp_path)
508
509 src = b"def fn():\n pass\n"
510 h = blob_id(src)
511 write_object(root, h, src)
512 (tmp_path / "fn.py").write_bytes(src)
513
514 manifest = {"fn.py": h}
515 cid = _write_commit_with_snapshot(root, manifest)
516
517 report = extract_docs(root, "test-repo-123", commit_id=cid)
518 assert report["commit_id"] == cid
519
520 def test_invalid_commit_returns_empty(self, tmp_path: pathlib.Path) -> None:
521 """An unknown commit_id returns an empty report, not an error."""
522 root = _make_repo(tmp_path)
523 report = extract_docs(root, "test-repo-123", commit_id="sha256:" + "0" * 64)
524 assert report["symbols"] == []
525
526
527 # ---------------------------------------------------------------------------
528 # Stress tests
529 # ---------------------------------------------------------------------------
530
531
532 class TestExtractDocsStress:
533 def test_many_symbols(self, tmp_path: pathlib.Path) -> None:
534 """extract_docs handles a file with 100 functions without crashing."""
535 root = _make_repo(tmp_path)
536
537 lines: list[str] = []
538 for i in range(100):
539 lines.append(f'def fn_{i}(x: int) -> int:')
540 lines.append(f' """Function {i} — does something useful."""')
541 lines.append(f' return x + {i}')
542 lines.append("")
543 src = "\n".join(lines).encode()
544 h = blob_id(src)
545 write_object(root, h, src)
546 (tmp_path / "big.py").write_bytes(src)
547
548 manifest = {"big.py": h}
549 _write_commit_with_snapshot(root, manifest)
550
551 report = extract_docs(root, "test-repo-123")
552 assert report["summary"]["total_symbols"] >= 50 # at least most parsed
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago