From ef08513bcf4682c1738fbedbac0b4c5e9206e2ea Mon Sep 17 00:00:00 2001 From: emil Date: Tue, 7 Jul 2026 22:00:07 +0000 Subject: [PATCH] DEVX-122: feat: consolidate docs checks into devx-docs-check target --- .gitea/workflows/ci.yml | 25 +-- .pre-commit-config.yaml | 14 +- .vale.ini | 5 +- .vale/styles/devx/CodeBlockLanguage.yml | 2 +- .vale/styles/write-good/README.md | 2 +- AGENTS.md | 8 +- README.md | 2 +- docs/tech/architecture.md | 2 +- docs/tech/ci-cd-workflow.md | 2 +- docs/user/cli-commands.md | 2 +- pyproject.toml | 6 + src/devx/ci/doc_coverage.py | 71 ++++++++- src/devx/make/devx.mak | 36 +++++ tests/unit/test_doc_coverage.py | 200 ++++++++++++++++++++++++ 14 files changed, 328 insertions(+), 49 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 1a87be3..fe13506 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -32,30 +32,15 @@ jobs: run: | . .venv/bin/activate 2>/dev/null || true python3 -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5 - - name: Documentation coverage check + - name: Documentation gate (coverage + stale refs + lint + version refs + prose) env: PYTHONPATH: src + DEVX_DOC_COVERAGE_STRICT: "1" + DEVX_VALE_LEVEL: warning run: | . .venv/bin/activate 2>/dev/null || true - python3 -m devx.ci.doc_coverage --fail-on-missing - - name: Documentation lint check - env: - PYTHONPATH: src - run: | - . .venv/bin/activate 2>/dev/null || true - python3 -m devx.ci.lint_docs --root . - - name: Documentation version reference check - env: - PYTHONPATH: src - run: | - . .venv/bin/activate 2>/dev/null || true - python3 -m devx.tools.check_doc_versions --root . - - name: Vale prose lint check - env: - PYTHONPATH: src - run: | - . .venv/bin/activate 2>/dev/null || true - make devx-vale + export PATH="$HOME/.local/bin:$PATH" + make devx-docs-check - name: Translation completeness check env: PYTHONPATH: src diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7716954..468053e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -73,18 +73,12 @@ repos: pass_filenames: false stages: [pre-commit] - - id: doc-coverage - name: documentation coverage check - entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.doc_coverage --fail-on-missing - language: system - pass_filenames: false - stages: [pre-commit] - - - id: lint-docs - name: documentation lint check - entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.lint_docs --root . + - id: docs-check + name: documentation gate (coverage + stale refs + lint + version refs + prose) + entry: bash -c 'PYTHONPATH=src DEVX_DOC_COVERAGE_STRICT=1 DEVX_VALE_LEVEL=warning make devx-docs-check' language: system pass_filenames: false + always_run: true stages: [pre-commit] - id: pytest-cov diff --git a/.vale.ini b/.vale.ini index 00167ae..571f80f 100644 --- a/.vale.ini +++ b/.vale.ini @@ -32,14 +32,17 @@ write-good.E-Prime = NO write-good.So = NO write-good.ThereIs = NO write-good.TooWordy = NO +write-good.Passive = NO # Vale defaults — spelling catches too many technical terms Vale.Terms = NO Vale.Repetition = NO Vale.Spelling = NO -# Readability — warnings only, technical docs are naturally complex +# Readability — technical docs are naturally complex, downgrade to suggestions Readability.FleschReadingEase = suggestion +Readability.FleschKincaid = suggestion +Readability.AutomatedReadability = suggestion Readability.ColemanLiau = suggestion Readability.LIX = suggestion Readability.GunningFog = suggestion diff --git a/.vale/styles/devx/CodeBlockLanguage.yml b/.vale/styles/devx/CodeBlockLanguage.yml index eb8a2e1..6361e05 100644 --- a/.vale/styles/devx/CodeBlockLanguage.yml +++ b/.vale/styles/devx/CodeBlockLanguage.yml @@ -3,4 +3,4 @@ message: "Unlabeled code block — add a language tag (```bash, ```yaml, etc.)" level: warning scope: raw raw: - - '(?s)```\n(?!.*```)' + - '(?ms)^\n```\n.*?^```\s*$' diff --git a/.vale/styles/write-good/README.md b/.vale/styles/write-good/README.md index 953c5a1..3edcc9b 100644 --- a/.vale/styles/write-good/README.md +++ b/.vale/styles/write-good/README.md @@ -2,7 +2,7 @@ Based on [write-good](https://github.com/btford/write-good). > Naive linter for English prose for developers who can't write good and wanna learn to do other stuff good too. -```text +``` The MIT License (MIT) Copyright (c) 2014 Brian Ford diff --git a/AGENTS.md b/AGENTS.md index 934d50b..2435f6d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -226,14 +226,14 @@ After a PR is merged to master, the **post-merge workflow** - Pushes both the commit and tag to master 3. **sync-wiki** — Syncs documentation to the Gitea wiki. Runs for ALL - non-release commits (not just when release succeeds), so docs-only + non-release commits (not only when release succeeds), so docs-only changes still update the wiki. 4. **badges** — Generates and pushes quality badge SVGs to the `badges` branch. Uses `if: always()` so it runs on every push, including release commits. 5. **vikunja** — Marks the corresponding Vikunja task as done. Runs for ALL - non-release commits (not just when release succeeds), so infrastructure-only + non-release commits (not only when release succeeds), so infrastructure-only changes still update the task tracker. 6. **publish** — Runs after release succeeds (needs: release). Builds and @@ -398,7 +398,7 @@ system loads `.env` automatically via `python-dotenv`. ### pyproject.toml [tool.devx] Configuration -In addition to `DEVX_` env vars, several devx tools read configuration from +In addition to `DEVX_` env vars, many devx tools read configuration from the `[tool.devx]` section in `pyproject.toml`. This allows per-project customization without environment variables. @@ -610,7 +610,7 @@ the user should not need to specify which profile to use. 2. **Background by default, foreground when blocking.** 3. **Provide full context in the prompt** — subagents don't inherit conversation history. 4. **One subagent per concern.** Chain: investigate → fix in main session → review. -5. **Don't delegate trivial work** (<30s, <50 lines of context). +5. **Don't delegate minor work** (<30s, <50 lines of context). 6. **Compact after subagent returns.** 7. **Never skip delegation to save time** — it keeps main context small. diff --git a/README.md b/README.md index b8fe924..07e28c1 100644 --- a/README.md +++ b/README.md @@ -372,7 +372,7 @@ infrastructure = [] # Files that would default to user-facing but are actually infrastructure infrastructure_overrides = [ - "src/myproject/__init__.py", # only contains __version__ + "src/myproject/__init__.py", # example only — only contains __version__ ] # Safety override for broad infrastructure patterns diff --git a/docs/tech/architecture.md b/docs/tech/architecture.md index 2ab8a60..271f5c5 100644 --- a/docs/tech/architecture.md +++ b/docs/tech/architecture.md @@ -311,7 +311,7 @@ from `devx.api_clients`, `devx.config`, and `devx.gitea_cli`. ### `setup.py` Project setup: installs Python dependencies (editable mode with extras), -Ansible Galaxy collections (if `ansible/requirements.yml` exists), pre-commit +Ansible Galaxy collections (if `ansible/requirements.yml` exists in the target repo), pre-commit hooks (pre-commit, commit-msg, pre-push), and configures the `tea` CLI login profile from `.env`. Supports `--extras` to specify dependency groups, `--no-pre-commit` to skip hook installation, and `--no-tea-login` to skip tea diff --git a/docs/tech/ci-cd-workflow.md b/docs/tech/ci-cd-workflow.md index 6371152..5a10d6b 100644 --- a/docs/tech/ci-cd-workflow.md +++ b/docs/tech/ci-cd-workflow.md @@ -251,7 +251,7 @@ badges using `python -m devx.ci.push_badges`: 1. **Fetch latest master** — `git fetch origin master && git reset --hard origin/master` (ensures the version badge reflects the current state, - even if the release job just pushed a new version) + even if the release job recently pushed a new version) 2. **Generate badges** — calls `devx.tools.generate_badges` which runs pytest-cov, doc-coverage, lint checks, and version extraction, then writes SVG files: `coverage.svg`, `tests.svg`, `docs.svg`, `quality.svg`, diff --git a/docs/user/cli-commands.md b/docs/user/cli-commands.md index 9f7463e..84794a6 100644 --- a/docs/user/cli-commands.md +++ b/docs/user/cli-commands.md @@ -405,7 +405,7 @@ devx tools install-tools --list # list status ### `devx tools setup` Project setup: install Python dependencies (editable mode with extras), -Ansible Galaxy collections (if `ansible/requirements.yml` exists), pre-commit +Ansible Galaxy collections (if `ansible/requirements.yml` exists in the target repo), pre-commit hooks (pre-commit, commit-msg, pre-push), and configure the tea CLI login profile from `.env`. diff --git a/pyproject.toml b/pyproject.toml index fd2d0d8..b004180 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,6 +121,12 @@ vikunja_project_id = 8 repo_owner = "oblachno-oss" repo_name = "devx" +[tool.devx.check_agent_docs] +skip_ref_prefixes = [ + "src/myproject/", + "ansible/requirements.yml", +] + # 3. infrastructure (DEFAULT_INFRASTRUCTURE + project-specific patterns) # 4. Default: user-facing (safe) [tool.devx.classify] diff --git a/src/devx/ci/doc_coverage.py b/src/devx/ci/doc_coverage.py index d988443..e3ccbf7 100644 --- a/src/devx/ci/doc_coverage.py +++ b/src/devx/ci/doc_coverage.py @@ -21,6 +21,7 @@ from pathlib import Path import click +from devx.config import _load_pyproject_devx from devx.i18n import _ # Default to the current working directory (consuming repo's root) @@ -71,12 +72,30 @@ def extract_cli_commands(source_dir: Path) -> list[str]: # Matches @cli.command, @ci.command, @tools.command, @molecule.command for match in re.finditer(r"@\w+\.command\b", content): # Check for explicit name="..." in the decorator arguments - decorator_end = content.find(")", match.start()) + # Use a balanced paren search to find the end of the decorator + # (handles nested parens like @cli.command(help=_("..."))) + depth = 0 + decorator_end = match.start() + for i in range(match.start(), len(content)): + if content[i] == "(": + depth += 1 + elif content[i] == ")": + depth -= 1 + if depth == 0: + decorator_end = i + break decorator_text = content[match.start() : decorator_end + 1] - name_match = re.search(r'["\']([^"\']+)["\']', decorator_text) + # Look for explicit name="..." parameter (not help=, not other kwargs) + name_match = re.search(r'\bname\s*=\s*["\']([^"\']+)["\']', decorator_text) if name_match: commands.append(name_match.group(1)) continue + # Look for a positional string argument (e.g. @cli.command("my-cmd")) + # but skip if the only strings are in help= or other keyword args + positional_match = re.search(r'@\w+\.command\s*\(\s*["\']([^"\']+)["\']', decorator_text) + if positional_match: + commands.append(positional_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) @@ -106,16 +125,38 @@ def check_module_documented(module: str, docs_content: str) -> bool: @click.command() @click.option("--docs-dir", default=None, help="Path to the docs directory (default: ./docs).") @click.option("--source-dir", default=None, help="Path to the source directory (default: auto-detect from src/).") +@click.option( + "--ci-scripts-dir", + default=None, + help=( + "Path to CI scripts directory (default: auto-detect from src/ci/). " + "Set to empty string to skip CI script checks." + ), +) @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 | None, source_dir: str | None, fail_on_missing: bool) -> None: +def main(docs_dir: str | None, source_dir: str | None, ci_scripts_dir: str | None, fail_on_missing: bool) -> None: root = Path.cwd() docs_path = Path(docs_dir) if docs_dir else root / "docs" + # Read [tool.devx.doc_coverage] config from pyproject.toml + devx_cfg = _load_pyproject_devx() + doc_cov_cfg_raw: object = devx_cfg.get("doc_coverage", {}) if isinstance(devx_cfg, dict) else {} + doc_cov_cfg: dict[str, object] = doc_cov_cfg_raw if isinstance(doc_cov_cfg_raw, dict) else {} + + # CLI args override config; config overrides defaults + if ci_scripts_dir is None and "ci_scripts_dir" in doc_cov_cfg: + ci_scripts_dir = str(doc_cov_cfg["ci_scripts_dir"]) + if docs_dir is None and "docs_dir" in doc_cov_cfg: + docs_dir = str(doc_cov_cfg["docs_dir"]) + docs_path = Path(docs_dir) + if source_dir is None and "source_dir" in doc_cov_cfg: + source_dir = str(doc_cov_cfg["source_dir"]) + # Auto-detect source directory if source_dir: src_path = Path(source_dir) @@ -166,13 +207,27 @@ def main(docs_dir: str | None, source_dir: str | None, fail_on_missing: bool) -> missing.append(f"Module: {module}") # Check CI scripts in ci-cd-workflow.md - # Auto-detect CI scripts from ci/ subdirectory + # Auto-detect CI scripts from ci/ subdirectory, or use explicit config click.echo(_("\nChecking CI script documentation in ci-cd-workflow.md...")) - ci_dir = src_path / "ci" if src_path.name != "ci" else src_path - if ci_dir.exists(): - detected_scripts = sorted(f.name for f in ci_dir.glob("*.py") if f.name != "__init__.py") + if ci_scripts_dir is not None: + # Explicit config — empty string means skip CI script checks + if ci_scripts_dir == "": + detected_scripts = [] + else: + ci_dir = Path(ci_scripts_dir) + if ci_dir.exists(): + detected_scripts = sorted(f.name for f in ci_dir.glob("*.py") if f.name != "__init__.py") + else: + detected_scripts = [] else: - detected_scripts = REQUIRED_SCRIPTS + # Auto-detect from src_path/ci/ + ci_dir = src_path / "ci" if src_path.name != "ci" else src_path + if ci_dir.exists(): + detected_scripts = sorted(f.name for f in ci_dir.glob("*.py") if f.name != "__init__.py") + else: + # No ci/ directory found — skip CI script checks rather than falling back + # to REQUIRED_SCRIPTS (which is devx-specific) + detected_scripts = [] total += len(detected_scripts) ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else "" for script in detected_scripts: diff --git a/src/devx/make/devx.mak b/src/devx/make/devx.mak index 28419ea..48e6ce4 100644 --- a/src/devx/make/devx.mak +++ b/src/devx/make/devx.mak @@ -39,6 +39,9 @@ # DEVX_GITEA_PYPI_ORG — Gitea PyPI org (default: oblachno-oss) # DEVX_ACTIONLINT_CFG — actionlint config file (default: .gitea/actionlint.yaml) # DEVX_WORKFLOW_DIR — workflow directory (default: .gitea/workflows) +# DEVX_DOC_COVERAGE_STRICT — fail on missing docs (default: 0) +# DEVX_DOC_VERSIONS_PKG — package name for version ref checks (default: auto) +# DEVX_VALE_LEVEL — vale alert threshold (default: warning) DEVX_PYTHON ?= python3 DEVX_PR_BASE ?= master @@ -52,6 +55,7 @@ DEVX_GITEA_PYPI_ORG ?= oblachno-oss DEVX_ACTIONLINT_CFG ?= .gitea/actionlint.yaml DEVX_WORKFLOW_DIR ?= .gitea/workflows DEVX_DOCKERFILE_PATHS ?= docker +DEVX_VALE_LEVEL ?= warning # PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured. # Usage: $(DEVX_PIP_INSTALL) install -e '.[ci,lint]' @@ -328,7 +332,39 @@ devx-check-docs: devx-check-doc-versions: @$(DEVX_PYTHON) -m devx.tools.check_doc_versions --root . +# Documentation coverage — checks that all modules/scripts/CLI commands +# are documented. Fails if any are missing when DEVX_DOC_COVERAGE_STRICT=1. +devx-doc-coverage: + @$(DEVX_PYTHON) -m devx.ci.doc_coverage $(if $(filter 1,$(DEVX_DOC_COVERAGE_STRICT)),--fail-on-missing) + +# All-in-one documentation gate: coverage + stale refs + structural lint + +# version refs + prose lint. Use in CI and pre-commit as a single step +# instead of 5+ separate steps. +# +# Configuration via environment variables (set in Makefile before include +# or in CI env): +# DEVX_DOC_COVERAGE_STRICT=1 — fail on missing docs (recommended) +# DEVX_DOC_VERSIONS_PKG= — enable version ref checks for a named package +# DEVX_VALE_LEVEL= — vale alert threshold (error, warning, suggestion) +# default: warning (catches weasel words, unlabeled +# code blocks, etc. — not just spelling errors) +devx-docs-check: devx-doc-coverage devx-check-docs + @$(DEVX_PYTHON) -m devx.ci.lint_docs --root . + @if [ -n "$(DEVX_DOC_VERSIONS_PKG)" ]; then \ + $(DEVX_PYTHON) -m devx.tools.check_doc_versions --root . --package $(DEVX_DOC_VERSIONS_PKG); \ + elif $(DEVX_PYTHON) -c "import importlib.util,sys; sys.exit(0 if any(importlib.util.find_spec(p) for p in ['devx','grm','oblachno_infra']) else 1)" 2>/dev/null; then \ + $(DEVX_PYTHON) -m devx.tools.check_doc_versions --root . 2>/dev/null || true; \ + fi + @export PATH="$$HOME/.local/bin:$$PATH" && \ + if ! command -v vale >/dev/null 2>&1; then \ + echo "[devx-docs-check] vale not installed — skipping prose lint (install with 'make install-tools')"; \ + else \ + vale sync >/dev/null 2>&1 || true; \ + vale --minAlertLevel=$(DEVX_VALE_LEVEL) docs/ AGENTS.md README.md; \ + fi + # Run Vale prose linter on docs and README (skips if vale not installed) +# Legacy target — use devx-docs-check for the full documentation gate. devx-vale: @export PATH="$$HOME/.local/bin:$$PATH" && \ if ! command -v vale >/dev/null 2>&1; then \ diff --git a/tests/unit/test_doc_coverage.py b/tests/unit/test_doc_coverage.py index e5e8838..cb0d2b5 100644 --- a/tests/unit/test_doc_coverage.py +++ b/tests/unit/test_doc_coverage.py @@ -55,6 +55,38 @@ class TestExtractCliCommands: assert "real_cmd" in commands assert "pass" not in commands + def test_command_with_explicit_name_param(self, tmp_path: Path) -> None: + """When a command uses name="explicit-name", that name is extracted.""" + fake_cli = tmp_path / "cli.py" + fake_cli.write_text( + '@click.group()\ndef cli():\n pass\n@cli.command(name="my-command")\ndef my_command():\n pass\n' + ) + commands = extract_cli_commands(tmp_path) + assert "my-command" in commands + assert "my_command" not in commands + + def test_command_with_help_kwarg_uses_def_name(self, tmp_path: Path) -> None: + """When a command uses help= kwarg but no name=, falls back to def name.""" + fake_cli = tmp_path / "cli.py" + fake_cli.write_text( + "@click.group()\ndef cli():\n pass\n" + '@cli.command(help="Do something useful")\ndef do_something():\n pass\n' + ) + commands = extract_cli_commands(tmp_path) + assert "do_something" in commands + assert "Do something useful" not in commands + + def test_command_with_help_translation_uses_def_name(self, tmp_path: Path) -> None: + """When a command uses help=_() translation, falls back to def name.""" + fake_cli = tmp_path / "cli.py" + fake_cli.write_text( + "@click.group()\ndef cli():\n pass\n" + '@cli.command(help=_("Install and configure things"))\ndef install():\n pass\n' + ) + commands = extract_cli_commands(tmp_path) + assert "install" in commands + assert "Install and configure things" not in commands + class TestCheckCommandDocumented: def test_finds_command_in_heading(self) -> None: @@ -195,3 +227,171 @@ class TestMain: result = runner.invoke(main, ["--docs-dir", str(docs)]) # No source dir found, so no CLI commands, but modules/scripts from REQUIRED lists assert result.exit_code == 0 + + def test_ci_scripts_dir_empty_skips_ci_checks(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """When --ci-scripts-dir is empty string, CI script checks are skipped.""" + monkeypatch.chdir(tmp_path) + docs = tmp_path / "docs" + (docs / "user").mkdir(parents=True) + (docs / "tech").mkdir(parents=True) + src = tmp_path / "src" / "devx" + src.mkdir(parents=True) + (src / "__init__.py").write_text("") + (src / "cli.py").write_text( + "@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n" + ) + (src / "config.py").write_text("# config") + (docs / "user" / "cli-commands.md").write_text("## release\n") + (docs / "tech" / "architecture.md").write_text("config.py") + (docs / "tech" / "ci-cd-workflow.md").write_text("") + runner = CliRunner() + result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src), "--ci-scripts-dir", ""]) + assert result.exit_code == 0 + assert "100%" in result.output + # Should not mention any CI scripts + assert "MISSING" not in result.output or "CI script" not in result.output + + def test_ci_scripts_dir_explicit_path(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """When --ci-scripts-dir points to a directory, scripts are detected from there.""" + monkeypatch.chdir(tmp_path) + docs = tmp_path / "docs" + (docs / "user").mkdir(parents=True) + (docs / "tech").mkdir(parents=True) + src = tmp_path / "src" / "myapp" + src.mkdir(parents=True) + (src / "__init__.py").write_text("") + (src / "cli.py").write_text( + "@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n" + ) + ci_dir = tmp_path / "ci" + ci_dir.mkdir() + (ci_dir / "my_script.py").write_text("# my script") + (ci_dir / "__init__.py").write_text("") + (docs / "user" / "cli-commands.md").write_text("## release\n") + (docs / "tech" / "architecture.md").write_text("") + (docs / "tech" / "ci-cd-workflow.md").write_text("my_script.py") + runner = CliRunner() + result = runner.invoke( + main, ["--docs-dir", str(docs), "--source-dir", str(src), "--ci-scripts-dir", str(ci_dir)] + ) + assert result.exit_code == 0 + assert "my_script.py" in result.output + assert "OK: my_script.py" in result.output + + def test_ci_scripts_dir_nonexistent_skips(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """When --ci-scripts-dir points to a non-existent path, CI checks are skipped.""" + monkeypatch.chdir(tmp_path) + docs = tmp_path / "docs" + (docs / "user").mkdir(parents=True) + (docs / "tech").mkdir(parents=True) + src = tmp_path / "src" / "myapp" + src.mkdir(parents=True) + (src / "__init__.py").write_text("") + (src / "cli.py").write_text( + "@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n" + ) + (docs / "user" / "cli-commands.md").write_text("## release\n") + (docs / "tech" / "architecture.md").write_text("") + (docs / "tech" / "ci-cd-workflow.md").write_text("") + runner = CliRunner() + result = runner.invoke( + main, ["--docs-dir", str(docs), "--source-dir", str(src), "--ci-scripts-dir", "/nonexistent"] + ) + assert result.exit_code == 0 + assert "100%" in result.output + + def test_config_from_pyproject_ci_scripts_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """When [tool.devx.doc_coverage] ci_scripts_dir is set in pyproject.toml, it's used.""" + monkeypatch.chdir(tmp_path) + docs = tmp_path / "docs" + (docs / "user").mkdir(parents=True) + (docs / "tech").mkdir(parents=True) + src = tmp_path / "src" / "myapp" + src.mkdir(parents=True) + (src / "__init__.py").write_text("") + (src / "cli.py").write_text( + "@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n" + ) + (src / "config.py").write_text("# config") + (docs / "user" / "cli-commands.md").write_text("## release\n") + (docs / "tech" / "architecture.md").write_text("config.py") + (docs / "tech" / "ci-cd-workflow.md").write_text("") + # Write pyproject.toml with ci_scripts_dir = "" + (tmp_path / "pyproject.toml").write_text('[tool.devx.doc_coverage]\nci_scripts_dir = ""\n') + runner = CliRunner() + result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)]) + assert result.exit_code == 0 + assert "100%" in result.output + + def test_config_from_pyproject_docs_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """When [tool.devx.doc_coverage] docs_dir is set in pyproject.toml, it's used.""" + monkeypatch.chdir(tmp_path) + custom_docs = tmp_path / "custom-docs" + (custom_docs / "user").mkdir(parents=True) + (custom_docs / "tech").mkdir(parents=True) + src = tmp_path / "src" / "myapp" + src.mkdir(parents=True) + (src / "__init__.py").write_text("") + (src / "cli.py").write_text( + "@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n" + ) + (src / "config.py").write_text("# config") + (custom_docs / "user" / "cli-commands.md").write_text("## release\n") + (custom_docs / "tech" / "architecture.md").write_text("config.py") + (custom_docs / "tech" / "ci-cd-workflow.md").write_text("") + # Write pyproject.toml with custom docs_dir + (tmp_path / "pyproject.toml").write_text( + f'[tool.devx.doc_coverage]\ndocs_dir = "{custom_docs}"\nci_scripts_dir = ""\n' + ) + runner = CliRunner() + result = runner.invoke(main, ["--source-dir", str(src)]) + assert result.exit_code == 0 + assert "100%" in result.output + + def test_config_from_pyproject_source_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """When [tool.devx.doc_coverage] source_dir is set in pyproject.toml, it's used.""" + monkeypatch.chdir(tmp_path) + docs = tmp_path / "docs" + (docs / "user").mkdir(parents=True) + (docs / "tech").mkdir(parents=True) + custom_src = tmp_path / "custom-src" / "myapp" + custom_src.mkdir(parents=True) + (custom_src / "__init__.py").write_text("") + (custom_src / "cli.py").write_text( + "@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n" + ) + (custom_src / "config.py").write_text("# config") + (docs / "user" / "cli-commands.md").write_text("## release\n") + (docs / "tech" / "architecture.md").write_text("config.py") + (docs / "tech" / "ci-cd-workflow.md").write_text("") + # Write pyproject.toml with custom source_dir + (tmp_path / "pyproject.toml").write_text( + f'[tool.devx.doc_coverage]\nsource_dir = "{custom_src}"\nci_scripts_dir = ""\n' + ) + runner = CliRunner() + result = runner.invoke(main, ["--docs-dir", str(docs)]) + assert result.exit_code == 0 + assert "100%" in result.output + + def test_config_doc_coverage_not_dict(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """When [tool.devx.doc_coverage] is not a dict, falls back to defaults.""" + monkeypatch.chdir(tmp_path) + docs = tmp_path / "docs" + (docs / "user").mkdir(parents=True) + (docs / "tech").mkdir(parents=True) + src = tmp_path / "src" / "myapp" + src.mkdir(parents=True) + (src / "__init__.py").write_text("") + (src / "cli.py").write_text( + "@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n" + ) + (src / "config.py").write_text("# config") + (docs / "user" / "cli-commands.md").write_text("## release\n") + (docs / "tech" / "architecture.md").write_text("config.py") + (docs / "tech" / "ci-cd-workflow.md").write_text("") + # Write pyproject.toml with doc_coverage as a non-dict value + (tmp_path / "pyproject.toml").write_text('[tool.devx]\ndoc_coverage = "not-a-dict"\n') + runner = CliRunner() + result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)]) + assert result.exit_code == 0 + assert "100%" in result.output