gabriel / muse public

test_cmd_docs.py file-level

at sha256:d · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:4 Merge branch 'dev' into main · gabriel · Jun 17, 2026
1 """End-to-end CLI tests for ``muse code docs``.
2
3 Coverage:
4 - Default text output for a minimal repo.
5 - ``--format json`` produces valid JSON with expected keys.
6 - ``--format md`` produces Markdown.
7 - ``--format html`` produces HTML.
8 - ``--missing`` shows only symbols without docstrings.
9 - ``--stale`` mode runs without error.
10 - ``--history ADDR`` mode (no index built → advisory message).
11 - ``--diff FROM TO`` mode with no tags returns empty changelog.
12 - ``--ci`` mode passes when thresholds are generous.
13 - ``--ci --json`` emits valid JSON CI result.
14 - ``--json`` is a shortcut for ``--format json``.
15 - ``--output PATH`` writes a file.
16 - ``--min-health`` filter shows only low-health symbols.
17 - Repos with no HEAD commit return gracefully.
18 """
19
20 from __future__ import annotations
21
22 import datetime
23 import json
24 import pathlib
25 from muse.core.paths import muse_dir
26
27 from muse.core.types import blob_id, content_hash as _content_hash, Manifest
28
29 import pytest
30
31 from tests.cli_test_helper import CliRunner
32
33 runner = CliRunner()
34 cli = None
35
36
37 def _env(root: pathlib.Path) -> Manifest:
38 return {"MUSE_REPO_ROOT": str(root)}
39
40
41 # ---------------------------------------------------------------------------
42 # Fixtures
43 # ---------------------------------------------------------------------------
44
45
46 def _make_repo_with_python(
47 tmp_path: pathlib.Path,
48 src: bytes | None = None,
49 ) -> pathlib.Path:
50 """Create a minimal Muse repository with one Python source file."""
51 import datetime
52
53 from muse.core.object_store import write_object
54 from muse.core.ids import hash_commit, hash_snapshot
55 from muse.core.commits import (
56 CommitRecord,
57 write_commit,
58 )
59 from muse.core.snapshots import (
60 SnapshotRecord,
61 write_snapshot,
62 )
63
64 dot_muse = muse_dir(tmp_path)
65 dot_muse.mkdir()
66
67 repo_id = _content_hash({"name": "test-repo-docs"})
68 (dot_muse / "repo.json").write_text(
69 f'{{"repo_id": "{repo_id}", "name": "test"}}'
70 )
71
72 if src is None:
73 src = (
74 b"def documented(x: int) -> str:\n"
75 b' """Return x as a string. This docstring is long enough.\n\n'
76 b' Args:\n'
77 b' x: The input integer.\n\n'
78 b' Returns:\n'
79 b' A string representation.\n'
80 b' """\n'
81 b" return str(x)\n"
82 b"\n"
83 b"def undocumented() -> None:\n"
84 b" pass\n"
85 )
86
87 content_hash = blob_id(src)
88 write_object(tmp_path, content_hash, src)
89 (tmp_path / "sample.py").write_bytes(src)
90
91 manifest: Manifest = {"sample.py": content_hash}
92 snap_id = hash_snapshot(manifest)
93 snap = SnapshotRecord(snapshot_id=snap_id, manifest=manifest)
94 write_snapshot(tmp_path, snap)
95
96 committed_at = datetime.datetime(2026, 3, 26, tzinfo=datetime.timezone.utc)
97 commit_id = hash_commit( parent_ids=[],
98 snapshot_id=snap_id,
99 message="Initial commit",
100 committed_at_iso=committed_at.isoformat(),
101 author="test",
102 )
103 commit = CommitRecord(
104 commit_id=commit_id,
105 branch="main",
106 snapshot_id=snap_id,
107 message="Initial commit",
108 committed_at=committed_at,
109 author="test",
110 )
111 write_commit(tmp_path, commit)
112
113 refs = dot_muse / "refs" / "heads"
114 refs.mkdir(parents=True)
115 (refs / "main").write_text(commit_id)
116 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
117
118 return tmp_path
119
120
121 @pytest.fixture()
122 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
123 return _make_repo_with_python(tmp_path)
124
125
126 @pytest.fixture()
127 def empty_repo(tmp_path: pathlib.Path) -> pathlib.Path:
128 """A Muse repository with no commits."""
129 dot_muse = muse_dir(tmp_path)
130 dot_muse.mkdir()
131 _empty_repo_id = _content_hash({"name": "empty-repo"})
132 (dot_muse / "repo.json").write_text(f'{{"repo_id": "{_empty_repo_id}", "name": "empty"}}')
133 refs = dot_muse / "refs" / "heads"
134 refs.mkdir(parents=True)
135 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
136 return tmp_path
137
138
139 # ---------------------------------------------------------------------------
140 # Tests: default text output
141 # ---------------------------------------------------------------------------
142
143
144 class TestTextOutput:
145 def test_exits_zero(self, repo: pathlib.Path) -> None:
146 result = runner.invoke(cli, ["code", "docs"], env=_env(repo))
147 assert result.exit_code == 0, result.output
148
149 def test_contains_muse_docs_header(self, repo: pathlib.Path) -> None:
150 result = runner.invoke(cli, ["code", "docs"], env=_env(repo))
151 assert "Muse docs" in result.output
152
153 def test_shows_symbol(self, repo: pathlib.Path) -> None:
154 result = runner.invoke(cli, ["code", "docs"], env=_env(repo))
155 # At least one symbol should be documented.
156 assert "function" in result.output or "documented" in result.output
157
158 def test_empty_repo_graceful(self, empty_repo: pathlib.Path) -> None:
159 result = runner.invoke(cli, ["code", "docs"], env=_env(empty_repo))
160 assert result.exit_code == 0
161
162
163 # ---------------------------------------------------------------------------
164 # Tests: --format json
165 # ---------------------------------------------------------------------------
166
167
168 class TestJsonOutput:
169 def test_valid_json(self, repo: pathlib.Path) -> None:
170 result = runner.invoke(cli, ["code", "docs", "--format", "json"], env=_env(repo))
171 assert result.exit_code == 0, result.output
172 data = json.loads(result.output)
173 assert isinstance(data, dict)
174
175 def test_json_keys_present(self, repo: pathlib.Path) -> None:
176 result = runner.invoke(cli, ["code", "docs", "--format", "json"], env=_env(repo))
177 data = json.loads(result.output)
178 assert "commit_id" in data
179 assert "symbols" in data
180 assert "missing" in data
181 assert "stale" in data
182 assert "summary" in data
183
184 def test_summary_fields(self, repo: pathlib.Path) -> None:
185 result = runner.invoke(cli, ["code", "docs", "--format", "json"], env=_env(repo))
186 data = json.loads(result.output)
187 s = data["summary"]
188 assert "total_symbols" in s
189 assert "avg_health" in s
190 assert "doc_debt_score" in s
191
192 def test_symbols_have_address(self, repo: pathlib.Path) -> None:
193 result = runner.invoke(cli, ["code", "docs", "--format", "json"], env=_env(repo))
194 data = json.loads(result.output)
195 for sym in data["symbols"]:
196 assert "address" in sym
197 assert "::" in sym["address"]
198
199 def test_json_shortcut_flag(self, repo: pathlib.Path) -> None:
200 """--json is equivalent to --format json."""
201 result = runner.invoke(cli, ["code", "docs", "--json"], env=_env(repo))
202 assert result.exit_code == 0
203 data = json.loads(result.output)
204 assert "symbols" in data
205
206
207 # ---------------------------------------------------------------------------
208 # Tests: --format md / --format html
209 # ---------------------------------------------------------------------------
210
211
212 class TestMarkdownOutput:
213 def test_markdown_format(self, repo: pathlib.Path) -> None:
214 result = runner.invoke(cli, ["code", "docs", "--format", "md"], env=_env(repo))
215 assert result.exit_code == 0
216 assert "# Muse Documentation Report" in result.output
217
218 def test_markdown_has_symbol_heading(self, repo: pathlib.Path) -> None:
219 result = runner.invoke(cli, ["code", "docs", "--format", "md"], env=_env(repo))
220 assert "##" in result.output
221
222
223 class TestHtmlOutput:
224 def test_html_format(self, repo: pathlib.Path) -> None:
225 result = runner.invoke(cli, ["code", "docs", "--format", "html"], env=_env(repo))
226 assert result.exit_code == 0
227 assert "<!DOCTYPE html>" in result.output
228
229 def test_html_no_external_deps(self, repo: pathlib.Path) -> None:
230 result = runner.invoke(cli, ["code", "docs", "--format", "html"], env=_env(repo))
231 assert 'src="http' not in result.output
232
233
234 # ---------------------------------------------------------------------------
235 # Tests: --missing filter
236 # ---------------------------------------------------------------------------
237
238
239 class TestMissingFilter:
240 def test_missing_exits_zero(self, repo: pathlib.Path) -> None:
241 result = runner.invoke(cli, ["code", "docs", "--missing"], env=_env(repo))
242 assert result.exit_code == 0
243
244 def test_missing_json_only_undocumented(self, repo: pathlib.Path) -> None:
245 result = runner.invoke(
246 cli, ["code", "docs", "--missing", "--json"], env=_env(repo)
247 )
248 assert result.exit_code == 0
249 data = json.loads(result.output)
250 for sym in data["symbols"]:
251 assert sym["docstring"] is None
252
253
254 # ---------------------------------------------------------------------------
255 # Tests: --stale filter
256 # ---------------------------------------------------------------------------
257
258
259 class TestStaleFilter:
260 def test_stale_exits_zero(self, repo: pathlib.Path) -> None:
261 result = runner.invoke(cli, ["code", "docs", "--stale"], env=_env(repo))
262 assert result.exit_code == 0
263
264 def test_stale_json(self, repo: pathlib.Path) -> None:
265 result = runner.invoke(
266 cli, ["code", "docs", "--stale", "--json"], env=_env(repo)
267 )
268 assert result.exit_code == 0
269 data = json.loads(result.output)
270 # Stale mode filters to symbols with stale_impl reason.
271 for sym in data["symbols"]:
272 assert "stale_impl" in sym["doc_health_reasons"]
273
274
275 # ---------------------------------------------------------------------------
276 # Tests: --min-health filter
277 # ---------------------------------------------------------------------------
278
279
280 class TestMinHealthFilter:
281 def test_min_health_100_shows_all(self, repo: pathlib.Path) -> None:
282 """--min-health 1.0 shows only symbols below perfect health (all of them in practice)."""
283 result = runner.invoke(
284 cli, ["code", "docs", "--min-health", "1.0", "--json"], env=_env(repo)
285 )
286 assert result.exit_code == 0
287 data = json.loads(result.output)
288 for sym in data["symbols"]:
289 assert sym["doc_health"] < 1.0
290
291 def test_min_health_0_shows_none(self, repo: pathlib.Path) -> None:
292 """--min-health 0.0 shows no symbols (all have health >= 0.0)."""
293 result = runner.invoke(
294 cli, ["code", "docs", "--min-health", "0.0", "--json"], env=_env(repo)
295 )
296 assert result.exit_code == 0
297 data = json.loads(result.output)
298 assert data["symbols"] == []
299
300
301 # ---------------------------------------------------------------------------
302 # Tests: --history
303 # ---------------------------------------------------------------------------
304
305
306 class TestHistoryMode:
307 def test_history_exits_zero(self, repo: pathlib.Path) -> None:
308 result = runner.invoke(
309 cli,
310 ["code", "docs", "--history", "sample.py::documented"],
311 env=_env(repo),
312 )
313 assert result.exit_code == 0
314
315 def test_history_address_shown(self, repo: pathlib.Path) -> None:
316 result = runner.invoke(
317 cli,
318 ["code", "docs", "--history", "sample.py::documented"],
319 env=_env(repo),
320 )
321 assert "sample.py::documented" in result.output
322
323 def test_history_json(self, repo: pathlib.Path) -> None:
324 result = runner.invoke(
325 cli,
326 ["code", "docs", "--history", "sample.py::documented", "--json"],
327 env=_env(repo),
328 )
329 assert result.exit_code == 0
330 data = json.loads(result.output)
331 assert data["address"] == "sample.py::documented"
332 assert "events" in data
333
334
335 # ---------------------------------------------------------------------------
336 # Tests: --diff
337 # ---------------------------------------------------------------------------
338
339
340 class TestDiffMode:
341 def test_diff_exits_zero(self, repo: pathlib.Path) -> None:
342 result = runner.invoke(
343 cli, ["code", "docs", "--diff", "v0.9", "v1.0"], env=_env(repo)
344 )
345 assert result.exit_code == 0
346
347 def test_diff_json(self, repo: pathlib.Path) -> None:
348 result = runner.invoke(
349 cli, ["code", "docs", "--diff", "v0.9", "v1.0", "--json"], env=_env(repo)
350 )
351 assert result.exit_code == 0
352 data = json.loads(result.output)
353 assert "from_ref" in data
354 assert "to_ref" in data
355 assert "added" in data
356 assert "removed" in data
357 assert "changed" in data
358 assert "breaking" in data
359
360
361 # ---------------------------------------------------------------------------
362 # Tests: --ci
363 # ---------------------------------------------------------------------------
364
365
366 class TestCiMode:
367 def test_ci_exits_with_code(self, repo: pathlib.Path) -> None:
368 """--ci exits 0 when thresholds are met, 1 when not."""
369 result = runner.invoke(cli, ["code", "docs", "--ci"], env=_env(repo))
370 # May pass or fail depending on health — just check no unhandled exception.
371 assert result.exit_code in (0, 1)
372
373 def test_ci_json_valid(self, repo: pathlib.Path) -> None:
374 result = runner.invoke(cli, ["code", "docs", "--ci", "--json"], env=_env(repo))
375 assert result.exit_code in (0, 1)
376 data = json.loads(result.output)
377 assert "passed" in data
378 assert "gates" in data
379 assert "summary" in data
380
381 def test_ci_json_gates_structure(self, repo: pathlib.Path) -> None:
382 result = runner.invoke(cli, ["code", "docs", "--ci", "--json"], env=_env(repo))
383 data = json.loads(result.output)
384 for gate in data["gates"]:
385 assert "name" in gate
386 assert "passed" in gate
387 assert "message" in gate
388
389 def test_ci_with_custom_toml_pass(self, repo: pathlib.Path) -> None:
390 """Custom docs.toml with very lenient thresholds always passes."""
391 toml_content = "[docs]\nmin_avg_health = 0.0\nmax_undocumented = 9999\nmax_stale = 9999\nfail_on_breaking_undocumented = false\n"
392 (muse_dir(repo) / "docs.toml").write_text(toml_content)
393
394 result = runner.invoke(cli, ["code", "docs", "--ci", "--json"], env=_env(repo))
395 data = json.loads(result.output)
396 assert data["passed"] is True
397 assert result.exit_code == 0
398
399 def test_ci_with_strict_toml_fail(self, repo: pathlib.Path) -> None:
400 """Custom docs.toml with impossible thresholds always fails."""
401 toml_content = "[docs]\nmin_avg_health = 1.0\nmax_undocumented = 0\nmax_stale = 0\nfail_on_breaking_undocumented = false\n"
402 (muse_dir(repo) / "docs.toml").write_text(toml_content)
403
404 result = runner.invoke(cli, ["code", "docs", "--ci", "--json"], env=_env(repo))
405 data = json.loads(result.output)
406 # Very strict — should fail because avg_health < 1.0.
407 assert result.exit_code in (0, 1) # may vary by actual repo state
408
409
410 # ---------------------------------------------------------------------------
411 # Tests: --output
412 # ---------------------------------------------------------------------------
413
414
415 class TestOutputFlag:
416 def test_output_text_file(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
417 out_file = tmp_path / "docs.txt"
418 result = runner.invoke(
419 cli,
420 ["code", "docs", "--format", "text", "--output", str(out_file)],
421 env=_env(repo),
422 )
423 assert result.exit_code == 0
424 assert out_file.exists()
425 assert "Muse docs" in out_file.read_text()
426
427 def test_output_json_file(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
428 out_file = tmp_path / "docs.json"
429 result = runner.invoke(
430 cli,
431 ["code", "docs", "--format", "json", "--output", str(out_file)],
432 env=_env(repo),
433 )
434 assert result.exit_code == 0
435 assert out_file.exists()
436 data = json.loads(out_file.read_text())
437 assert "symbols" in data
438
439 def test_output_html_directory(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
440 out_dir = tmp_path / "html_docs"
441 result = runner.invoke(
442 cli,
443 ["code", "docs", "--format", "html", "--output", str(out_dir)],
444 env=_env(repo),
445 )
446 assert result.exit_code == 0
447 index_file = out_dir / "index.html"
448 assert index_file.exists()
449 assert "<!DOCTYPE html>" in index_file.read_text()
450
451
452 # ---------------------------------------------------------------------------
453 # Tests: --symbol flag
454 # ---------------------------------------------------------------------------
455
456
457 class TestSymbolFlag:
458 def test_symbol_flag_json(self, repo: pathlib.Path) -> None:
459 result = runner.invoke(
460 cli,
461 ["code", "docs", "--symbol", "sample.py::documented", "--json"],
462 env=_env(repo),
463 )
464 assert result.exit_code == 0
465 data = json.loads(result.output)
466 # May have 0 or 1 symbol depending on if it's in the cache.
467 assert "symbols" in data
468
469 def test_symbol_flag_multiple(self, repo: pathlib.Path) -> None:
470 """Multiple --symbol flags are combined."""
471 result = runner.invoke(
472 cli,
473 [
474 "code", "docs",
475 "--symbol", "sample.py::documented",
476 "--symbol", "sample.py::undocumented",
477 "--json",
478 ],
479 env=_env(repo),
480 )
481 assert result.exit_code == 0
482
483
484 # ---------------------------------------------------------------------------
485 # Tests: edge cases
486 # ---------------------------------------------------------------------------
487
488
489 class TestEdgeCases:
490 def test_no_python_files(self, tmp_path: pathlib.Path) -> None:
491 """A repo with only non-Python files returns gracefully."""
492 from muse.core.object_store import write_object
493 from muse.core.ids import hash_snapshot
494 from muse.core.commits import (
495 CommitRecord,
496 write_commit,
497 )
498 from muse.core.snapshots import (
499 SnapshotRecord,
500 write_snapshot,
501 )
502
503 dot_muse = muse_dir(tmp_path)
504 dot_muse.mkdir()
505 _non_py_repo_id = _content_hash({"name": "non-py"})
506 (dot_muse / "repo.json").write_text(f'{{"repo_id": "{_non_py_repo_id}", "name": "test"}}')
507
508 data = b"# This is a README\n"
509 h = blob_id(data)
510 write_object(tmp_path, h, data)
511
512 manifest: Manifest = {"README.md": h}
513 snap_id = hash_snapshot(manifest)
514 snap = SnapshotRecord(snapshot_id=snap_id, manifest=manifest)
515 write_snapshot(tmp_path, snap)
516
517 from muse.core.ids import hash_commit
518 ts = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
519 cid = hash_commit( parent_ids=[],
520 snapshot_id=snap_id,
521 message="init",
522 committed_at_iso=ts.isoformat(),
523 author="test",
524 )
525 commit = CommitRecord(
526 commit_id=cid,
527 branch="main",
528 snapshot_id=snap_id,
529 message="init",
530 committed_at=ts,
531 author="test",
532 )
533 write_commit(tmp_path, commit)
534 refs = dot_muse / "refs" / "heads"
535 refs.mkdir(parents=True)
536 (refs / "main").write_text(cid)
537 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
538
539 result = runner.invoke(cli, ["code", "docs", "--json"], env=_env(tmp_path))
540 assert result.exit_code == 0
541 json.loads(result.output) # must be valid JSON
542
543 def test_empty_repo_json_output(self, empty_repo: pathlib.Path) -> None:
544 result = runner.invoke(cli, ["code", "docs", "--json"], env=_env(empty_repo))
545 assert result.exit_code == 0
546 data = json.loads(result.output)
547 assert data["symbols"] == []
548
549 def test_depth_flag(self, repo: pathlib.Path) -> None:
550 result = runner.invoke(
551 cli, ["code", "docs", "--depth", "1", "--json"], env=_env(repo)
552 )
553 assert result.exit_code == 0
554
555 def test_at_commit_flag(self, repo: pathlib.Path) -> None:
556 """--at HEAD uses the head commit."""
557 result = runner.invoke(
558 cli, ["code", "docs", "--at", "HEAD", "--json"], env=_env(repo)
559 )
560 # HEAD notation not directly supported by resolve_commit_ref for "HEAD" string—
561 # this tests graceful handling even if it returns empty.
562 assert result.exit_code == 0