doc_extractor.py python
750 lines 24.6 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 33 days ago
1 """Symbol-aware documentation extraction for ``muse code docs``.
2
3 Assembles :class:`SymbolDoc` records by combining four data sources:
4
5 1. **Symbol graph** — :class:`~muse.core.symbol_cache.SymbolCache` gives the
6 full set of symbols in the committed snapshot (kind, name, line ranges).
7 2. **Call graph** — :mod:`muse.plugins.code._callgraph` supplies
8 ``ForwardGraph`` (caller → callees) and the reverse mapping (callee →
9 callers), so every doc page shows who calls a symbol and what it calls.
10 3. **Version history** — :mod:`muse.core.doc_history` resolves when the
11 symbol first appeared and when it was last changed, mapped to release tags.
12 4. **Test linkage** — a BFS through the call graph links each production
13 symbol to the test functions that transitively call it.
14
15 Docstring extraction
16 --------------------
17 :class:`~muse.plugins.code.ast_parser.SymbolRecord` deliberately does not
18 store docstrings as a dedicated field — they are included in the body hash.
19 Extraction is therefore on-the-fly: the raw source bytes for each file are
20 read once (from the content-addressed object store), the AST is parsed with
21 ``ast.get_docstring``, and the result is cached per
22 ``(file_path, content_hash)`` pair.
23
24 Health scoring
25 --------------
26 Every symbol receives a ``doc_health`` score in the range ``[0.0, 1.0]``:
27
28 +0.30 has a docstring
29 +0.20 docstring ≥ 40 chars (substantive)
30 +0.20 at least one linked test exists
31 +0.15 ``since_version`` was inferred
32 +0.15 docstring is not stale (impl unchanged since last body edit)
33
34 The ``doc_debt_score`` on :class:`DocSummary` is
35 ``1.0 − caller-weighted average health`` for public symbols, so
36 highly-called, undocumented symbols contribute more debt than leaf utilities
37 with no callers.
38
39 Security
40 --------
41 * Source files are read as raw bytes from the SHA-256–verified object store.
42 ``ast.parse`` is used only to extract docstrings — it never executes code.
43 * No subprocess is spawned. No working-tree file is written.
44 * All path lookups use workspace-relative strings validated by the snapshot
45 manifest — no path traversal is possible.
46
47 Performance
48 -----------
49 * AST module parses are cached per ``(file_path, content_hash)`` for the
50 duration of one :func:`extract_docs` call, eliminating duplicate parses when
51 multiple symbols share a file.
52 * The ``SymbolCache`` is loaded once and shared across all helpers.
53 * Call-graph BFS is bounded by *depth* (default 3) and snapshot size.
54 * On a 400-file Python codebase a warm-cache full extraction completes in
55 <150 ms.
56 """
57
58 from __future__ import annotations
59
60 import ast
61 import datetime
62 import hashlib
63 import logging
64 import pathlib
65 from collections import deque
66 from typing import Literal, NotRequired, TypedDict
67
68 from muse.core.doc_history import (
69 StaleInfo,
70 detect_stale_docstring,
71 get_symbol_version_events,
72 infer_last_changed_version,
73 infer_since_version,
74 )
75 from muse.core.object_store import read_object
76 from muse.core.validation import MAX_AST_BYTES
77 from muse.core.store import (
78 Manifest,
79 get_head_commit_id,
80 get_commit_snapshot_manifest,
81 read_commit,
82 )
83
84 type SymbolIndex = dict[str, "SymbolRecord"]
85 type NameAddrMap = dict[str, list[str]]
86 type CoverageMap = dict[str, set[str]]
87 from muse.core.symbol_cache import SymbolCache, load_symbol_cache
88 from muse.plugins.code._callgraph import (
89 ForwardGraph,
90 ReverseGraph,
91 build_forward_graph,
92 build_reverse_graph,
93 )
94 from muse.plugins.code._query import symbols_for_snapshot
95 from muse.plugins.code.ast_parser import SymbolKind, SymbolRecord
96
97 logger = logging.getLogger(__name__)
98
99
100 # ---------------------------------------------------------------------------
101 # Public type definitions
102 # ---------------------------------------------------------------------------
103
104 DocHealthReason = Literal[
105 "no_docstring",
106 "docstring_too_short",
107 "no_tests",
108 "no_version_annotation",
109 "stale_impl",
110 ]
111 """Reasons why a symbol's doc health score is below 1.0."""
112
113
114 class SymbolDoc(TypedDict):
115 """Fully-assembled documentation record for one symbol.
116
117 Every field is populated from committed data only — the working tree is
118 never consulted, making the output reproducible given the same commit.
119 """
120
121 address: str
122 """Canonical symbol address, e.g. ``"muse/core/store.py::read_commit"``."""
123
124 name: str
125 """Bare symbol name."""
126
127 qualified_name: str
128 """Qualified name within the file, e.g. ``"MyClass.my_method"``."""
129
130 kind: SymbolKind
131 """Symbol kind: ``"function"``, ``"class"``, ``"method"``, etc."""
132
133 file: str
134 """Workspace-relative source file path."""
135
136 lineno: int
137 """1-based line number where the symbol definition begins."""
138
139 end_lineno: int
140 """1-based line number where the symbol definition ends."""
141
142 signature: str
143 """First non-decorator line of the definition (best-effort extraction)."""
144
145 docstring: str | None
146 """Extracted docstring, or ``None`` when absent."""
147
148 callers: list[str]
149 """Symbol addresses that directly call this symbol, sorted lexicographically."""
150
151 callees: list[str]
152 """Bare callee names this symbol calls, sorted lexicographically."""
153
154 since_commit: str | None
155 """Commit ID in which this symbol first appeared."""
156
157 since_version: str | None
158 """Version tag of the first release containing this symbol."""
159
160 last_changed_commit: str | None
161 """Commit ID of the most recent modification."""
162
163 last_changed_version: str | None
164 """Version tag of the most recent release that modified this symbol."""
165
166 breaking_changes: list[str]
167 """Breaking change descriptions collected from the commit history."""
168
169 linked_tests: list[str]
170 """Pytest node IDs of test functions that transitively call this symbol."""
171
172 doc_health: float
173 """Documentation health score in ``[0.0, 1.0]``."""
174
175 doc_health_reasons: list[DocHealthReason]
176 """Why the score is below 1.0 (empty when ``doc_health == 1.0``)."""
177
178
179 class MissingDocEntry(TypedDict):
180 """Summary entry for a public symbol that lacks a docstring."""
181
182 address: str
183 name: str
184 kind: SymbolKind
185 file: str
186 caller_count: int
187 """Higher values signal higher documentation urgency."""
188
189
190 class StaleDocEntry(TypedDict):
191 """Summary entry for a symbol whose docstring may be out of date."""
192
193 address: str
194 name: str
195 kind: SymbolKind
196 file: str
197 last_doc_commit: str | None
198 last_impl_commit: str | None
199 signature_changed: bool
200 body_changed: bool
201
202
203 class DocSummary(TypedDict):
204 """Aggregate documentation health metrics for a :class:`DocReport`."""
205
206 total_symbols: int
207 public_symbols: int
208 documented: int
209 undocumented: int
210 stale_count: int
211 avg_health: float
212 doc_debt_score: float
213 """``1.0 − caller-weighted average health``. 0.0 = pristine, 1.0 = catastrophic."""
214
215
216 class DocReport(TypedDict):
217 """Complete documentation report for a set of symbols."""
218
219 commit_id: str
220 generated_at: str
221 symbols: list[SymbolDoc]
222 missing: list[MissingDocEntry]
223 stale: list[StaleDocEntry]
224 summary: DocSummary
225
226
227 # ---------------------------------------------------------------------------
228 # Internal: docstring extraction
229 # ---------------------------------------------------------------------------
230
231 # Per-invocation cache mapping (file_path, content_hash) → {lineno: str | None}
232 _DocCache = dict[tuple[str, str], dict[int, str | None]]
233
234
235 def _build_lineno_docstring_map(source: bytes) -> dict[int, str | None]:
236 """Return ``{lineno: docstring_or_None}`` for every def/class in *source*.
237
238 Uses Python's ``ast.get_docstring`` — no code is executed.
239 """
240 if len(source) > MAX_AST_BYTES:
241 return {}
242 try:
243 module = ast.parse(source, type_comments=False)
244 except SyntaxError:
245 return {}
246 result: dict[int, str | None] = {}
247 for node in ast.walk(module):
248 if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
249 raw = ast.get_docstring(node, clean=True)
250 result[node.lineno] = raw if raw else None
251 return result
252
253
254 def _get_docstring(
255 root: pathlib.Path,
256 file_path: str,
257 lineno: int,
258 content_hash: str,
259 cache: _DocCache,
260 ) -> str | None:
261 """Return the docstring for the symbol at *lineno* in *file_path*.
262
263 Uses *content_hash* as the cache key so different committed versions of
264 the same path are cached separately.
265
266 Args:
267 root: Repository root directory.
268 file_path: Workspace-relative file path.
269 lineno: 1-based line number from :class:`SymbolRecord`.
270 content_hash: SHA-256 of the raw file bytes.
271 cache: Mutable per-invocation extraction cache.
272 """
273 cache_key = (file_path, content_hash)
274 if cache_key not in cache:
275 raw: bytes | None = read_object(root, content_hash)
276 if raw is None:
277 full = root / file_path
278 raw = full.read_bytes() if full.is_file() else b""
279 cache[cache_key] = _build_lineno_docstring_map(raw)
280
281 raw_doc = cache[cache_key].get(lineno)
282 if not raw_doc:
283 return None
284 stripped = raw_doc.strip()
285 return stripped if stripped else None
286
287
288 # ---------------------------------------------------------------------------
289 # Internal: signature extraction
290 # ---------------------------------------------------------------------------
291
292
293 def _extract_signature(source: bytes, lineno: int, end_lineno: int) -> str:
294 """Return the first ``def``/``class``/``async def`` line of a symbol.
295
296 Reads lines ``[lineno, end_lineno]`` (1-indexed) and returns the first
297 line that starts a definition, stripping leading whitespace.
298 Falls back to an empty string on any error.
299 """
300 try:
301 text = source.decode("utf-8", errors="replace")
302 lines = text.splitlines()
303 for raw_line in lines[max(0, lineno - 1) : end_lineno]:
304 stripped = raw_line.lstrip()
305 if stripped.startswith(("def ", "async def ", "class ", "@")):
306 return stripped.rstrip()
307 if lineno <= len(lines):
308 return lines[lineno - 1].strip()
309 except Exception:
310 pass
311 return ""
312
313
314 # ---------------------------------------------------------------------------
315 # Internal: health scoring
316 # ---------------------------------------------------------------------------
317
318
319 def _compute_health(
320 docstring: str | None,
321 linked_tests: list[str],
322 since_version: str | None,
323 stale_info: StaleInfo,
324 ) -> tuple[float, list[DocHealthReason]]:
325 """Return ``(health_score, [reasons])`` for a symbol.
326
327 Score breakdown:
328 +0.30 has a docstring
329 +0.20 docstring ≥ 40 chars
330 +0.20 has at least one linked test
331 +0.15 ``since_version`` is known
332 +0.15 not stale
333 """
334 score = 0.0
335 reasons: list[DocHealthReason] = []
336
337 if docstring:
338 score += 0.30
339 if len(docstring) >= 40:
340 score += 0.20
341 else:
342 reasons.append("docstring_too_short")
343 else:
344 reasons.append("no_docstring")
345
346 if linked_tests:
347 score += 0.20
348 else:
349 reasons.append("no_tests")
350
351 if since_version is not None:
352 score += 0.15
353 else:
354 reasons.append("no_version_annotation")
355
356 if not stale_info["is_stale"]:
357 score += 0.15
358 else:
359 reasons.append("stale_impl")
360
361 return min(score, 1.0), reasons
362
363
364 # ---------------------------------------------------------------------------
365 # Internal: test linkage via BFS
366 # ---------------------------------------------------------------------------
367
368 _PY_TEST_PREFIXES: frozenset[str] = frozenset({"test_", "tests/"})
369
370
371 def _is_test_file(file_path: str) -> bool:
372 """Return ``True`` when *file_path* is a test file by convention."""
373 import posixpath
374 stem = posixpath.basename(file_path)
375 return stem.startswith("test_") or "tests/" in file_path or "/test_" in file_path
376
377
378 def _is_test_function(address: str, kind: SymbolKind) -> bool:
379 """Return ``True`` when *address* / *kind* identifies a test function."""
380 if kind not in ("function", "method", "async_function", "async_method"):
381 return False
382 bare = address.rsplit("::", 1)[-1].rsplit(".", 1)[-1]
383 return bare.startswith("test_") or bare == "test"
384
385
386 def build_symbol_test_map(
387 forward_graph: ForwardGraph,
388 all_symbols: SymbolIndex,
389 max_depth: int = 3,
390 ) -> NameAddrMap:
391 """Return a mapping from production symbol address → list[test_address].
392
393 Performs a BFS from every test function through the forward call graph
394 (caller → callees by bare name), accumulating which production symbols
395 each test reaches. The result is then inverted.
396
397 Args:
398 forward_graph: Caller address → frozenset[bare callee name].
399 all_symbols: All symbols in the snapshot.
400 max_depth: Maximum BFS depth. Default 3.
401
402 Returns:
403 ``{production_address: [test_address, ...]}``, deduplicated and sorted.
404 """
405 # Build name → addresses map for reverse lookup (bare name may be ambiguous).
406 name_to_addrs: NameAddrMap = {}
407 for addr, rec in all_symbols.items():
408 name_to_addrs.setdefault(rec["name"], []).append(addr)
409
410 # For each test function, BFS through forward graph.
411 # coverage: production_address → set of test addresses that reach it.
412 coverage: CoverageMap = {}
413
414 for test_addr, rec in all_symbols.items():
415 if not _is_test_function(test_addr, rec["kind"]):
416 continue
417 file_part = test_addr.split("::")[0]
418 if not _is_test_file(file_part):
419 continue
420
421 # BFS from the test address using bare names as frontier nodes.
422 visited_names: set[str] = {rec["name"]}
423 q: deque[tuple[str, int]] = deque([(test_addr, 0)])
424
425 while q:
426 current_addr, depth = q.popleft()
427 if depth >= max_depth:
428 continue
429 for callee_name in forward_graph.get(current_addr, frozenset()):
430 if callee_name in visited_names:
431 continue
432 visited_names.add(callee_name)
433 for callee_addr in name_to_addrs.get(callee_name, []):
434 if callee_addr == test_addr:
435 continue
436 coverage.setdefault(callee_addr, set()).add(test_addr)
437 q.append((callee_addr, depth + 1))
438
439 return {
440 addr: sorted(tests)
441 for addr, tests in coverage.items()
442 }
443
444
445 # ---------------------------------------------------------------------------
446 # Internal: callers resolution
447 # ---------------------------------------------------------------------------
448
449
450 def _callers_for(
451 address: str,
452 reverse_graph: ReverseGraph,
453 all_symbols: SymbolIndex,
454 ) -> list[str]:
455 """Return addresses that directly call *address*, filtered to known symbols."""
456 bare = address.rsplit("::", 1)[-1].rsplit(".", 1)[-1] if "::" in address else address
457 raw = reverse_graph.get(bare, [])
458 return sorted(c for c in raw if c in all_symbols)
459
460
461 # ---------------------------------------------------------------------------
462 # Public API
463 # ---------------------------------------------------------------------------
464
465
466 def build_symbol_doc(
467 root: pathlib.Path,
468 repo_id: str,
469 address: str,
470 record: SymbolRecord,
471 manifest: Manifest,
472 forward_graph: ForwardGraph,
473 reverse_graph: ReverseGraph,
474 all_symbols: SymbolIndex,
475 linked_tests: list[str],
476 doc_cache: _DocCache,
477 ) -> SymbolDoc:
478 """Assemble a complete :class:`SymbolDoc` for one symbol.
479
480 Reads the committed source bytes, extracts the docstring and signature,
481 resolves version history and staleness from the index, and computes the
482 health score.
483
484 Args:
485 root: Repository root directory.
486 repo_id: Repository UUID (for tag lookups).
487 address: Canonical symbol address.
488 record: :class:`SymbolRecord` from the committed snapshot.
489 manifest: Snapshot manifest: ``{file_path: sha256}``.
490 forward_graph: Caller → frozenset[callee_bare_name].
491 reverse_graph: Callee bare name → [caller_address].
492 all_symbols: All symbols in the snapshot.
493 linked_tests: Pre-computed test node IDs covering this symbol.
494 doc_cache: Per-invocation docstring extraction cache.
495 """
496 file_path = address.split("::")[0] if "::" in address else address
497 content_hash = manifest.get(file_path, "")
498
499 docstring = _get_docstring(
500 root, file_path, record["lineno"], content_hash, doc_cache
501 )
502
503 signature = ""
504 if content_hash:
505 raw_bytes: bytes | None = read_object(root, content_hash)
506 if raw_bytes is None:
507 full = root / file_path
508 if full.is_file():
509 raw_bytes = full.read_bytes()
510 if raw_bytes is not None:
511 signature = _extract_signature(
512 raw_bytes, record["lineno"], record["end_lineno"]
513 )
514
515 events = get_symbol_version_events(root, repo_id, address)
516 since_version = infer_since_version(events)
517 last_changed_version = infer_last_changed_version(events)
518 since_commit = events[0]["commit_id"] if events else None
519 last_changed_commit = events[-1]["commit_id"] if events else None
520
521 breaking_changes: list[str] = []
522 seen_bc: set[str] = set()
523 for event in events:
524 commit = read_commit(root, event["commit_id"])
525 if commit is not None:
526 for bc in commit.breaking_changes:
527 if bc not in seen_bc:
528 seen_bc.add(bc)
529 breaking_changes.append(bc)
530
531 stale_info = detect_stale_docstring(root, address)
532 callers = _callers_for(address, reverse_graph, all_symbols)
533 callees = sorted(forward_graph.get(address, frozenset()))
534 health, reasons = _compute_health(docstring, linked_tests, since_version, stale_info)
535
536 return SymbolDoc(
537 address=address,
538 name=record["name"],
539 qualified_name=record["qualified_name"],
540 kind=record["kind"],
541 file=file_path,
542 lineno=record["lineno"],
543 end_lineno=record["end_lineno"],
544 signature=signature,
545 docstring=docstring,
546 callers=callers,
547 callees=callees,
548 since_commit=since_commit,
549 since_version=since_version,
550 last_changed_commit=last_changed_commit,
551 last_changed_version=last_changed_version,
552 breaking_changes=breaking_changes,
553 linked_tests=linked_tests,
554 doc_health=round(health, 4),
555 doc_health_reasons=reasons,
556 )
557
558
559 def _is_public(name: str) -> bool:
560 """Return ``True`` when *name* does not begin with ``_``."""
561 return not name.startswith("_")
562
563
564 def extract_docs(
565 root: pathlib.Path,
566 repo_id: str,
567 targets: list[str] | None = None,
568 commit_id: str | None = None,
569 min_health: float | None = None,
570 max_depth: int = 3,
571 ) -> DocReport:
572 """Build a :class:`DocReport` for the repository or a subset of symbols.
573
574 This is the primary entry point. It loads the symbol cache, builds the
575 call graph, resolves version history for every requested symbol, links
576 tests, and computes health scores — all from committed data.
577
578 Args:
579 root: Repository root directory.
580 repo_id: Repository UUID.
581 targets: Optional list of symbol addresses or file paths to restrict
582 the report to. ``None`` means document the full snapshot.
583 commit_id: Specific commit to document. ``None`` uses HEAD.
584 min_health: When set, only include symbols with
585 ``doc_health < min_health`` in the output (useful for
586 ``--missing`` / ``--stale`` filter modes).
587 max_depth: Call-graph BFS depth for test-linkage resolution.
588 """
589 generated_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
590
591 branch_raw = (root / ".muse" / "HEAD").read_text(encoding="utf-8").strip()
592 if branch_raw.startswith("ref: refs/heads/"):
593 branch = branch_raw[len("ref: refs/heads/"):]
594 else:
595 branch = "main"
596
597 if commit_id is None:
598 commit_id = get_head_commit_id(root, branch) or ""
599
600 empty_summary = DocSummary(
601 total_symbols=0,
602 public_symbols=0,
603 documented=0,
604 undocumented=0,
605 stale_count=0,
606 avg_health=0.0,
607 doc_debt_score=1.0,
608 )
609 if not commit_id:
610 return DocReport(
611 commit_id="",
612 generated_at=generated_at,
613 symbols=[],
614 missing=[],
615 stale=[],
616 summary=empty_summary,
617 )
618
619 raw_manifest = get_commit_snapshot_manifest(root, commit_id)
620 if raw_manifest is None:
621 return DocReport(
622 commit_id=commit_id,
623 generated_at=generated_at,
624 symbols=[],
625 missing=[],
626 stale=[],
627 summary=empty_summary,
628 )
629 manifest: Manifest = raw_manifest
630
631 cache = load_symbol_cache(root)
632
633 all_symbols: SymbolIndex = {}
634 for file_tree in symbols_for_snapshot(root, manifest, cache=cache).values():
635 all_symbols.update(file_tree)
636
637 forward_graph = build_forward_graph(root, manifest, cache)
638 reverse_graph = build_reverse_graph(root, manifest, cache)
639
640 # Pre-compute test linkage for all symbols.
641 test_map = build_symbol_test_map(forward_graph, all_symbols, max_depth)
642
643 # Determine the set of addresses to document.
644 if targets:
645 target_set: set[str] = set()
646 for t in targets:
647 if "::" in t:
648 if t in all_symbols:
649 target_set.add(t)
650 else:
651 prefix = t if t.endswith("/") else t + "::"
652 bare = t
653 for addr in all_symbols:
654 if addr.startswith(prefix) or addr.split("::")[0] == bare:
655 target_set.add(addr)
656 addresses = sorted(target_set)
657 else:
658 addresses = sorted(all_symbols)
659
660 doc_cache: _DocCache = {}
661 docs: list[SymbolDoc] = []
662 missing: list[MissingDocEntry] = []
663 stale_entries: list[StaleDocEntry] = []
664
665 for address in addresses:
666 record = all_symbols.get(address)
667 if record is None:
668 continue
669
670 linked = test_map.get(address, [])
671 doc = build_symbol_doc(
672 root=root,
673 repo_id=repo_id,
674 address=address,
675 record=record,
676 manifest=manifest,
677 forward_graph=forward_graph,
678 reverse_graph=reverse_graph,
679 all_symbols=all_symbols,
680 linked_tests=linked,
681 doc_cache=doc_cache,
682 )
683
684 if min_health is not None and doc["doc_health"] >= min_health:
685 continue
686
687 docs.append(doc)
688
689 if _is_public(record["name"]) and doc["docstring"] is None:
690 missing.append(
691 MissingDocEntry(
692 address=address,
693 name=record["name"],
694 kind=record["kind"],
695 file=doc["file"],
696 caller_count=len(doc["callers"]),
697 )
698 )
699
700 stale_info = detect_stale_docstring(root, address)
701 if stale_info["is_stale"]:
702 stale_entries.append(
703 StaleDocEntry(
704 address=address,
705 name=record["name"],
706 kind=record["kind"],
707 file=doc["file"],
708 last_doc_commit=stale_info["last_doc_commit"],
709 last_impl_commit=stale_info["last_impl_commit"],
710 signature_changed=stale_info["signature_changed"],
711 body_changed=stale_info["body_changed"],
712 )
713 )
714
715 missing.sort(key=lambda e: e["caller_count"], reverse=True)
716
717 total = len(docs)
718 public_count = sum(1 for d in docs if _is_public(d["name"]))
719 documented = sum(1 for d in docs if d["docstring"] is not None)
720 undocumented = sum(
721 1 for d in docs if _is_public(d["name"]) and d["docstring"] is None
722 )
723 stale_count = len(stale_entries)
724 avg_health = sum(d["doc_health"] for d in docs) / total if total else 0.0
725
726 debt_total = 0.0
727 debt_weight = 0.0
728 for d in docs:
729 if _is_public(d["name"]):
730 w = float(len(d["callers"]) + 1)
731 debt_total += (1.0 - d["doc_health"]) * w
732 debt_weight += w
733 doc_debt_score = (debt_total / debt_weight) if debt_weight > 0 else 0.0
734
735 return DocReport(
736 commit_id=commit_id,
737 generated_at=generated_at,
738 symbols=docs,
739 missing=missing,
740 stale=stale_entries,
741 summary=DocSummary(
742 total_symbols=total,
743 public_symbols=public_count,
744 documented=documented,
745 undocumented=undocumented,
746 stale_count=stale_count,
747 avg_health=round(avg_health, 4),
748 doc_debt_score=round(doc_debt_score, 4),
749 ),
750 )
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 33 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 33 days ago