gabriel / muse public
docs_cmd.py python
798 lines 25.4 KB
Raw
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