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