gabriel / muse public
docs_cmd.py python
695 lines 21.6 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """muse code docs — symbol-aware, version-annotated documentation for any codebase.
2
3 Traditional documentation tools parse the current file. They extract your
4 docstring. They know nothing else.
5
6 ``muse code docs`` knows everything:
7
8 * **Who calls this symbol** and **what it calls** — via the committed call
9 graph. Every doc page includes live caller and callee lists, not static
10 hand-written cross-references.
11
12 * **When it was introduced** — ``since v1.2.0`` inferred from the symbol
13 history index and tag store, without manual ``.. versionadded::``
14 annotations.
15
16 * **Whether the docstring is stale** — if the signature or implementation
17 changed since the last body edit, Muse flags it. No guessing.
18
19 * **Which tests cover it** — linked directly from the call-graph BFS. Every
20 function's doc page lists the tests that exercise it.
21
22 * **A quantitative health score** — per symbol and repo-wide, weighted by
23 caller count so highly-used undocumented symbols contribute more debt.
24
25 * **Machine-readable JSON** — the ``--format json`` output is structured for
26 LLM ingestion, RAG pipelines, and agent-driven doc generation.
27
28 Usage::
29
30 # Document the whole repository (text output)
31 muse code docs
32
33 # Document a single file
34 muse code docs muse/core/store.py
35
36 # Document a single symbol
37 muse code docs "muse/core/store.py::read_commit"
38
39 # Find all public symbols missing a docstring
40 muse code docs --missing
41
42 # Find all potentially stale docstrings
43 muse code docs --stale
44
45 # HTML output written to docs/ directory
46 muse code docs --format html -o docs/
47
48 # Markdown to stdout
49 muse code docs --format md
50
51 # Machine-readable JSON
52 muse code docs --format json
53
54 # Changelog between two tags/commits
55 muse code docs --diff v1.0 v2.0
56
57 # Version history for one symbol
58 muse code docs --history "muse/core/store.py::read_commit"
59
60 # Document a specific historical commit
61 muse code docs --at HEAD~5
62
63 # Doc quality CI gate (.muse/docs.toml)
64 muse code docs --ci
65
66 # Only symbols below a health threshold
67 muse code docs --min-health 0.6
68
69 Flags
70 -----
71
72 ``TARGETS``
73 Optional symbol addresses (``"file.py::Symbol"``) or file paths. When
74 omitted, the entire committed snapshot is documented.
75
76 ``--format json|html|md|text``
77 Output format. Default: ``text``.
78
79 ``--output PATH, -o PATH``
80 Write output to *PATH*. For ``--format html``, *PATH* is treated as a
81 directory (created if absent) and ``index.html`` is written inside it.
82 For other formats, *PATH* is the output file.
83
84 ``--missing``
85 Show only public symbols that lack a docstring.
86
87 ``--stale``
88 Show only symbols with potentially stale documentation.
89
90 ``--min-health SCORE``
91 Show only symbols whose health score is below *SCORE* (0.0–1.0).
92
93 ``--symbol ADDR, -s ADDR``
94 Document a specific symbol. Equivalent to passing *ADDR* as a positional
95 argument. May be repeated.
96
97 ``--depth N, -d N``
98 Call-graph BFS depth for test-linkage resolution (default 3).
99
100 ``--diff FROM TO``
101 Generate a changelog between two refs (commit IDs or tag names).
102
103 ``--history ADDR``
104 Show the full version history timeline for one symbol address.
105
106 ``--at COMMIT``
107 Document the repository as of *COMMIT* (HEAD notation, SHA prefix, or
108 tag name).
109
110 ``--ci``
111 Run the documentation quality gate from ``.muse/docs.toml`` and exit
112 with code 1 when the thresholds are not met.
113
114 ``--json``
115 Shortcut for ``--format json``.
116 """
117
118 from __future__ import annotations
119
120 import argparse
121 import json
122 import logging
123 import pathlib
124 import sys
125 import tomllib
126 from typing import NotRequired, TypedDict
127
128 from muse.core.doc_extractor import (
129 DocReport,
130 DocSummary,
131 MissingDocEntry,
132 StaleDocEntry,
133 SymbolDoc,
134 extract_docs,
135 )
136 from muse.core.doc_history import (
137 ChangelogEntry,
138 ChangelogReport,
139 SymbolVersionEvent,
140 generate_changelog,
141 get_symbol_version_events,
142 )
143 from muse.core.doc_renderer import RenderFormat, render
144 from muse.core.repo import read_repo_id, require_repo
145 from muse.core.store import MsgpackValue, _int_val, _str_val
146 from muse.core.validation import sanitize_display, validate_output_path
147
148 logger = logging.getLogger(__name__)
149
150
151 # ---------------------------------------------------------------------------
152 # CI gate types and helpers
153 # ---------------------------------------------------------------------------
154
155
156 class DocCiConfig(TypedDict):
157 """Configuration for the documentation quality CI gate."""
158
159 min_avg_health: float
160 """Fail when average health across all public symbols falls below this."""
161
162 max_undocumented: int
163 """Fail when more than this many public symbols lack a docstring."""
164
165 max_stale: int
166 """Fail when more than this many symbols have a stale docstring."""
167
168 fail_on_breaking_undocumented: bool
169 """Fail when any symbol with breaking changes has no docstring."""
170
171
172 _DEFAULT_DOC_CI_CONFIG = DocCiConfig(
173 min_avg_health=0.5,
174 max_undocumented=50,
175 max_stale=20,
176 fail_on_breaking_undocumented=False,
177 )
178
179 _DOC_CI_TOML = ".muse/docs.toml"
180
181
182 def _load_doc_ci_config(root: pathlib.Path) -> DocCiConfig:
183 """Load documentation CI config from ``.muse/docs.toml``, falling back to defaults."""
184 path = root / _DOC_CI_TOML
185 if not path.exists():
186 return _DEFAULT_DOC_CI_CONFIG
187 try:
188 raw = tomllib.loads(path.read_text(encoding="utf-8"))
189 docs_section = raw.get("docs", {})
190 if not isinstance(docs_section, dict):
191 return _DEFAULT_DOC_CI_CONFIG
192
193 def _fval(key: str, default: float) -> float:
194 v = docs_section.get(key, default)
195 return float(v) if isinstance(v, (int, float)) else default
196
197 def _ival(key: str, default: int) -> int:
198 v = docs_section.get(key, default)
199 return int(v) if isinstance(v, (int, float)) else default
200
201 def _bval(key: str, default: bool) -> bool:
202 v = docs_section.get(key, default)
203 return bool(v) if isinstance(v, bool) else default
204
205 return DocCiConfig(
206 min_avg_health=_fval("min_avg_health", _DEFAULT_DOC_CI_CONFIG["min_avg_health"]),
207 max_undocumented=_ival("max_undocumented", _DEFAULT_DOC_CI_CONFIG["max_undocumented"]),
208 max_stale=_ival("max_stale", _DEFAULT_DOC_CI_CONFIG["max_stale"]),
209 fail_on_breaking_undocumented=_bval(
210 "fail_on_breaking_undocumented",
211 _DEFAULT_DOC_CI_CONFIG["fail_on_breaking_undocumented"],
212 ),
213 )
214 except Exception as exc:
215 logger.warning("⚠️ Could not parse %s: %s — using defaults", _DOC_CI_TOML, exc)
216 return _DEFAULT_DOC_CI_CONFIG
217
218
219 class DocCiGateResult(TypedDict):
220 """Result of a single documentation CI gate check."""
221
222 name: str
223 passed: bool
224 message: str
225
226
227 class DocCiResult(TypedDict):
228 """Overall result of the documentation CI gate."""
229
230 passed: bool
231 gates: list[DocCiGateResult]
232 summary: DocSummary
233
234
235 def _run_doc_ci(report: DocReport, config: DocCiConfig) -> DocCiResult:
236 """Evaluate documentation quality gates against *report*.
237
238 Args:
239 report: The :class:`DocReport` to evaluate.
240 config: CI gate thresholds from ``.muse/docs.toml``.
241 """
242 gates: list[DocCiGateResult] = []
243 s = report["summary"]
244
245 avg_ok = s["avg_health"] >= config["min_avg_health"]
246 gates.append(
247 DocCiGateResult(
248 name="avg_health",
249 passed=avg_ok,
250 message=(
251 f"avg_health={s['avg_health']:.2f} "
252 f">= {config['min_avg_health']:.2f}"
253 if avg_ok
254 else f"avg_health={s['avg_health']:.2f} "
255 f"< {config['min_avg_health']:.2f} (threshold)"
256 ),
257 )
258 )
259
260 und_ok = s["undocumented"] <= config["max_undocumented"]
261 gates.append(
262 DocCiGateResult(
263 name="max_undocumented",
264 passed=und_ok,
265 message=(
266 f"undocumented={s['undocumented']} "
267 f"<= {config['max_undocumented']}"
268 if und_ok
269 else f"undocumented={s['undocumented']} "
270 f"> {config['max_undocumented']} (threshold)"
271 ),
272 )
273 )
274
275 stale_ok = s["stale_count"] <= config["max_stale"]
276 gates.append(
277 DocCiGateResult(
278 name="max_stale",
279 passed=stale_ok,
280 message=(
281 f"stale={s['stale_count']} <= {config['max_stale']}"
282 if stale_ok
283 else f"stale={s['stale_count']} > {config['max_stale']} (threshold)"
284 ),
285 )
286 )
287
288 if config["fail_on_breaking_undocumented"]:
289 breaking_undoc = [
290 d for d in report["symbols"]
291 if d["breaking_changes"] and d["docstring"] is None
292 ]
293 bu_ok = len(breaking_undoc) == 0
294 gates.append(
295 DocCiGateResult(
296 name="breaking_undocumented",
297 passed=bu_ok,
298 message=(
299 "no breaking-change symbols lack docstrings"
300 if bu_ok
301 else f"{len(breaking_undoc)} breaking-change symbol(s) have no docstring"
302 ),
303 )
304 )
305
306 passed = all(g["passed"] for g in gates)
307 return DocCiResult(passed=passed, gates=gates, summary=s)
308
309
310 # ---------------------------------------------------------------------------
311 # JSON output types
312 # ---------------------------------------------------------------------------
313
314
315 class _HistoryEventJson(TypedDict):
316 """JSON representation of one symbol version event."""
317
318 commit_id: str
319 committed_at: str
320 op: str
321 version: str | None
322 sem_ver_bump: str | None
323 breaking: bool
324
325
326 class _SymbolHistoryJson(TypedDict):
327 """JSON representation of a symbol's full history."""
328
329 address: str
330 events: list[_HistoryEventJson]
331
332
333 class _ChangelogJson(TypedDict):
334 """JSON representation of a :class:`ChangelogReport`."""
335
336 from_ref: str
337 to_ref: str
338 added: list[str]
339 removed: list[str]
340 changed: list[str]
341 breaking: list[str]
342
343
344 class _DocCiGateJson(TypedDict):
345 name: str
346 passed: bool
347 message: str
348
349
350 class _DocCiJson(TypedDict):
351 passed: bool
352 gates: list[_DocCiGateJson]
353 summary: DocSummary
354
355
356 # ---------------------------------------------------------------------------
357 # Output helpers
358 # ---------------------------------------------------------------------------
359
360
361 def _print_history(address: str, events: list[SymbolVersionEvent]) -> None:
362 """Print the version history for *address* in human-readable format."""
363 print(f"History for: {sanitize_display(address)}")
364 print(f" {len(events)} event(s)")
365 print()
366 if not events:
367 print(" (no history — is the symbol history index built?)")
368 print(" Run: muse code index rebuild")
369 return
370 for ev in events:
371 ver = f" [{ev['version']}]" if ev["version"] else ""
372 bump = f" {ev['sem_ver_bump']}" if ev["sem_ver_bump"] else ""
373 brk = " ⚠ breaking" if ev["breaking"] else ""
374 print(f" {ev['committed_at'][:19]} {ev['op']:8}{ver}{bump}{brk}")
375 print(f" commit: {ev['commit_id'][:16]}")
376
377
378 def _print_ci_result(result: DocCiResult) -> None:
379 """Print the CI gate result in human-readable format."""
380 status = "✅ passed" if result["passed"] else "❌ failed"
381 print(f"Doc CI gate: {status}")
382 print()
383 for gate in result["gates"]:
384 icon = "✅" if gate["passed"] else "❌"
385 print(f" {icon} {sanitize_display(gate['name']):30} {sanitize_display(gate['message'])}")
386 s = result["summary"]
387 print()
388 print(
389 f" avg_health={s['avg_health']:.2f} "
390 f"documented={s['documented']}/{s['total_symbols']} "
391 f"undocumented={s['undocumented']} "
392 f"stale={s['stale_count']}"
393 )
394
395
396 # ---------------------------------------------------------------------------
397 # CLI registration
398 # ---------------------------------------------------------------------------
399
400
401 def register(
402 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
403 ) -> None:
404 """Register the ``docs`` subcommand under a code sub-parser."""
405 parser = subparsers.add_parser(
406 "docs",
407 help="Symbol-aware, version-annotated documentation for any codebase.",
408 description=__doc__,
409 formatter_class=argparse.RawDescriptionHelpFormatter,
410 )
411
412 parser.add_argument(
413 "targets",
414 nargs="*",
415 metavar="TARGET",
416 help=(
417 "Optional symbol addresses ('file.py::Symbol') or file paths. "
418 "Omit to document the full snapshot."
419 ),
420 )
421 parser.add_argument(
422 "--format",
423 "-f",
424 choices=["json", "html", "md", "text"],
425 default="text",
426 dest="fmt",
427 metavar="FORMAT",
428 help="Output format: json, html, md, text (default: text).",
429 )
430 parser.add_argument(
431 "--output",
432 "-o",
433 default=None,
434 metavar="PATH",
435 help=(
436 "Write output to PATH. For --format html, treated as a directory "
437 "(index.html is written inside it)."
438 ),
439 )
440 parser.add_argument(
441 "--missing",
442 action="store_true",
443 help="Show only public symbols that lack a docstring.",
444 )
445 parser.add_argument(
446 "--stale",
447 action="store_true",
448 help="Show only symbols with potentially stale documentation.",
449 )
450 parser.add_argument(
451 "--min-health",
452 type=float,
453 default=None,
454 metavar="SCORE",
455 dest="min_health",
456 help="Show only symbols whose health score is below SCORE (0.0–1.0).",
457 )
458 parser.add_argument(
459 "--symbol",
460 "-s",
461 action="append",
462 dest="symbols",
463 default=[],
464 metavar="ADDR",
465 help="Document a specific symbol address (repeatable).",
466 )
467 parser.add_argument(
468 "--depth",
469 "-d",
470 type=int,
471 default=3,
472 metavar="N",
473 help="Call-graph BFS depth for test-linkage resolution (default 3).",
474 )
475 parser.add_argument(
476 "--diff",
477 nargs=2,
478 metavar=("FROM", "TO"),
479 default=None,
480 help="Generate a changelog between FROM and TO (tags or commit IDs).",
481 )
482 parser.add_argument(
483 "--history",
484 default=None,
485 metavar="ADDR",
486 help="Show the full version history timeline for one symbol address.",
487 )
488 parser.add_argument(
489 "--at",
490 default=None,
491 metavar="COMMIT",
492 dest="at_commit",
493 help="Document the repository as of COMMIT (HEAD notation, SHA, or tag).",
494 )
495 parser.add_argument(
496 "--ci",
497 action="store_true",
498 help="Run the documentation quality gate from .muse/docs.toml.",
499 )
500 parser.add_argument(
501 "--json",
502 action="store_true",
503 dest="json_output",
504 help="Shortcut for --format json.",
505 )
506
507 parser.set_defaults(func=run)
508
509
510 # ---------------------------------------------------------------------------
511 # Command handler
512 # ---------------------------------------------------------------------------
513
514
515 def _to_render_format(raw: str) -> RenderFormat:
516 """Convert an argparse format string to a :class:`RenderFormat` literal.
517
518 ``"md"`` is accepted as an alias for ``"markdown"``. Unknown values fall
519 back to ``"text"`` with a warning.
520 """
521 if raw == "md" or raw == "markdown":
522 return "markdown"
523 if raw == "json":
524 return "json"
525 if raw == "html":
526 return "html"
527 if raw == "text":
528 return "text"
529 logger.warning("⚠️ Unknown format %r — falling back to text", raw)
530 return "text"
531
532
533 def run(args: argparse.Namespace) -> None:
534 """Execute ``muse code docs`` based on *args*."""
535 root = require_repo()
536 repo_id = read_repo_id(root)
537
538 raw_fmt: str = "json" if args.json_output else args.fmt
539 # "md" is an argparse alias — convert to the canonical renderer name.
540 fmt = _to_render_format(raw_fmt)
541
542 # ------------------------------------------------------------------
543 # Mode: --history ADDR
544 # ------------------------------------------------------------------
545 if args.history:
546 events = get_symbol_version_events(root, repo_id, args.history)
547 if args.json_output:
548 out: _SymbolHistoryJson = {
549 "address": args.history,
550 "events": [
551 _HistoryEventJson(
552 commit_id=ev["commit_id"],
553 committed_at=ev["committed_at"],
554 op=ev["op"],
555 version=ev["version"],
556 sem_ver_bump=ev["sem_ver_bump"],
557 breaking=ev["breaking"],
558 )
559 for ev in events
560 ],
561 }
562 print(json.dumps(out, indent=2))
563 else:
564 _print_history(args.history, events)
565 return
566
567 # ------------------------------------------------------------------
568 # Mode: --diff FROM TO
569 # ------------------------------------------------------------------
570 if args.diff:
571 from_ref, to_ref = args.diff
572 changelog = generate_changelog(root, repo_id, from_ref, to_ref)
573 if args.json_output:
574 out_cl: _ChangelogJson = {
575 "from_ref": changelog["from_ref"],
576 "to_ref": changelog["to_ref"],
577 "added": [e["address"] for e in changelog["added"]],
578 "removed": [e["address"] for e in changelog["removed"]],
579 "changed": [e["address"] for e in changelog["changed"]],
580 "breaking": [e["address"] for e in changelog["breaking"]],
581 }
582 print(json.dumps(out_cl, indent=2))
583 else:
584 _print_changelog(changelog)
585 return
586
587 # ------------------------------------------------------------------
588 # Normal documentation mode
589 # ------------------------------------------------------------------
590 all_targets: list[str] = list(args.targets) + list(args.symbols)
591
592 report = extract_docs(
593 root=root,
594 repo_id=repo_id,
595 targets=all_targets if all_targets else None,
596 commit_id=args.at_commit,
597 max_depth=args.depth,
598 )
599
600 # Apply filters AFTER extraction.
601 if args.missing:
602 # Keep only symbols without docstrings.
603 report = DocReport(
604 commit_id=report["commit_id"],
605 generated_at=report["generated_at"],
606 symbols=[d for d in report["symbols"] if d["docstring"] is None],
607 missing=report["missing"],
608 stale=report["stale"],
609 summary=report["summary"],
610 )
611 elif args.stale:
612 report = DocReport(
613 commit_id=report["commit_id"],
614 generated_at=report["generated_at"],
615 symbols=[d for d in report["symbols"] if "stale_impl" in d["doc_health_reasons"]],
616 missing=report["missing"],
617 stale=report["stale"],
618 summary=report["summary"],
619 )
620 elif args.min_health is not None:
621 report = DocReport(
622 commit_id=report["commit_id"],
623 generated_at=report["generated_at"],
624 symbols=[d for d in report["symbols"] if d["doc_health"] < args.min_health],
625 missing=report["missing"],
626 stale=report["stale"],
627 summary=report["summary"],
628 )
629
630 # ------------------------------------------------------------------
631 # Mode: --ci
632 # ------------------------------------------------------------------
633 if args.ci:
634 config = _load_doc_ci_config(root)
635 ci_result = _run_doc_ci(report, config)
636 if args.json_output:
637 out_ci: _DocCiJson = {
638 "passed": ci_result["passed"],
639 "gates": [
640 _DocCiGateJson(
641 name=g["name"],
642 passed=g["passed"],
643 message=g["message"],
644 )
645 for g in ci_result["gates"]
646 ],
647 "summary": ci_result["summary"],
648 }
649 print(json.dumps(out_ci, indent=2))
650 else:
651 _print_ci_result(ci_result)
652 sys.exit(0 if ci_result["passed"] else 1)
653
654 # ------------------------------------------------------------------
655 # Render and write/print output
656 # ------------------------------------------------------------------
657 output = render(report, fmt)
658
659 output_path = validate_output_path(args.output, root) if args.output else None
660
661 if output_path is not None:
662 if fmt == "html":
663 output_path.mkdir(parents=True, exist_ok=True)
664 target_file = output_path / "index.html"
665 else:
666 output_path.parent.mkdir(parents=True, exist_ok=True)
667 target_file = output_path
668 target_file.write_text(output, encoding="utf-8")
669 logger.info("✅ Documentation written to %s", target_file)
670 print(f"Wrote {len(report['symbols'])} symbol(s) to {sanitize_display(str(target_file))}")
671 else:
672 print(output)
673
674
675 def _print_changelog(changelog: ChangelogReport) -> None:
676 """Print a :class:`ChangelogReport` in human-readable format."""
677 print(f"Changelog: {changelog['from_ref']} → {changelog['to_ref']}")
678 print()
679 _print_section("Added", changelog["added"], "✅")
680 _print_section("Removed", changelog["removed"], "🗑️")
681 _print_section("Changed", changelog["changed"], "✏️")
682 _print_section("Breaking", changelog["breaking"], "⚠️")
683
684
685 def _print_section(
686 title: str,
687 entries: list[ChangelogEntry],
688 icon: str,
689 ) -> None:
690 if not entries:
691 return
692 print(f"{icon} {title} ({len(entries)}):")
693 for e in entries:
694 print(f" {e['address']}")
695 print()
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