#!/usr/bin/env python3 """Check documentation coverage for CLI commands and major modules. Parses Click commands from the CLI source code and checks if each command has corresponding documentation in the wiki/docs. Reports missing documentation as warnings and exits with non-zero if coverage is below 100%. Usage: python3 scripts/ci/doc_coverage.py [--docs-dir docs/] [--fail-on-missing] """ from __future__ import annotations import re import sys from pathlib import Path import click from gitea_runner_manager.i18n import _ REPO_ROOT = Path(__file__).resolve().parent.parent.parent DOCS_DIR = REPO_ROOT / "docs" CLI_FILE = REPO_ROOT / "src" / "gitea_runner_manager" / "cli.py" # Major modules that should be documented in tech/architecture.md REQUIRED_MODULES = [ "cli.py", "runner_manager.py", "executor.py", "registry.py", "i18n.py", "exceptions.py", "api_clients.py", "config.py", ] # CI scripts that should be documented in tech/ci-cd-workflow.md REQUIRED_SCRIPTS = [ "auto_merge.py", "release.py", "publish.py", "review_pr.py", "notify_failure.py", "post_merge.py", "classify_changes.py", "discover_runners.py", ] def extract_cli_commands() -> list[str]: """Extract command names from the CLI source file.""" content = CLI_FILE.read_text() commands: list[str] = [] # Find all @cli.command(...) occurrences, then the next def statement for match in re.finditer(r'@cli\.command\b', content): # Check for explicit name="..." in the decorator arguments decorator_end = content.find(")", match.start()) decorator_text = content[match.start() : decorator_end + 1] name_match = re.search(r'name\s*=\s*"([^"]+)"', decorator_text) if name_match: commands.append(name_match.group(1)) continue # Find the next def statement after this decorator after = content[decorator_end:] def_match = re.search(r'def\s+(\w+)\s*\(', after) if def_match: commands.append(def_match.group(1)) return commands def check_command_documented(command: str, docs_content: str) -> bool: """Check if a CLI command is documented in the docs content.""" # Look for the command name as a heading or in code blocks patterns = [ rf"##.*\b{re.escape(command)}\b", rf"`grm\s+{re.escape(command)}\b", rf"\bgrm\s+{re.escape(command)}\b", rf"###.*\b{re.escape(command)}\b", ] return any(re.search(p, docs_content, re.IGNORECASE) for p in patterns) def check_module_documented(module: str, docs_content: str) -> bool: """Check if a module is mentioned in the docs content.""" return module in docs_content @click.command() @click.option("--docs-dir", default=str(DOCS_DIR), help="Path to the docs directory.") @click.option( "--fail-on-missing", is_flag=True, default=False, help="Exit with non-zero status if any documentation is missing.", ) def main(docs_dir: str, fail_on_missing: bool) -> None: docs_path = Path(docs_dir) cli_commands_file = docs_path / "user" / "cli-commands.md" architecture_file = docs_path / "tech" / "architecture.md" ci_cd_file = docs_path / "tech" / "ci-cd-workflow.md" missing: list[str] = [] total = 0 # Check CLI commands click.echo(_("Checking CLI command documentation...")) commands = extract_cli_commands() total += len(commands) cli_docs = cli_commands_file.read_text() if cli_commands_file.exists() else "" for cmd in commands: if check_command_documented(cmd, cli_docs): click.echo(_(" OK: grm {cmd}", cmd=cmd)) else: click.echo(_(" MISSING: grm {cmd}", cmd=cmd)) missing.append(f"CLI command: grm {cmd}") # Check modules in architecture.md click.echo(_("\nChecking module documentation in architecture.md...")) total += len(REQUIRED_MODULES) arch_docs = architecture_file.read_text() if architecture_file.exists() else "" for module in REQUIRED_MODULES: if check_module_documented(module, arch_docs): click.echo(_(" OK: {module}", module=module)) else: click.echo(_(" MISSING: {module}", module=module)) missing.append(f"Module: {module}") # Check CI scripts in ci-cd-workflow.md click.echo(_("\nChecking CI script documentation in ci-cd-workflow.md...")) total += len(REQUIRED_SCRIPTS) ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else "" for script in REQUIRED_SCRIPTS: if check_module_documented(script, ci_docs): click.echo(_(" OK: {script}", script=script)) else: click.echo(_(" MISSING: {script}", script=script)) missing.append(f"CI script: {script}") # Report covered = total - len(missing) percentage = (covered / total * 100) if total > 0 else 100.0 click.echo( _( "\nDoc coverage: {covered}/{total} ({pct}%)", covered=covered, total=total, pct=f"{percentage:.0f}%", ) ) if missing: click.echo(_("\nMissing documentation:")) for item in missing: click.echo(f" - {item}") if missing and fail_on_missing: click.echo(_("\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.")) sys.exit(1) if not missing: click.echo(_("\nAll documentation coverage checks passed!")) if __name__ == "__main__": # pragma: no cover main()