docs_cmd.py
python
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