"""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._types import short_id from muse.core.envelope import EnvelopeJson, make_envelope from muse.core.timing import start_timer 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, resolve_commit_ref, read_current_branch 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): """One symbol version event nested inside :class:`_SymbolHistoryJson`. Fields ------ commit_id Full content-addressed commit ID where this event was recorded. committed_at ISO-8601 timestamp of the commit (UTC). op Operation type: "added", "modified", "deleted", or "renamed". version Explicit version annotation extracted from the docstring, or None. sem_ver_bump Semantic-version bump inferred for this change: "major", "minor", "patch", or None when not determinable. breaking True when the change is classified as a breaking API change. """ commit_id: str committed_at: str op: str version: str | None sem_ver_bump: str | None breaking: bool class _SymbolHistoryJson(EnvelopeJson): """JSON output for ``muse code docs --history --json``. Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`. Fields ------ address The full symbol address inspected (e.g. ``"store.py::read_commit"``). events Ordered list of version events for the symbol, oldest first. """ address: str events: list[_HistoryEventJson] class _ChangelogJson(EnvelopeJson): """JSON output for ``muse code docs --diff --json``. Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`. Fields ------ from_ref Start ref of the changelog range (branch, tag, or commit SHA). to_ref End ref of the changelog range (branch, tag, or commit SHA). added Addresses of symbols added in the range. removed Addresses of symbols removed in the range. changed Addresses of symbols with doc changes. breaking Addresses of symbols with breaking changes. """ from_ref: str to_ref: str added: list[str] removed: list[str] changed: list[str] breaking: list[str] class _DocCiGateJson(TypedDict): """Per-gate quality result nested inside :class:`_DocCiJson`. Fields ------ name Gate identifier (e.g. "public_symbols_documented", "no_missing_args"). passed True when this gate's quality threshold was met. message Human-readable explanation of the gate result, empty on pass. """ name: str passed: bool message: str class _DocCiJson(EnvelopeJson): """JSON output for ``muse code docs --ci --json``. Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`. Fields ------ passed True when all doc quality gates passed (safe to merge / release). gates Ordered list of per-gate results — each has name, passed, message. summary Aggregated documentation coverage summary for the snapshot. """ 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: {short_id(ev['commit_id'])}") 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", "-j", action="store_true", dest="json_out", 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: """Extract, render, and gate documentation for a repository. Operates in four modes: default renders all symbol documentation; ``--history ADDR`` shows version history for one symbol; ``--diff FROM TO`` generates a changelog between two refs; ``--ci`` runs the documentation quality gate defined in ``.muse/docs.toml``. Agent quickstart ---------------- :: muse docs --json muse docs --history "src/billing.py::compute_total" --json muse docs --diff v1.0.0 v2.0.0 --json muse docs --ci --json JSON fields (default mode) -------------------------- symbols List of symbol documentation objects: ``address``, ``kind``, ``docstring``, ``signature``. JSON fields (``--history`` mode) ---------------------------------- address Symbol address queried. events List of version events: ``commit_id``, ``committed_at``, ``op``, ``version``, ``sem_ver_bump``, ``breaking``. JSON fields (``--diff`` mode) ------------------------------ from_ref Start ref. to_ref End ref. added List of added symbol addresses. removed List of removed symbol addresses. Exit codes ---------- 0 Success. 1 ``--ci`` gate failures; or invalid arguments. 2 Not inside a Muse repository. """ elapsed = start_timer() root = require_repo() repo_id = read_repo_id(root) raw_fmt: str = "json" if args.json_out 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_out: print(json.dumps(_SymbolHistoryJson( **make_envelope(elapsed), 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 ], ))) 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_out: print(json.dumps(_ChangelogJson( **make_envelope(elapsed), 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"]], ))) else: _print_changelog(changelog) return # ------------------------------------------------------------------ # Normal documentation mode # ------------------------------------------------------------------ all_targets: list[str] = list(args.targets) + list(args.symbols) at_commit: str | None = args.at_commit if at_commit is not None: try: branch = read_current_branch(root) except ValueError: branch = "main" resolved = resolve_commit_ref(root, repo_id, branch, at_commit) at_commit = resolved.commit_id if resolved else None report = extract_docs( root=root, repo_id=repo_id, targets=all_targets if all_targets else None, commit_id=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_out: print(json.dumps(_DocCiJson( **make_envelope(elapsed), 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"], ))) 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()