"""muse code docs — symbol-aware, version-annotated documentation for any codebase. Traditional documentation tools parse the current file. They extract your docstring. They know nothing else. ``muse code docs`` knows everything: * **Who calls this symbol** and **what it calls** — via the committed call graph. Every doc page includes live caller and callee lists, not static hand-written cross-references. * **When it was introduced** — ``since v1.2.0`` inferred from the symbol history index and tag store, without manual ``.. versionadded::`` annotations. * **Whether the docstring is stale** — if the signature or implementation changed since the last body edit, Muse flags it. No guessing. * **Which tests cover it** — linked directly from the call-graph BFS. Every function's doc page lists the tests that exercise it. * **A quantitative health score** — per symbol and repo-wide, weighted by caller count so highly-used undocumented symbols contribute more debt. * **Machine-readable JSON** — the ``--format json`` output is structured for LLM ingestion, RAG pipelines, and agent-driven doc generation. Usage:: # Document the whole repository (text output) muse code docs # Document a single file muse code docs muse/core/store.py # Document a single symbol muse code docs "muse/core/store.py::read_commit" # Find all public symbols missing a docstring muse code docs --missing # Find all potentially stale docstrings muse code docs --stale # HTML output written to docs/ directory muse code docs --format html -o docs/ # Markdown to stdout muse code docs --format md # Machine-readable JSON muse code docs --format json # Changelog between two tags/commits muse code docs --diff v1.0 v2.0 # Version history for one symbol muse code docs --history "muse/core/store.py::read_commit" # Document a specific historical commit muse code docs --at HEAD~5 # Doc quality CI gate (.muse/docs.toml) muse code docs --ci # Only symbols below a health threshold muse code docs --min-health 0.6 Flags ----- ``TARGETS`` Optional symbol addresses (``"file.py::Symbol"``) or file paths. When omitted, the entire committed snapshot is documented. ``--format json|html|md|text`` Output format. Default: ``text``. ``--output PATH, -o PATH`` Write output to *PATH*. For ``--format html``, *PATH* is treated as a directory (created if absent) and ``index.html`` is written inside it. For other formats, *PATH* is the output file. ``--missing`` Show only public symbols that lack a docstring. ``--stale`` Show only symbols with potentially stale documentation. ``--min-health SCORE`` Show only symbols whose health score is below *SCORE* (0.0–1.0). ``--symbol ADDR, -s ADDR`` Document a specific symbol. Equivalent to passing *ADDR* as a positional argument. May be repeated. ``--depth N, -d N`` Call-graph BFS depth for test-linkage resolution (default 3). ``--diff FROM TO`` Generate a changelog between two refs (commit IDs or tag names). ``--history ADDR`` Show the full version history timeline for one symbol address. ``--at COMMIT`` Document the repository as of *COMMIT* (HEAD notation, SHA prefix, or tag name). ``--ci`` Run the documentation quality gate from ``.muse/docs.toml`` and exit with code 1 when the thresholds are not met. ``--json`` Shortcut for ``--format json``. """ from __future__ import annotations import argparse import json import logging import pathlib import sys import tomllib from typing import NotRequired, TypedDict from muse.core.doc_extractor import ( DocReport, DocSummary, MissingDocEntry, StaleDocEntry, SymbolDoc, extract_docs, ) from muse.core.doc_history import ( ChangelogEntry, ChangelogReport, SymbolVersionEvent, generate_changelog, get_symbol_version_events, ) from muse.core.doc_renderer import RenderFormat, render from muse.core.repo import read_repo_id, require_repo from muse.core.store import MsgpackValue, _int_val, _str_val from muse.core.validation import sanitize_display, validate_output_path logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # CI gate types and helpers # --------------------------------------------------------------------------- class DocCiConfig(TypedDict): """Configuration for the documentation quality CI gate.""" min_avg_health: float """Fail when average health across all public symbols falls below this.""" max_undocumented: int """Fail when more than this many public symbols lack a docstring.""" max_stale: int """Fail when more than this many symbols have a stale docstring.""" fail_on_breaking_undocumented: bool """Fail when any symbol with breaking changes has no docstring.""" _DEFAULT_DOC_CI_CONFIG = DocCiConfig( min_avg_health=0.5, max_undocumented=50, max_stale=20, fail_on_breaking_undocumented=False, ) _DOC_CI_TOML = ".muse/docs.toml" def _load_doc_ci_config(root: pathlib.Path) -> DocCiConfig: """Load documentation CI config from ``.muse/docs.toml``, falling back to defaults.""" path = root / _DOC_CI_TOML if not path.exists(): return _DEFAULT_DOC_CI_CONFIG try: raw = tomllib.loads(path.read_text(encoding="utf-8")) docs_section = raw.get("docs", {}) if not isinstance(docs_section, dict): return _DEFAULT_DOC_CI_CONFIG def _fval(key: str, default: float) -> float: v = docs_section.get(key, default) return float(v) if isinstance(v, (int, float)) else default def _ival(key: str, default: int) -> int: v = docs_section.get(key, default) return int(v) if isinstance(v, (int, float)) else default def _bval(key: str, default: bool) -> bool: v = docs_section.get(key, default) return bool(v) if isinstance(v, bool) else default return DocCiConfig( min_avg_health=_fval("min_avg_health", _DEFAULT_DOC_CI_CONFIG["min_avg_health"]), max_undocumented=_ival("max_undocumented", _DEFAULT_DOC_CI_CONFIG["max_undocumented"]), max_stale=_ival("max_stale", _DEFAULT_DOC_CI_CONFIG["max_stale"]), fail_on_breaking_undocumented=_bval( "fail_on_breaking_undocumented", _DEFAULT_DOC_CI_CONFIG["fail_on_breaking_undocumented"], ), ) except Exception as exc: logger.warning("⚠️ Could not parse %s: %s — using defaults", _DOC_CI_TOML, exc) return _DEFAULT_DOC_CI_CONFIG class DocCiGateResult(TypedDict): """Result of a single documentation CI gate check.""" name: str passed: bool message: str class DocCiResult(TypedDict): """Overall result of the documentation CI gate.""" passed: bool gates: list[DocCiGateResult] summary: DocSummary def _run_doc_ci(report: DocReport, config: DocCiConfig) -> DocCiResult: """Evaluate documentation quality gates against *report*. Args: report: The :class:`DocReport` to evaluate. config: CI gate thresholds from ``.muse/docs.toml``. """ gates: list[DocCiGateResult] = [] s = report["summary"] avg_ok = s["avg_health"] >= config["min_avg_health"] gates.append( DocCiGateResult( name="avg_health", passed=avg_ok, message=( f"avg_health={s['avg_health']:.2f} " f">= {config['min_avg_health']:.2f}" if avg_ok else f"avg_health={s['avg_health']:.2f} " f"< {config['min_avg_health']:.2f} (threshold)" ), ) ) und_ok = s["undocumented"] <= config["max_undocumented"] gates.append( DocCiGateResult( name="max_undocumented", passed=und_ok, message=( f"undocumented={s['undocumented']} " f"<= {config['max_undocumented']}" if und_ok else f"undocumented={s['undocumented']} " f"> {config['max_undocumented']} (threshold)" ), ) ) stale_ok = s["stale_count"] <= config["max_stale"] gates.append( DocCiGateResult( name="max_stale", passed=stale_ok, message=( f"stale={s['stale_count']} <= {config['max_stale']}" if stale_ok else f"stale={s['stale_count']} > {config['max_stale']} (threshold)" ), ) ) if config["fail_on_breaking_undocumented"]: breaking_undoc = [ d for d in report["symbols"] if d["breaking_changes"] and d["docstring"] is None ] bu_ok = len(breaking_undoc) == 0 gates.append( DocCiGateResult( name="breaking_undocumented", passed=bu_ok, message=( "no breaking-change symbols lack docstrings" if bu_ok else f"{len(breaking_undoc)} breaking-change symbol(s) have no docstring" ), ) ) passed = all(g["passed"] for g in gates) return DocCiResult(passed=passed, gates=gates, summary=s) # --------------------------------------------------------------------------- # JSON output types # --------------------------------------------------------------------------- class _HistoryEventJson(TypedDict): """JSON representation of one symbol version event.""" commit_id: str committed_at: str op: str version: str | None sem_ver_bump: str | None breaking: bool class _SymbolHistoryJson(TypedDict): """JSON representation of a symbol's full history.""" address: str events: list[_HistoryEventJson] class _ChangelogJson(TypedDict): """JSON representation of a :class:`ChangelogReport`.""" from_ref: str to_ref: str added: list[str] removed: list[str] changed: list[str] breaking: list[str] class _DocCiGateJson(TypedDict): name: str passed: bool message: str class _DocCiJson(TypedDict): passed: bool gates: list[_DocCiGateJson] summary: DocSummary # --------------------------------------------------------------------------- # Output helpers # --------------------------------------------------------------------------- def _print_history(address: str, events: list[SymbolVersionEvent]) -> None: """Print the version history for *address* in human-readable format.""" print(f"History for: {sanitize_display(address)}") print(f" {len(events)} event(s)") print() if not events: print(" (no history — is the symbol history index built?)") print(" Run: muse code index rebuild") return for ev in events: ver = f" [{ev['version']}]" if ev["version"] else "" bump = f" {ev['sem_ver_bump']}" if ev["sem_ver_bump"] else "" brk = " ⚠ breaking" if ev["breaking"] else "" print(f" {ev['committed_at'][:19]} {ev['op']:8}{ver}{bump}{brk}") print(f" commit: {ev['commit_id'][:16]}") def _print_ci_result(result: DocCiResult) -> None: """Print the CI gate result in human-readable format.""" status = "✅ passed" if result["passed"] else "❌ failed" print(f"Doc CI gate: {status}") print() for gate in result["gates"]: icon = "✅" if gate["passed"] else "❌" print(f" {icon} {sanitize_display(gate['name']):30} {sanitize_display(gate['message'])}") s = result["summary"] print() print( f" avg_health={s['avg_health']:.2f} " f"documented={s['documented']}/{s['total_symbols']} " f"undocumented={s['undocumented']} " f"stale={s['stale_count']}" ) # --------------------------------------------------------------------------- # CLI registration # --------------------------------------------------------------------------- def register( subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]", ) -> None: """Register the ``docs`` subcommand under a code sub-parser.""" parser = subparsers.add_parser( "docs", help="Symbol-aware, version-annotated documentation for any codebase.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "targets", nargs="*", metavar="TARGET", help=( "Optional symbol addresses ('file.py::Symbol') or file paths. " "Omit to document the full snapshot." ), ) parser.add_argument( "--format", "-f", choices=["json", "html", "md", "text"], default="text", dest="fmt", metavar="FORMAT", help="Output format: json, html, md, text (default: text).", ) parser.add_argument( "--output", "-o", default=None, metavar="PATH", help=( "Write output to PATH. For --format html, treated as a directory " "(index.html is written inside it)." ), ) parser.add_argument( "--missing", action="store_true", help="Show only public symbols that lack a docstring.", ) parser.add_argument( "--stale", action="store_true", help="Show only symbols with potentially stale documentation.", ) parser.add_argument( "--min-health", type=float, default=None, metavar="SCORE", dest="min_health", help="Show only symbols whose health score is below SCORE (0.0–1.0).", ) parser.add_argument( "--symbol", "-s", action="append", dest="symbols", default=[], metavar="ADDR", help="Document a specific symbol address (repeatable).", ) parser.add_argument( "--depth", "-d", type=int, default=3, metavar="N", help="Call-graph BFS depth for test-linkage resolution (default 3).", ) parser.add_argument( "--diff", nargs=2, metavar=("FROM", "TO"), default=None, help="Generate a changelog between FROM and TO (tags or commit IDs).", ) parser.add_argument( "--history", default=None, metavar="ADDR", help="Show the full version history timeline for one symbol address.", ) parser.add_argument( "--at", default=None, metavar="COMMIT", dest="at_commit", help="Document the repository as of COMMIT (HEAD notation, SHA, or tag).", ) parser.add_argument( "--ci", action="store_true", help="Run the documentation quality gate from .muse/docs.toml.", ) parser.add_argument( "--json", action="store_true", dest="json_output", help="Shortcut for --format json.", ) parser.set_defaults(func=run) # --------------------------------------------------------------------------- # Command handler # --------------------------------------------------------------------------- def _to_render_format(raw: str) -> RenderFormat: """Convert an argparse format string to a :class:`RenderFormat` literal. ``"md"`` is accepted as an alias for ``"markdown"``. Unknown values fall back to ``"text"`` with a warning. """ if raw == "md" or raw == "markdown": return "markdown" if raw == "json": return "json" if raw == "html": return "html" if raw == "text": return "text" logger.warning("⚠️ Unknown format %r — falling back to text", raw) return "text" def run(args: argparse.Namespace) -> None: """Execute ``muse code docs`` based on *args*.""" root = require_repo() repo_id = read_repo_id(root) raw_fmt: str = "json" if args.json_output else args.fmt # "md" is an argparse alias — convert to the canonical renderer name. fmt = _to_render_format(raw_fmt) # ------------------------------------------------------------------ # Mode: --history ADDR # ------------------------------------------------------------------ if args.history: events = get_symbol_version_events(root, repo_id, args.history) if args.json_output: out: _SymbolHistoryJson = { "address": args.history, "events": [ _HistoryEventJson( commit_id=ev["commit_id"], committed_at=ev["committed_at"], op=ev["op"], version=ev["version"], sem_ver_bump=ev["sem_ver_bump"], breaking=ev["breaking"], ) for ev in events ], } print(json.dumps(out, indent=2)) else: _print_history(args.history, events) return # ------------------------------------------------------------------ # Mode: --diff FROM TO # ------------------------------------------------------------------ if args.diff: from_ref, to_ref = args.diff changelog = generate_changelog(root, repo_id, from_ref, to_ref) if args.json_output: out_cl: _ChangelogJson = { "from_ref": changelog["from_ref"], "to_ref": changelog["to_ref"], "added": [e["address"] for e in changelog["added"]], "removed": [e["address"] for e in changelog["removed"]], "changed": [e["address"] for e in changelog["changed"]], "breaking": [e["address"] for e in changelog["breaking"]], } print(json.dumps(out_cl, indent=2)) else: _print_changelog(changelog) return # ------------------------------------------------------------------ # Normal documentation mode # ------------------------------------------------------------------ all_targets: list[str] = list(args.targets) + list(args.symbols) report = extract_docs( root=root, repo_id=repo_id, targets=all_targets if all_targets else None, commit_id=args.at_commit, max_depth=args.depth, ) # Apply filters AFTER extraction. if args.missing: # Keep only symbols without docstrings. report = DocReport( commit_id=report["commit_id"], generated_at=report["generated_at"], symbols=[d for d in report["symbols"] if d["docstring"] is None], missing=report["missing"], stale=report["stale"], summary=report["summary"], ) elif args.stale: report = DocReport( commit_id=report["commit_id"], generated_at=report["generated_at"], symbols=[d for d in report["symbols"] if "stale_impl" in d["doc_health_reasons"]], missing=report["missing"], stale=report["stale"], summary=report["summary"], ) elif args.min_health is not None: report = DocReport( commit_id=report["commit_id"], generated_at=report["generated_at"], symbols=[d for d in report["symbols"] if d["doc_health"] < args.min_health], missing=report["missing"], stale=report["stale"], summary=report["summary"], ) # ------------------------------------------------------------------ # Mode: --ci # ------------------------------------------------------------------ if args.ci: config = _load_doc_ci_config(root) ci_result = _run_doc_ci(report, config) if args.json_output: out_ci: _DocCiJson = { "passed": ci_result["passed"], "gates": [ _DocCiGateJson( name=g["name"], passed=g["passed"], message=g["message"], ) for g in ci_result["gates"] ], "summary": ci_result["summary"], } print(json.dumps(out_ci, indent=2)) else: _print_ci_result(ci_result) sys.exit(0 if ci_result["passed"] else 1) # ------------------------------------------------------------------ # Render and write/print output # ------------------------------------------------------------------ output = render(report, fmt) output_path = validate_output_path(args.output, root) if args.output else None if output_path is not None: if fmt == "html": output_path.mkdir(parents=True, exist_ok=True) target_file = output_path / "index.html" else: output_path.parent.mkdir(parents=True, exist_ok=True) target_file = output_path target_file.write_text(output, encoding="utf-8") logger.info("✅ Documentation written to %s", target_file) print(f"Wrote {len(report['symbols'])} symbol(s) to {sanitize_display(str(target_file))}") else: print(output) def _print_changelog(changelog: ChangelogReport) -> None: """Print a :class:`ChangelogReport` in human-readable format.""" print(f"Changelog: {changelog['from_ref']} → {changelog['to_ref']}") print() _print_section("Added", changelog["added"], "✅") _print_section("Removed", changelog["removed"], "🗑️") _print_section("Changed", changelog["changed"], "✏️") _print_section("Breaking", changelog["breaking"], "⚠️") def _print_section( title: str, entries: list[ChangelogEntry], icon: str, ) -> None: if not entries: return print(f"{icon} {title} ({len(entries)}):") for e in entries: print(f" {e['address']}") print()