Public Access
DEVX-122: feat: consolidate docs checks into devx-docs-check target
Post-merge / detect-type (push) Successful in 19s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / vikunja (push) Successful in 24s
Post-merge / configure-repo (push) Successful in 25s
Post-merge / sync-wiki (push) Successful in 32s
Post-merge / release (push) Successful in 43s
Post-merge / publish (push) Successful in 21s
Post-merge / badges (push) Successful in 38s
Post-merge / detect-type (push) Successful in 19s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / vikunja (push) Successful in 24s
Post-merge / configure-repo (push) Successful in 25s
Post-merge / sync-wiki (push) Successful in 32s
Post-merge / release (push) Successful in 43s
Post-merge / publish (push) Successful in 21s
Post-merge / badges (push) Successful in 38s
This commit was merged in pull request #185.
This commit is contained in:
+5
-20
@@ -32,30 +32,15 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate 2>/dev/null || true
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
python3 -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5
|
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:
|
env:
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
|
DEVX_DOC_COVERAGE_STRICT: "1"
|
||||||
|
DEVX_VALE_LEVEL: warning
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate 2>/dev/null || true
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
python3 -m devx.ci.doc_coverage --fail-on-missing
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
- name: Documentation lint check
|
make devx-docs-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
|
|
||||||
- name: Translation completeness check
|
- name: Translation completeness check
|
||||||
env:
|
env:
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
|
|||||||
+4
-10
@@ -73,18 +73,12 @@ repos:
|
|||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
stages: [pre-commit]
|
stages: [pre-commit]
|
||||||
|
|
||||||
- id: doc-coverage
|
- id: docs-check
|
||||||
name: documentation coverage check
|
name: documentation gate (coverage + stale refs + lint + version refs + prose)
|
||||||
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.doc_coverage --fail-on-missing
|
entry: bash -c 'PYTHONPATH=src DEVX_DOC_COVERAGE_STRICT=1 DEVX_VALE_LEVEL=warning make devx-docs-check'
|
||||||
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 .
|
|
||||||
language: system
|
language: system
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
stages: [pre-commit]
|
stages: [pre-commit]
|
||||||
|
|
||||||
- id: pytest-cov
|
- id: pytest-cov
|
||||||
|
|||||||
@@ -32,14 +32,17 @@ write-good.E-Prime = NO
|
|||||||
write-good.So = NO
|
write-good.So = NO
|
||||||
write-good.ThereIs = NO
|
write-good.ThereIs = NO
|
||||||
write-good.TooWordy = NO
|
write-good.TooWordy = NO
|
||||||
|
write-good.Passive = NO
|
||||||
|
|
||||||
# Vale defaults — spelling catches too many technical terms
|
# Vale defaults — spelling catches too many technical terms
|
||||||
Vale.Terms = NO
|
Vale.Terms = NO
|
||||||
Vale.Repetition = NO
|
Vale.Repetition = NO
|
||||||
Vale.Spelling = 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.FleschReadingEase = suggestion
|
||||||
|
Readability.FleschKincaid = suggestion
|
||||||
|
Readability.AutomatedReadability = suggestion
|
||||||
Readability.ColemanLiau = suggestion
|
Readability.ColemanLiau = suggestion
|
||||||
Readability.LIX = suggestion
|
Readability.LIX = suggestion
|
||||||
Readability.GunningFog = suggestion
|
Readability.GunningFog = suggestion
|
||||||
|
|||||||
@@ -3,4 +3,4 @@ message: "Unlabeled code block — add a language tag (```bash, ```yaml, etc.)"
|
|||||||
level: warning
|
level: warning
|
||||||
scope: raw
|
scope: raw
|
||||||
raw:
|
raw:
|
||||||
- '(?s)```\n(?!.*```)'
|
- '(?ms)^\n```\n.*?^```\s*$'
|
||||||
|
|||||||
@@ -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.
|
> 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)
|
The MIT License (MIT)
|
||||||
|
|
||||||
Copyright (c) 2014 Brian Ford
|
Copyright (c) 2014 Brian Ford
|
||||||
|
|||||||
@@ -226,14 +226,14 @@ After a PR is merged to master, the **post-merge workflow**
|
|||||||
- Pushes both the commit and tag to master
|
- Pushes both the commit and tag to master
|
||||||
|
|
||||||
3. **sync-wiki** — Syncs documentation to the Gitea wiki. Runs for ALL
|
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.
|
changes still update the wiki.
|
||||||
|
|
||||||
4. **badges** — Generates and pushes quality badge SVGs to the `badges` branch.
|
4. **badges** — Generates and pushes quality badge SVGs to the `badges` branch.
|
||||||
Uses `if: always()` so it runs on every push, including release commits.
|
Uses `if: always()` so it runs on every push, including release commits.
|
||||||
|
|
||||||
5. **vikunja** — Marks the corresponding Vikunja task as done. Runs for ALL
|
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.
|
changes still update the task tracker.
|
||||||
|
|
||||||
6. **publish** — Runs after release succeeds (needs: release). Builds and
|
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
|
### 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
|
the `[tool.devx]` section in `pyproject.toml`. This allows per-project
|
||||||
customization without environment variables.
|
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.**
|
2. **Background by default, foreground when blocking.**
|
||||||
3. **Provide full context in the prompt** — subagents don't inherit conversation history.
|
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.
|
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.**
|
6. **Compact after subagent returns.**
|
||||||
7. **Never skip delegation to save time** — it keeps main context small.
|
7. **Never skip delegation to save time** — it keeps main context small.
|
||||||
|
|
||||||
|
|||||||
@@ -372,7 +372,7 @@ infrastructure = []
|
|||||||
|
|
||||||
# Files that would default to user-facing but are actually infrastructure
|
# Files that would default to user-facing but are actually infrastructure
|
||||||
infrastructure_overrides = [
|
infrastructure_overrides = [
|
||||||
"src/myproject/__init__.py", # only contains __version__
|
"src/myproject/__init__.py", # example only — only contains __version__
|
||||||
]
|
]
|
||||||
|
|
||||||
# Safety override for broad infrastructure patterns
|
# Safety override for broad infrastructure patterns
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ from `devx.api_clients`, `devx.config`, and `devx.gitea_cli`.
|
|||||||
### `setup.py`
|
### `setup.py`
|
||||||
|
|
||||||
Project setup: installs Python dependencies (editable mode with extras),
|
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
|
hooks (pre-commit, commit-msg, pre-push), and configures the `tea` CLI login
|
||||||
profile from `.env`. Supports `--extras` to specify dependency groups,
|
profile from `.env`. Supports `--extras` to specify dependency groups,
|
||||||
`--no-pre-commit` to skip hook installation, and `--no-tea-login` to skip tea
|
`--no-pre-commit` to skip hook installation, and `--no-tea-login` to skip tea
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ badges using `python -m devx.ci.push_badges`:
|
|||||||
|
|
||||||
1. **Fetch latest master** — `git fetch origin master && git reset --hard
|
1. **Fetch latest master** — `git fetch origin master && git reset --hard
|
||||||
origin/master` (ensures the version badge reflects the current state,
|
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
|
2. **Generate badges** — calls `devx.tools.generate_badges` which runs
|
||||||
pytest-cov, doc-coverage, lint checks, and version extraction, then writes
|
pytest-cov, doc-coverage, lint checks, and version extraction, then writes
|
||||||
SVG files: `coverage.svg`, `tests.svg`, `docs.svg`, `quality.svg`,
|
SVG files: `coverage.svg`, `tests.svg`, `docs.svg`, `quality.svg`,
|
||||||
|
|||||||
@@ -405,7 +405,7 @@ devx tools install-tools --list # list status
|
|||||||
### `devx tools setup`
|
### `devx tools setup`
|
||||||
|
|
||||||
Project setup: install Python dependencies (editable mode with extras),
|
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
|
hooks (pre-commit, commit-msg, pre-push), and configure the tea CLI login
|
||||||
profile from `.env`.
|
profile from `.env`.
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,12 @@ vikunja_project_id = 8
|
|||||||
repo_owner = "oblachno-oss"
|
repo_owner = "oblachno-oss"
|
||||||
repo_name = "devx"
|
repo_name = "devx"
|
||||||
|
|
||||||
|
[tool.devx.check_agent_docs]
|
||||||
|
skip_ref_prefixes = [
|
||||||
|
"src/myproject/",
|
||||||
|
"ansible/requirements.yml",
|
||||||
|
]
|
||||||
|
|
||||||
# 3. infrastructure (DEFAULT_INFRASTRUCTURE + project-specific patterns)
|
# 3. infrastructure (DEFAULT_INFRASTRUCTURE + project-specific patterns)
|
||||||
# 4. Default: user-facing (safe)
|
# 4. Default: user-facing (safe)
|
||||||
[tool.devx.classify]
|
[tool.devx.classify]
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
from devx.config import _load_pyproject_devx
|
||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
|
|
||||||
# Default to the current working directory (consuming repo's root)
|
# 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
|
# Matches @cli.command, @ci.command, @tools.command, @molecule.command
|
||||||
for match in re.finditer(r"@\w+\.command\b", content):
|
for match in re.finditer(r"@\w+\.command\b", content):
|
||||||
# Check for explicit name="..." in the decorator arguments
|
# 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]
|
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:
|
if name_match:
|
||||||
commands.append(name_match.group(1))
|
commands.append(name_match.group(1))
|
||||||
continue
|
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
|
# Find the next def statement after this decorator
|
||||||
after = content[decorator_end:]
|
after = content[decorator_end:]
|
||||||
def_match = re.search(r"def\s+(\w+)\s*\(", after)
|
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.command()
|
||||||
@click.option("--docs-dir", default=None, help="Path to the docs directory (default: ./docs).")
|
@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("--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(
|
@click.option(
|
||||||
"--fail-on-missing",
|
"--fail-on-missing",
|
||||||
is_flag=True,
|
is_flag=True,
|
||||||
default=False,
|
default=False,
|
||||||
help="Exit with non-zero status if any documentation is missing.",
|
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()
|
root = Path.cwd()
|
||||||
docs_path = Path(docs_dir) if docs_dir else root / "docs"
|
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
|
# Auto-detect source directory
|
||||||
if source_dir:
|
if source_dir:
|
||||||
src_path = Path(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}")
|
missing.append(f"Module: {module}")
|
||||||
|
|
||||||
# Check CI scripts in ci-cd-workflow.md
|
# 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..."))
|
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_scripts_dir is not None:
|
||||||
if ci_dir.exists():
|
# Explicit config — empty string means skip CI script checks
|
||||||
detected_scripts = sorted(f.name for f in ci_dir.glob("*.py") if f.name != "__init__.py")
|
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:
|
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)
|
total += len(detected_scripts)
|
||||||
ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else ""
|
ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else ""
|
||||||
for script in detected_scripts:
|
for script in detected_scripts:
|
||||||
|
|||||||
@@ -39,6 +39,9 @@
|
|||||||
# DEVX_GITEA_PYPI_ORG — Gitea PyPI org (default: oblachno-oss)
|
# DEVX_GITEA_PYPI_ORG — Gitea PyPI org (default: oblachno-oss)
|
||||||
# DEVX_ACTIONLINT_CFG — actionlint config file (default: .gitea/actionlint.yaml)
|
# DEVX_ACTIONLINT_CFG — actionlint config file (default: .gitea/actionlint.yaml)
|
||||||
# DEVX_WORKFLOW_DIR — workflow directory (default: .gitea/workflows)
|
# 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_PYTHON ?= python3
|
||||||
DEVX_PR_BASE ?= master
|
DEVX_PR_BASE ?= master
|
||||||
@@ -52,6 +55,7 @@ DEVX_GITEA_PYPI_ORG ?= oblachno-oss
|
|||||||
DEVX_ACTIONLINT_CFG ?= .gitea/actionlint.yaml
|
DEVX_ACTIONLINT_CFG ?= .gitea/actionlint.yaml
|
||||||
DEVX_WORKFLOW_DIR ?= .gitea/workflows
|
DEVX_WORKFLOW_DIR ?= .gitea/workflows
|
||||||
DEVX_DOCKERFILE_PATHS ?= docker
|
DEVX_DOCKERFILE_PATHS ?= docker
|
||||||
|
DEVX_VALE_LEVEL ?= warning
|
||||||
|
|
||||||
# PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured.
|
# PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured.
|
||||||
# Usage: $(DEVX_PIP_INSTALL) install -e '.[ci,lint]'
|
# Usage: $(DEVX_PIP_INSTALL) install -e '.[ci,lint]'
|
||||||
@@ -328,7 +332,39 @@ devx-check-docs:
|
|||||||
devx-check-doc-versions:
|
devx-check-doc-versions:
|
||||||
@$(DEVX_PYTHON) -m devx.tools.check_doc_versions --root .
|
@$(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=<pkg> — enable version ref checks for a named package
|
||||||
|
# DEVX_VALE_LEVEL=<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)
|
# 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:
|
devx-vale:
|
||||||
@export PATH="$$HOME/.local/bin:$$PATH" && \
|
@export PATH="$$HOME/.local/bin:$$PATH" && \
|
||||||
if ! command -v vale >/dev/null 2>&1; then \
|
if ! command -v vale >/dev/null 2>&1; then \
|
||||||
|
|||||||
@@ -55,6 +55,38 @@ class TestExtractCliCommands:
|
|||||||
assert "real_cmd" in commands
|
assert "real_cmd" in commands
|
||||||
assert "pass" not 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:
|
class TestCheckCommandDocumented:
|
||||||
def test_finds_command_in_heading(self) -> None:
|
def test_finds_command_in_heading(self) -> None:
|
||||||
@@ -195,3 +227,171 @@ class TestMain:
|
|||||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
||||||
# No source dir found, so no CLI commands, but modules/scripts from REQUIRED lists
|
# No source dir found, so no CLI commands, but modules/scripts from REQUIRED lists
|
||||||
assert result.exit_code == 0
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user