docs_cmd.py
python
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9
docs: expand cache plan with all seven testing tiers and do…
Sonnet 4.6
132 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._types import short_id |
| 129 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 130 | from muse.core.timing import start_timer |
| 131 | from muse.core.doc_extractor import ( |
| 132 | DocReport, |
| 133 | DocSummary, |
| 134 | MissingDocEntry, |
| 135 | StaleDocEntry, |
| 136 | SymbolDoc, |
| 137 | extract_docs, |
| 138 | ) |
| 139 | from muse.core.doc_history import ( |
| 140 | ChangelogEntry, |
| 141 | ChangelogReport, |
| 142 | SymbolVersionEvent, |
| 143 | generate_changelog, |
| 144 | get_symbol_version_events, |
| 145 | ) |
| 146 | from muse.core.doc_renderer import RenderFormat, render |
| 147 | from muse.core.repo import read_repo_id, require_repo |
| 148 | from muse.core.store import MsgpackValue, _int_val, _str_val, resolve_commit_ref, read_current_branch |
| 149 | from muse.core.validation import sanitize_display, validate_output_path |
| 150 | |
| 151 | logger = logging.getLogger(__name__) |
| 152 | |
| 153 | |
| 154 | # --------------------------------------------------------------------------- |
| 155 | # CI gate types and helpers |
| 156 | # --------------------------------------------------------------------------- |
| 157 | |
| 158 | |
| 159 | class DocCiConfig(TypedDict): |
| 160 | """Configuration for the documentation quality CI gate.""" |
| 161 | |
| 162 | min_avg_health: float |
| 163 | """Fail when average health across all public symbols falls below this.""" |
| 164 | |
| 165 | max_undocumented: int |
| 166 | """Fail when more than this many public symbols lack a docstring.""" |
| 167 | |
| 168 | max_stale: int |
| 169 | """Fail when more than this many symbols have a stale docstring.""" |
| 170 | |
| 171 | fail_on_breaking_undocumented: bool |
| 172 | """Fail when any symbol with breaking changes has no docstring.""" |
| 173 | |
| 174 | |
| 175 | _DEFAULT_DOC_CI_CONFIG = DocCiConfig( |
| 176 | min_avg_health=0.5, |
| 177 | max_undocumented=50, |
| 178 | max_stale=20, |
| 179 | fail_on_breaking_undocumented=False, |
| 180 | ) |
| 181 | |
| 182 | _DOC_CI_TOML = ".muse/docs.toml" |
| 183 | |
| 184 | |
| 185 | def _load_doc_ci_config(root: pathlib.Path) -> DocCiConfig: |
| 186 | """Load documentation CI config from ``.muse/docs.toml``, falling back to defaults.""" |
| 187 | path = root / _DOC_CI_TOML |
| 188 | if not path.exists(): |
| 189 | return _DEFAULT_DOC_CI_CONFIG |
| 190 | try: |
| 191 | raw = tomllib.loads(path.read_text(encoding="utf-8")) |
| 192 | docs_section = raw.get("docs", {}) |
| 193 | if not isinstance(docs_section, dict): |
| 194 | return _DEFAULT_DOC_CI_CONFIG |
| 195 | |
| 196 | def _fval(key: str, default: float) -> float: |
| 197 | v = docs_section.get(key, default) |
| 198 | return float(v) if isinstance(v, (int, float)) else default |
| 199 | |
| 200 | def _ival(key: str, default: int) -> int: |
| 201 | v = docs_section.get(key, default) |
| 202 | return int(v) if isinstance(v, (int, float)) else default |
| 203 | |
| 204 | def _bval(key: str, default: bool) -> bool: |
| 205 | v = docs_section.get(key, default) |
| 206 | return bool(v) if isinstance(v, bool) else default |
| 207 | |
| 208 | return DocCiConfig( |
| 209 | min_avg_health=_fval("min_avg_health", _DEFAULT_DOC_CI_CONFIG["min_avg_health"]), |
| 210 | max_undocumented=_ival("max_undocumented", _DEFAULT_DOC_CI_CONFIG["max_undocumented"]), |
| 211 | max_stale=_ival("max_stale", _DEFAULT_DOC_CI_CONFIG["max_stale"]), |
| 212 | fail_on_breaking_undocumented=_bval( |
| 213 | "fail_on_breaking_undocumented", |
| 214 | _DEFAULT_DOC_CI_CONFIG["fail_on_breaking_undocumented"], |
| 215 | ), |
| 216 | ) |
| 217 | except Exception as exc: |
| 218 | logger.warning("⚠️ Could not parse %s: %s — using defaults", _DOC_CI_TOML, exc) |
| 219 | return _DEFAULT_DOC_CI_CONFIG |
| 220 | |
| 221 | |
| 222 | class DocCiGateResult(TypedDict): |
| 223 | """Result of a single documentation CI gate check.""" |
| 224 | |
| 225 | name: str |
| 226 | passed: bool |
| 227 | message: str |
| 228 | |
| 229 | |
| 230 | class DocCiResult(TypedDict): |
| 231 | """Overall result of the documentation CI gate.""" |
| 232 | |
| 233 | passed: bool |
| 234 | gates: list[DocCiGateResult] |
| 235 | summary: DocSummary |
| 236 | |
| 237 | |
| 238 | def _run_doc_ci(report: DocReport, config: DocCiConfig) -> DocCiResult: |
| 239 | """Evaluate documentation quality gates against *report*. |
| 240 | |
| 241 | Args: |
| 242 | report: The :class:`DocReport` to evaluate. |
| 243 | config: CI gate thresholds from ``.muse/docs.toml``. |
| 244 | """ |
| 245 | gates: list[DocCiGateResult] = [] |
| 246 | s = report["summary"] |
| 247 | |
| 248 | avg_ok = s["avg_health"] >= config["min_avg_health"] |
| 249 | gates.append( |
| 250 | DocCiGateResult( |
| 251 | name="avg_health", |
| 252 | passed=avg_ok, |
| 253 | message=( |
| 254 | f"avg_health={s['avg_health']:.2f} " |
| 255 | f">= {config['min_avg_health']:.2f}" |
| 256 | if avg_ok |
| 257 | else f"avg_health={s['avg_health']:.2f} " |
| 258 | f"< {config['min_avg_health']:.2f} (threshold)" |
| 259 | ), |
| 260 | ) |
| 261 | ) |
| 262 | |
| 263 | und_ok = s["undocumented"] <= config["max_undocumented"] |
| 264 | gates.append( |
| 265 | DocCiGateResult( |
| 266 | name="max_undocumented", |
| 267 | passed=und_ok, |
| 268 | message=( |
| 269 | f"undocumented={s['undocumented']} " |
| 270 | f"<= {config['max_undocumented']}" |
| 271 | if und_ok |
| 272 | else f"undocumented={s['undocumented']} " |
| 273 | f"> {config['max_undocumented']} (threshold)" |
| 274 | ), |
| 275 | ) |
| 276 | ) |
| 277 | |
| 278 | stale_ok = s["stale_count"] <= config["max_stale"] |
| 279 | gates.append( |
| 280 | DocCiGateResult( |
| 281 | name="max_stale", |
| 282 | passed=stale_ok, |
| 283 | message=( |
| 284 | f"stale={s['stale_count']} <= {config['max_stale']}" |
| 285 | if stale_ok |
| 286 | else f"stale={s['stale_count']} > {config['max_stale']} (threshold)" |
| 287 | ), |
| 288 | ) |
| 289 | ) |
| 290 | |
| 291 | if config["fail_on_breaking_undocumented"]: |
| 292 | breaking_undoc = [ |
| 293 | d for d in report["symbols"] |
| 294 | if d["breaking_changes"] and d["docstring"] is None |
| 295 | ] |
| 296 | bu_ok = len(breaking_undoc) == 0 |
| 297 | gates.append( |
| 298 | DocCiGateResult( |
| 299 | name="breaking_undocumented", |
| 300 | passed=bu_ok, |
| 301 | message=( |
| 302 | "no breaking-change symbols lack docstrings" |
| 303 | if bu_ok |
| 304 | else f"{len(breaking_undoc)} breaking-change symbol(s) have no docstring" |
| 305 | ), |
| 306 | ) |
| 307 | ) |
| 308 | |
| 309 | passed = all(g["passed"] for g in gates) |
| 310 | return DocCiResult(passed=passed, gates=gates, summary=s) |
| 311 | |
| 312 | |
| 313 | # --------------------------------------------------------------------------- |
| 314 | # JSON output types |
| 315 | # --------------------------------------------------------------------------- |
| 316 | |
| 317 | |
| 318 | class _HistoryEventJson(TypedDict): |
| 319 | """One symbol version event nested inside :class:`_SymbolHistoryJson`. |
| 320 | |
| 321 | Fields |
| 322 | ------ |
| 323 | commit_id Full content-addressed commit ID where this event was recorded. |
| 324 | committed_at ISO-8601 timestamp of the commit (UTC). |
| 325 | op Operation type: "added", "modified", "deleted", or "renamed". |
| 326 | version Explicit version annotation extracted from the docstring, or None. |
| 327 | sem_ver_bump Semantic-version bump inferred for this change: "major", "minor", |
| 328 | "patch", or None when not determinable. |
| 329 | breaking True when the change is classified as a breaking API change. |
| 330 | """ |
| 331 | |
| 332 | commit_id: str |
| 333 | committed_at: str |
| 334 | op: str |
| 335 | version: str | None |
| 336 | sem_ver_bump: str | None |
| 337 | breaking: bool |
| 338 | |
| 339 | |
| 340 | class _SymbolHistoryJson(EnvelopeJson): |
| 341 | """JSON output for ``muse code docs --history --json``. |
| 342 | |
| 343 | Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`. |
| 344 | |
| 345 | Fields |
| 346 | ------ |
| 347 | address The full symbol address inspected (e.g. ``"store.py::read_commit"``). |
| 348 | events Ordered list of version events for the symbol, oldest first. |
| 349 | """ |
| 350 | |
| 351 | address: str |
| 352 | events: list[_HistoryEventJson] |
| 353 | |
| 354 | |
| 355 | class _ChangelogJson(EnvelopeJson): |
| 356 | """JSON output for ``muse code docs --diff --json``. |
| 357 | |
| 358 | Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`. |
| 359 | |
| 360 | Fields |
| 361 | ------ |
| 362 | from_ref Start ref of the changelog range (branch, tag, or commit SHA). |
| 363 | to_ref End ref of the changelog range (branch, tag, or commit SHA). |
| 364 | added Addresses of symbols added in the range. |
| 365 | removed Addresses of symbols removed in the range. |
| 366 | changed Addresses of symbols with doc changes. |
| 367 | breaking Addresses of symbols with breaking changes. |
| 368 | """ |
| 369 | |
| 370 | from_ref: str |
| 371 | to_ref: str |
| 372 | added: list[str] |
| 373 | removed: list[str] |
| 374 | changed: list[str] |
| 375 | breaking: list[str] |
| 376 | |
| 377 | |
| 378 | class _DocCiGateJson(TypedDict): |
| 379 | """Per-gate quality result nested inside :class:`_DocCiJson`. |
| 380 | |
| 381 | Fields |
| 382 | ------ |
| 383 | name Gate identifier (e.g. "public_symbols_documented", "no_missing_args"). |
| 384 | passed True when this gate's quality threshold was met. |
| 385 | message Human-readable explanation of the gate result, empty on pass. |
| 386 | """ |
| 387 | |
| 388 | name: str |
| 389 | passed: bool |
| 390 | message: str |
| 391 | |
| 392 | |
| 393 | class _DocCiJson(EnvelopeJson): |
| 394 | """JSON output for ``muse code docs --ci --json``. |
| 395 | |
| 396 | Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`. |
| 397 | |
| 398 | Fields |
| 399 | ------ |
| 400 | passed True when all doc quality gates passed (safe to merge / release). |
| 401 | gates Ordered list of per-gate results — each has name, passed, message. |
| 402 | summary Aggregated documentation coverage summary for the snapshot. |
| 403 | """ |
| 404 | |
| 405 | passed: bool |
| 406 | gates: list[_DocCiGateJson] |
| 407 | summary: DocSummary |
| 408 | |
| 409 | |
| 410 | # --------------------------------------------------------------------------- |
| 411 | # Output helpers |
| 412 | # --------------------------------------------------------------------------- |
| 413 | |
| 414 | |
| 415 | def _print_history(address: str, events: list[SymbolVersionEvent]) -> None: |
| 416 | """Print the version history for *address* in human-readable format.""" |
| 417 | print(f"History for: {sanitize_display(address)}") |
| 418 | print(f" {len(events)} event(s)") |
| 419 | print() |
| 420 | if not events: |
| 421 | print(" (no history — is the symbol history index built?)") |
| 422 | print(" Run: muse code index rebuild") |
| 423 | return |
| 424 | for ev in events: |
| 425 | ver = f" [{ev['version']}]" if ev["version"] else "" |
| 426 | bump = f" {ev['sem_ver_bump']}" if ev["sem_ver_bump"] else "" |
| 427 | brk = " ⚠ breaking" if ev["breaking"] else "" |
| 428 | print(f" {ev['committed_at'][:19]} {ev['op']:8}{ver}{bump}{brk}") |
| 429 | print(f" commit: {short_id(ev['commit_id'])}") |
| 430 | |
| 431 | |
| 432 | def _print_ci_result(result: DocCiResult) -> None: |
| 433 | """Print the CI gate result in human-readable format.""" |
| 434 | status = "✅ passed" if result["passed"] else "❌ failed" |
| 435 | print(f"Doc CI gate: {status}") |
| 436 | print() |
| 437 | for gate in result["gates"]: |
| 438 | icon = "✅" if gate["passed"] else "❌" |
| 439 | print(f" {icon} {sanitize_display(gate['name']):30} {sanitize_display(gate['message'])}") |
| 440 | s = result["summary"] |
| 441 | print() |
| 442 | print( |
| 443 | f" avg_health={s['avg_health']:.2f} " |
| 444 | f"documented={s['documented']}/{s['total_symbols']} " |
| 445 | f"undocumented={s['undocumented']} " |
| 446 | f"stale={s['stale_count']}" |
| 447 | ) |
| 448 | |
| 449 | |
| 450 | # --------------------------------------------------------------------------- |
| 451 | # CLI registration |
| 452 | # --------------------------------------------------------------------------- |
| 453 | |
| 454 | |
| 455 | def register( |
| 456 | subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]", |
| 457 | ) -> None: |
| 458 | """Register the ``docs`` subcommand under a code sub-parser.""" |
| 459 | parser = subparsers.add_parser( |
| 460 | "docs", |
| 461 | help="Symbol-aware, version-annotated documentation for any codebase.", |
| 462 | description=__doc__, |
| 463 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 464 | ) |
| 465 | |
| 466 | parser.add_argument( |
| 467 | "targets", |
| 468 | nargs="*", |
| 469 | metavar="TARGET", |
| 470 | help=( |
| 471 | "Optional symbol addresses ('file.py::Symbol') or file paths. " |
| 472 | "Omit to document the full snapshot." |
| 473 | ), |
| 474 | ) |
| 475 | parser.add_argument( |
| 476 | "--format", |
| 477 | "-f", |
| 478 | choices=["json", "html", "md", "text"], |
| 479 | default="text", |
| 480 | dest="fmt", |
| 481 | metavar="FORMAT", |
| 482 | help="Output format: json, html, md, text (default: text).", |
| 483 | ) |
| 484 | parser.add_argument( |
| 485 | "--output", |
| 486 | "-o", |
| 487 | default=None, |
| 488 | metavar="PATH", |
| 489 | help=( |
| 490 | "Write output to PATH. For --format html, treated as a directory " |
| 491 | "(index.html is written inside it)." |
| 492 | ), |
| 493 | ) |
| 494 | parser.add_argument( |
| 495 | "--missing", |
| 496 | action="store_true", |
| 497 | help="Show only public symbols that lack a docstring.", |
| 498 | ) |
| 499 | parser.add_argument( |
| 500 | "--stale", |
| 501 | action="store_true", |
| 502 | help="Show only symbols with potentially stale documentation.", |
| 503 | ) |
| 504 | parser.add_argument( |
| 505 | "--min-health", |
| 506 | type=float, |
| 507 | default=None, |
| 508 | metavar="SCORE", |
| 509 | dest="min_health", |
| 510 | help="Show only symbols whose health score is below SCORE (0.0–1.0).", |
| 511 | ) |
| 512 | parser.add_argument( |
| 513 | "--symbol", |
| 514 | "-s", |
| 515 | action="append", |
| 516 | dest="symbols", |
| 517 | default=[], |
| 518 | metavar="ADDR", |
| 519 | help="Document a specific symbol address (repeatable).", |
| 520 | ) |
| 521 | parser.add_argument( |
| 522 | "--depth", |
| 523 | "-d", |
| 524 | type=int, |
| 525 | default=3, |
| 526 | metavar="N", |
| 527 | help="Call-graph BFS depth for test-linkage resolution (default 3).", |
| 528 | ) |
| 529 | parser.add_argument( |
| 530 | "--diff", |
| 531 | nargs=2, |
| 532 | metavar=("FROM", "TO"), |
| 533 | default=None, |
| 534 | help="Generate a changelog between FROM and TO (tags or commit IDs).", |
| 535 | ) |
| 536 | parser.add_argument( |
| 537 | "--history", |
| 538 | default=None, |
| 539 | metavar="ADDR", |
| 540 | help="Show the full version history timeline for one symbol address.", |
| 541 | ) |
| 542 | parser.add_argument( |
| 543 | "--at", |
| 544 | default=None, |
| 545 | metavar="COMMIT", |
| 546 | dest="at_commit", |
| 547 | help="Document the repository as of COMMIT (HEAD notation, SHA, or tag).", |
| 548 | ) |
| 549 | parser.add_argument( |
| 550 | "--ci", |
| 551 | action="store_true", |
| 552 | help="Run the documentation quality gate from .muse/docs.toml.", |
| 553 | ) |
| 554 | parser.add_argument( |
| 555 | "--json", "-j", |
| 556 | action="store_true", |
| 557 | dest="json_out", |
| 558 | help="Shortcut for --format json.", |
| 559 | ) |
| 560 | |
| 561 | parser.set_defaults(func=run) |
| 562 | |
| 563 | |
| 564 | # --------------------------------------------------------------------------- |
| 565 | # Command handler |
| 566 | # --------------------------------------------------------------------------- |
| 567 | |
| 568 | |
| 569 | def _to_render_format(raw: str) -> RenderFormat: |
| 570 | """Convert an argparse format string to a :class:`RenderFormat` literal. |
| 571 | |
| 572 | ``"md"`` is accepted as an alias for ``"markdown"``. Unknown values fall |
| 573 | back to ``"text"`` with a warning. |
| 574 | """ |
| 575 | if raw == "md" or raw == "markdown": |
| 576 | return "markdown" |
| 577 | if raw == "json": |
| 578 | return "json" |
| 579 | if raw == "html": |
| 580 | return "html" |
| 581 | if raw == "text": |
| 582 | return "text" |
| 583 | logger.warning("⚠️ Unknown format %r — falling back to text", raw) |
| 584 | return "text" |
| 585 | |
| 586 | |
| 587 | def run(args: argparse.Namespace) -> None: |
| 588 | """Extract, render, and gate documentation for a repository. |
| 589 | |
| 590 | Operates in four modes: default renders all symbol documentation; |
| 591 | ``--history ADDR`` shows version history for one symbol; ``--diff FROM TO`` |
| 592 | generates a changelog between two refs; ``--ci`` runs the documentation |
| 593 | quality gate defined in ``.muse/docs.toml``. |
| 594 | |
| 595 | Agent quickstart |
| 596 | ---------------- |
| 597 | :: |
| 598 | |
| 599 | muse docs --json |
| 600 | muse docs --history "src/billing.py::compute_total" --json |
| 601 | muse docs --diff v1.0.0 v2.0.0 --json |
| 602 | muse docs --ci --json |
| 603 | |
| 604 | JSON fields (default mode) |
| 605 | -------------------------- |
| 606 | symbols List of symbol documentation objects: ``address``, ``kind``, |
| 607 | ``docstring``, ``signature``. |
| 608 | |
| 609 | JSON fields (``--history`` mode) |
| 610 | ---------------------------------- |
| 611 | address Symbol address queried. |
| 612 | events List of version events: ``commit_id``, ``committed_at``, ``op``, |
| 613 | ``version``, ``sem_ver_bump``, ``breaking``. |
| 614 | |
| 615 | JSON fields (``--diff`` mode) |
| 616 | ------------------------------ |
| 617 | from_ref Start ref. |
| 618 | to_ref End ref. |
| 619 | added List of added symbol addresses. |
| 620 | removed List of removed symbol addresses. |
| 621 | |
| 622 | Exit codes |
| 623 | ---------- |
| 624 | 0 Success. |
| 625 | 1 ``--ci`` gate failures; or invalid arguments. |
| 626 | 2 Not inside a Muse repository. |
| 627 | """ |
| 628 | elapsed = start_timer() |
| 629 | root = require_repo() |
| 630 | repo_id = read_repo_id(root) |
| 631 | |
| 632 | raw_fmt: str = "json" if args.json_out else args.fmt |
| 633 | # "md" is an argparse alias — convert to the canonical renderer name. |
| 634 | fmt = _to_render_format(raw_fmt) |
| 635 | |
| 636 | # ------------------------------------------------------------------ |
| 637 | # Mode: --history ADDR |
| 638 | # ------------------------------------------------------------------ |
| 639 | if args.history: |
| 640 | events = get_symbol_version_events(root, repo_id, args.history) |
| 641 | if args.json_out: |
| 642 | print(json.dumps(_SymbolHistoryJson( |
| 643 | **make_envelope(elapsed), |
| 644 | address=args.history, |
| 645 | events=[ |
| 646 | _HistoryEventJson( |
| 647 | commit_id=ev["commit_id"], |
| 648 | committed_at=ev["committed_at"], |
| 649 | op=ev["op"], |
| 650 | version=ev["version"], |
| 651 | sem_ver_bump=ev["sem_ver_bump"], |
| 652 | breaking=ev["breaking"], |
| 653 | ) |
| 654 | for ev in events |
| 655 | ], |
| 656 | ))) |
| 657 | else: |
| 658 | _print_history(args.history, events) |
| 659 | return |
| 660 | |
| 661 | # ------------------------------------------------------------------ |
| 662 | # Mode: --diff FROM TO |
| 663 | # ------------------------------------------------------------------ |
| 664 | if args.diff: |
| 665 | from_ref, to_ref = args.diff |
| 666 | changelog = generate_changelog(root, repo_id, from_ref, to_ref) |
| 667 | if args.json_out: |
| 668 | print(json.dumps(_ChangelogJson( |
| 669 | **make_envelope(elapsed), |
| 670 | from_ref=changelog["from_ref"], |
| 671 | to_ref=changelog["to_ref"], |
| 672 | added=[e["address"] for e in changelog["added"]], |
| 673 | removed=[e["address"] for e in changelog["removed"]], |
| 674 | changed=[e["address"] for e in changelog["changed"]], |
| 675 | breaking=[e["address"] for e in changelog["breaking"]], |
| 676 | ))) |
| 677 | else: |
| 678 | _print_changelog(changelog) |
| 679 | return |
| 680 | |
| 681 | # ------------------------------------------------------------------ |
| 682 | # Normal documentation mode |
| 683 | # ------------------------------------------------------------------ |
| 684 | all_targets: list[str] = list(args.targets) + list(args.symbols) |
| 685 | |
| 686 | at_commit: str | None = args.at_commit |
| 687 | if at_commit is not None: |
| 688 | try: |
| 689 | branch = read_current_branch(root) |
| 690 | except ValueError: |
| 691 | branch = "main" |
| 692 | resolved = resolve_commit_ref(root, repo_id, branch, at_commit) |
| 693 | at_commit = resolved.commit_id if resolved else None |
| 694 | |
| 695 | report = extract_docs( |
| 696 | root=root, |
| 697 | repo_id=repo_id, |
| 698 | targets=all_targets if all_targets else None, |
| 699 | commit_id=at_commit, |
| 700 | max_depth=args.depth, |
| 701 | ) |
| 702 | |
| 703 | # Apply filters AFTER extraction. |
| 704 | if args.missing: |
| 705 | # Keep only symbols without docstrings. |
| 706 | report = DocReport( |
| 707 | commit_id=report["commit_id"], |
| 708 | generated_at=report["generated_at"], |
| 709 | symbols=[d for d in report["symbols"] if d["docstring"] is None], |
| 710 | missing=report["missing"], |
| 711 | stale=report["stale"], |
| 712 | summary=report["summary"], |
| 713 | ) |
| 714 | elif args.stale: |
| 715 | report = DocReport( |
| 716 | commit_id=report["commit_id"], |
| 717 | generated_at=report["generated_at"], |
| 718 | symbols=[d for d in report["symbols"] if "stale_impl" in d["doc_health_reasons"]], |
| 719 | missing=report["missing"], |
| 720 | stale=report["stale"], |
| 721 | summary=report["summary"], |
| 722 | ) |
| 723 | elif args.min_health is not None: |
| 724 | report = DocReport( |
| 725 | commit_id=report["commit_id"], |
| 726 | generated_at=report["generated_at"], |
| 727 | symbols=[d for d in report["symbols"] if d["doc_health"] < args.min_health], |
| 728 | missing=report["missing"], |
| 729 | stale=report["stale"], |
| 730 | summary=report["summary"], |
| 731 | ) |
| 732 | |
| 733 | # ------------------------------------------------------------------ |
| 734 | # Mode: --ci |
| 735 | # ------------------------------------------------------------------ |
| 736 | if args.ci: |
| 737 | config = _load_doc_ci_config(root) |
| 738 | ci_result = _run_doc_ci(report, config) |
| 739 | if args.json_out: |
| 740 | print(json.dumps(_DocCiJson( |
| 741 | **make_envelope(elapsed), |
| 742 | passed=ci_result["passed"], |
| 743 | gates=[ |
| 744 | _DocCiGateJson( |
| 745 | name=g["name"], |
| 746 | passed=g["passed"], |
| 747 | message=g["message"], |
| 748 | ) |
| 749 | for g in ci_result["gates"] |
| 750 | ], |
| 751 | summary=ci_result["summary"], |
| 752 | ))) |
| 753 | else: |
| 754 | _print_ci_result(ci_result) |
| 755 | sys.exit(0 if ci_result["passed"] else 1) |
| 756 | |
| 757 | # ------------------------------------------------------------------ |
| 758 | # Render and write/print output |
| 759 | # ------------------------------------------------------------------ |
| 760 | output = render(report, fmt) |
| 761 | |
| 762 | output_path = validate_output_path(args.output, root) if args.output else None |
| 763 | |
| 764 | if output_path is not None: |
| 765 | if fmt == "html": |
| 766 | output_path.mkdir(parents=True, exist_ok=True) |
| 767 | target_file = output_path / "index.html" |
| 768 | else: |
| 769 | output_path.parent.mkdir(parents=True, exist_ok=True) |
| 770 | target_file = output_path |
| 771 | target_file.write_text(output, encoding="utf-8") |
| 772 | logger.info("✅ Documentation written to %s", target_file) |
| 773 | print(f"Wrote {len(report['symbols'])} symbol(s) to {sanitize_display(str(target_file))}") |
| 774 | else: |
| 775 | print(output) |
| 776 | |
| 777 | |
| 778 | def _print_changelog(changelog: ChangelogReport) -> None: |
| 779 | """Print a :class:`ChangelogReport` in human-readable format.""" |
| 780 | print(f"Changelog: {changelog['from_ref']} → {changelog['to_ref']}") |
| 781 | print() |
| 782 | _print_section("Added", changelog["added"], "✅") |
| 783 | _print_section("Removed", changelog["removed"], "🗑️") |
| 784 | _print_section("Changed", changelog["changed"], "✏️") |
| 785 | _print_section("Breaking", changelog["breaking"], "⚠️") |
| 786 | |
| 787 | |
| 788 | def _print_section( |
| 789 | title: str, |
| 790 | entries: list[ChangelogEntry], |
| 791 | icon: str, |
| 792 | ) -> None: |
| 793 | if not entries: |
| 794 | return |
| 795 | print(f"{icon} {title} ({len(entries)}):") |
| 796 | for e in entries: |
| 797 | print(f" {e['address']}") |
| 798 | print() |
File History
3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9
docs: expand cache plan with all seven testing tiers and do…
Sonnet 4.6
132 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c
docs: docstring sprint for-each-ref→hotspots — idiomatic ru…
Sonnet 4.6
patch
138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9
fix(cursorignore): remove git-ism (.git/worktrees)
Human
minor
⚠
141 days ago