diff --git a/src/openamp_foundry/checks/docs_coverage.py b/src/openamp_foundry/checks/docs_coverage.py new file mode 100644 index 00000000..e0f09e9e --- /dev/null +++ b/src/openamp_foundry/checks/docs_coverage.py @@ -0,0 +1,169 @@ +""" +Docs coverage report for Python modules. + +J3: Shows what is and isn't documented. Scans Python modules in a source +directory and reports which ones have module-level docstrings. +A module without a docstring is an undocumented module — a gap in the +knowledge base that agents and reviewers may trip over. +""" + +from __future__ import annotations + +import ast +import os +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass +class DocsCoverageEntry: + """Coverage entry for a single Python module.""" + + module_path: str + module_name: str + has_module_docstring: bool + docstring_preview: str + + +@dataclass +class DocsCoverageReport: + """Aggregate docs coverage report for a source directory.""" + + total_modules: int + covered_modules: int + uncovered_modules: int + coverage_fraction: float + entries: list[DocsCoverageEntry] + is_fully_covered: bool + coverage_summary: str + + +def _extract_module_name(module_path: Path, source_dir: Path) -> str: + """ + Convert an absolute module path to a dotted module name relative to source_dir. + + e.g. /path/to/src/openamp_foundry/evidence/foo.py + -> openamp_foundry.evidence.foo + """ + try: + relative = module_path.relative_to(source_dir) + except ValueError: + return module_path.stem + parts = list(relative.parts) + if parts and parts[-1].endswith(".py"): + parts[-1] = parts[-1][:-3] + if parts and parts[-1] == "__init__": + parts = parts[:-1] + return ".".join(parts) if parts else module_path.stem + + +def _has_module_docstring(source_path: Path) -> tuple[bool, str]: + """ + Return (has_docstring, preview) by parsing the module's AST. + + Returns (False, "") if the file cannot be parsed. + """ + try: + source = source_path.read_text(encoding="utf-8", errors="replace") + tree = ast.parse(source, filename=str(source_path)) + except SyntaxError: + return False, "" + + if ( + tree.body + and isinstance(tree.body[0], ast.Expr) + and isinstance(tree.body[0].value, ast.Constant) + and isinstance(tree.body[0].value.value, str) + ): + docstring = tree.body[0].value.value.strip() + preview = docstring[:80] + ("..." if len(docstring) > 80 else "") + return True, preview + + return False, "" + + +def check_docs_coverage( + source_dir: str | Path, + glob_pattern: str = "**/*.py", + exclude_init: bool = False, +) -> DocsCoverageReport: + """ + Scan all Python modules in source_dir and report module-level docstring coverage. + + Args: + source_dir: Root directory to scan. + glob_pattern: Glob pattern for Python files (default: all .py files). + exclude_init: If True, skip __init__.py files (they rarely have docstrings). + """ + source_dir = Path(source_dir) + entries: list[DocsCoverageEntry] = [] + + for py_file in sorted(source_dir.glob(glob_pattern)): + if not py_file.is_file(): + continue + if exclude_init and py_file.name == "__init__.py": + continue + + module_name = _extract_module_name(py_file, source_dir) + has_docstring, preview = _has_module_docstring(py_file) + + entries.append( + DocsCoverageEntry( + module_path=str(py_file), + module_name=module_name, + has_module_docstring=has_docstring, + docstring_preview=preview, + ) + ) + + total = len(entries) + covered = sum(1 for e in entries if e.has_module_docstring) + uncovered = total - covered + fraction = covered / total if total > 0 else 0.0 + is_fully = uncovered == 0 and total > 0 + + if total == 0: + summary = "No Python modules found." + elif is_fully: + summary = f"All {total} module(s) have docstrings. Coverage: 100%." + else: + summary = ( + f"{covered}/{total} module(s) have docstrings " + f"({fraction:.0%} coverage). " + f"{uncovered} undocumented." + ) + + return DocsCoverageReport( + total_modules=total, + covered_modules=covered, + uncovered_modules=uncovered, + coverage_fraction=fraction, + entries=entries, + is_fully_covered=is_fully, + coverage_summary=summary, + ) + + +def format_docs_coverage_report(report: DocsCoverageReport) -> str: + """Return a human-readable docs coverage report.""" + lines: list[str] = [ + "Docs Coverage Report", + f" {report.coverage_summary}", + "", + ] + + if report.uncovered_modules > 0: + lines.append(f"Undocumented modules ({report.uncovered_modules}):") + for entry in report.entries: + if not entry.has_module_docstring: + lines.append(f" - {entry.module_name}") + lines.append("") + + if report.covered_modules > 0: + lines.append(f"Documented modules ({report.covered_modules}):") + for entry in report.entries: + if entry.has_module_docstring: + preview = f" — {entry.docstring_preview}" if entry.docstring_preview else "" + lines.append(f" + {entry.module_name}{preview}") + + return "\n".join(lines) diff --git a/tests/checks/test_docs_coverage.py b/tests/checks/test_docs_coverage.py new file mode 100644 index 00000000..eb2a47d0 --- /dev/null +++ b/tests/checks/test_docs_coverage.py @@ -0,0 +1,327 @@ +"""Tests for J3 docs coverage report.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest + +from openamp_foundry.checks.docs_coverage import ( + DocsCoverageEntry, + DocsCoverageReport, + _extract_module_name, + _has_module_docstring, + check_docs_coverage, + format_docs_coverage_report, +) + + +class TestDocsCoverageEntryConstants: + def test_entry_is_dataclass(self): + entry = DocsCoverageEntry( + module_path="/x/y.py", + module_name="y", + has_module_docstring=True, + docstring_preview="A module.", + ) + assert entry.module_path == "/x/y.py" + + def test_entry_no_docstring(self): + entry = DocsCoverageEntry( + module_path="/x/y.py", + module_name="y", + has_module_docstring=False, + docstring_preview="", + ) + assert not entry.has_module_docstring + + def test_report_is_dataclass(self): + report = DocsCoverageReport( + total_modules=2, + covered_modules=1, + uncovered_modules=1, + coverage_fraction=0.5, + entries=[], + is_fully_covered=False, + coverage_summary="1/2 covered.", + ) + assert report.total_modules == 2 + + +class TestExtractModuleName: + def test_simple_module(self, tmp_path): + src = tmp_path / "src" + src.mkdir() + py = src / "foo.py" + py.touch() + name = _extract_module_name(py, src) + assert name == "foo" + + def test_nested_module(self, tmp_path): + src = tmp_path / "src" + pkg = src / "pkg" + pkg.mkdir(parents=True) + py = pkg / "bar.py" + py.touch() + name = _extract_module_name(py, src) + assert name == "pkg.bar" + + def test_init_stripped(self, tmp_path): + src = tmp_path / "src" + pkg = src / "pkg" + pkg.mkdir(parents=True) + init = pkg / "__init__.py" + init.touch() + name = _extract_module_name(init, src) + assert name == "pkg" + + def test_deeply_nested(self, tmp_path): + src = tmp_path / "src" + deep = src / "a" / "b" / "c" + deep.mkdir(parents=True) + py = deep / "module.py" + py.touch() + name = _extract_module_name(py, src) + assert name == "a.b.c.module" + + def test_outside_source_dir(self, tmp_path): + other = tmp_path / "other" / "x.py" + other.parent.mkdir(parents=True) + other.touch() + name = _extract_module_name(other, tmp_path / "src") + assert name == "x" + + +class TestHasModuleDocstring: + def test_with_docstring(self, tmp_path): + py = tmp_path / "a.py" + py.write_text('"""Module docstring."""\n\nx = 1\n') + has_doc, preview = _has_module_docstring(py) + assert has_doc is True + assert "Module docstring" in preview + + def test_without_docstring(self, tmp_path): + py = tmp_path / "b.py" + py.write_text("x = 1\n") + has_doc, preview = _has_module_docstring(py) + assert has_doc is False + assert preview == "" + + def test_comment_not_docstring(self, tmp_path): + py = tmp_path / "c.py" + py.write_text("# This is a comment\nx = 1\n") + has_doc, preview = _has_module_docstring(py) + assert has_doc is False + + def test_multiline_docstring(self, tmp_path): + py = tmp_path / "d.py" + py.write_text('"""\nFirst line.\nSecond line.\n"""\n\nx = 1\n') + has_doc, preview = _has_module_docstring(py) + assert has_doc is True + + def test_long_docstring_truncated(self, tmp_path): + py = tmp_path / "e.py" + long_doc = "A" * 200 + py.write_text(f'"""{long_doc}"""\n') + has_doc, preview = _has_module_docstring(py) + assert has_doc is True + assert len(preview) <= 83 + + def test_empty_file(self, tmp_path): + py = tmp_path / "f.py" + py.write_text("") + has_doc, preview = _has_module_docstring(py) + assert has_doc is False + + def test_syntax_error_returns_false(self, tmp_path): + py = tmp_path / "g.py" + py.write_text("def broken(:\n") + has_doc, preview = _has_module_docstring(py) + assert has_doc is False + + def test_single_quoted_docstring(self, tmp_path): + py = tmp_path / "h.py" + py.write_text("'Single quoted docstring.'\n\nx = 1\n") + has_doc, preview = _has_module_docstring(py) + assert has_doc is True + + def test_string_not_first_stmt_not_docstring(self, tmp_path): + py = tmp_path / "i.py" + py.write_text('x = 1\n"""Not a docstring."""\n') + has_doc, preview = _has_module_docstring(py) + assert has_doc is False + + +class TestCheckDocsCoverage: + def _make_src(self, tmp_path, modules: dict) -> Path: + src = tmp_path / "src" + src.mkdir() + for name, content in modules.items(): + parts = name.split("/") + parent = src + for part in parts[:-1]: + parent = parent / part + parent.mkdir(exist_ok=True) + (parent / parts[-1]).write_text(content) + return src + + def test_all_documented(self, tmp_path): + src = self._make_src(tmp_path, { + "a.py": '"""Module A."""\n', + "b.py": '"""Module B."""\n', + }) + report = check_docs_coverage(src) + assert report.is_fully_covered is True + + def test_some_undocumented(self, tmp_path): + src = self._make_src(tmp_path, { + "a.py": '"""Module A."""\n', + "b.py": "x = 1\n", + }) + report = check_docs_coverage(src) + assert report.total_modules == 2 + assert report.covered_modules == 1 + assert report.uncovered_modules == 1 + assert not report.is_fully_covered + + def test_empty_directory(self, tmp_path): + src = tmp_path / "src" + src.mkdir() + report = check_docs_coverage(src) + assert report.total_modules == 0 + assert "No Python modules found" in report.coverage_summary + + def test_coverage_fraction(self, tmp_path): + src = self._make_src(tmp_path, { + "a.py": '"""A."""\n', + "b.py": '"""B."""\n', + "c.py": "x = 1\n", + "d.py": "y = 2\n", + }) + report = check_docs_coverage(src) + assert abs(report.coverage_fraction - 0.5) < 0.01 + + def test_exclude_init(self, tmp_path): + src = self._make_src(tmp_path, { + "__init__.py": "", + "a.py": '"""A."""\n', + }) + report = check_docs_coverage(src, exclude_init=True) + assert report.total_modules == 1 + + def test_include_init_by_default(self, tmp_path): + src = self._make_src(tmp_path, { + "__init__.py": "", + "a.py": '"""A."""\n', + }) + report = check_docs_coverage(src) + assert report.total_modules == 2 + + def test_returns_report(self, tmp_path): + src = self._make_src(tmp_path, {"a.py": '"""A."""\n'}) + report = check_docs_coverage(src) + assert isinstance(report, DocsCoverageReport) + + def test_entries_list(self, tmp_path): + src = self._make_src(tmp_path, { + "a.py": '"""A."""\n', + "b.py": "x = 1\n", + }) + report = check_docs_coverage(src) + assert len(report.entries) == 2 + + def test_nested_modules_found(self, tmp_path): + src = self._make_src(tmp_path, { + "pkg/a.py": '"""A."""\n', + "pkg/b.py": "x = 1\n", + }) + report = check_docs_coverage(src) + assert report.total_modules == 2 + + def test_string_path_accepted(self, tmp_path): + src = self._make_src(tmp_path, {"a.py": '"""A."""\n'}) + report = check_docs_coverage(str(src)) + assert report.total_modules == 1 + + def test_100_percent_message(self, tmp_path): + src = self._make_src(tmp_path, {"a.py": '"""A."""\n'}) + report = check_docs_coverage(src) + assert "100%" in report.coverage_summary + + def test_partial_message_shows_fraction(self, tmp_path): + src = self._make_src(tmp_path, { + "a.py": '"""A."""\n', + "b.py": "x = 1\n", + }) + report = check_docs_coverage(src) + assert "50%" in report.coverage_summary or "1/2" in report.coverage_summary + + +class TestFormatDocsCoverageReport: + def _make_report(self, entries): + covered = sum(1 for e in entries if e.has_module_docstring) + uncovered = len(entries) - covered + total = len(entries) + fraction = covered / total if total > 0 else 0.0 + return DocsCoverageReport( + total_modules=total, + covered_modules=covered, + uncovered_modules=uncovered, + coverage_fraction=fraction, + entries=entries, + is_fully_covered=uncovered == 0 and total > 0, + coverage_summary=f"{covered}/{total} covered", + ) + + def test_returns_string(self): + report = self._make_report([]) + text = format_docs_coverage_report(report) + assert isinstance(text, str) + + def test_contains_summary(self): + report = self._make_report([]) + text = format_docs_coverage_report(report) + assert "0/0 covered" in text + + def test_shows_undocumented_section(self): + entries = [ + DocsCoverageEntry("x.py", "x", False, ""), + ] + report = self._make_report(entries) + text = format_docs_coverage_report(report) + assert "Undocumented" in text + assert "x" in text + + def test_shows_documented_section(self): + entries = [ + DocsCoverageEntry("y.py", "y", True, "Module Y."), + ] + report = self._make_report(entries) + text = format_docs_coverage_report(report) + assert "Documented" in text + assert "y" in text + + def test_preview_shown_for_documented(self): + entries = [ + DocsCoverageEntry("z.py", "z", True, "Module for testing things."), + ] + report = self._make_report(entries) + text = format_docs_coverage_report(report) + assert "Module for testing things" in text + + def test_no_undocumented_section_when_all_covered(self): + entries = [ + DocsCoverageEntry("a.py", "a", True, "Module A."), + ] + report = self._make_report(entries) + text = format_docs_coverage_report(report) + assert "Undocumented modules" not in text + + def test_no_documented_section_when_none_covered(self): + entries = [ + DocsCoverageEntry("a.py", "a", False, ""), + ] + report = self._make_report(entries) + text = format_docs_coverage_report(report) + assert "Documented modules" not in text