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