DEVX-63: feat: extract generic tools into devx, expand devx.mak, remove personal references
Post-merge / detect-type (push) Successful in 37s
Post-merge / validate-commit-msg (push) Successful in 42s
Post-merge / sync-wiki (push) Successful in 52s
Post-merge / vikunja (push) Successful in 49s
Post-merge / release (push) Successful in 59s
Post-merge / badges (push) Successful in 1m0s
Post-merge / configure-repo (push) Successful in 49s

This commit was merged in pull request #103.
This commit is contained in:
2026-06-26 18:48:43 +00:00
parent 85e38f37fd
commit 58261f7d1a
16 changed files with 2791 additions and 49 deletions
+267
View File
@@ -0,0 +1,267 @@
#!/usr/bin/env python3
"""Pre-merge validation gate for auto-merge preconditions.
Validates that a PR satisfies auto-merge requirements BEFORE expensive
jobs (molecule tests, staging deploy) run. This catches issues early:
1. Branch name contains a task ID (e.g., ``DEVX-256-fix-foo``).
2. PR title follows ``{PREFIX}-N: <title>`` format.
3. PR title task ID matches the branch task ID.
4. PR title matches the Vikunja task title (requires ``VIKUNJA_TOKEN``).
5. Branch is not behind master (would trigger a rebase retry cycle).
Exit code 0 = ready for auto-merge (preconditions satisfied).
Exit code 1 = NOT ready — fix issues before pushing.
Usage::
# CI (with VIKUNJA_TOKEN and REPO_TOKEN):
python3 -m devx.ci.check_auto_merge_ready \\
--branch "$HEAD_REF" \\
--pr-title "$PR_TITLE" \\
--repo "$REPOSITORY" \\
--pr-number "$PR_NUMBER"
# Local (pre-push hook, no PR yet — validates branch + title format only):
python3 -m devx.ci.check_auto_merge_ready --branch "$(git rev-parse --abbrev-ref HEAD)"
# Local (with PR number, fetches title from Gitea):
python3 -m devx.ci.check_auto_merge_ready --branch "$(git rev-parse --abbrev-ref HEAD)" \\
--repo owner/repo --pr-number 123
If ``VIKUNJA_TOKEN`` is not set, the Vikunja title match check is
skipped (with a warning) — this allows local pre-push hooks to run
without CI secrets. In CI, the token is always set and the check is
mandatory.
If ``REPO_TOKEN`` is not set and ``--pr-number`` is not provided, only
branch-name and PR-title-format checks run (local mode).
"""
from __future__ import annotations
import os
import subprocess # nosec B404
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.api_clients import GiteaClient, VikunjaClient
from devx.ci.auto_merge import extract_task_id
from devx.config import (
GITEA_API_URL,
VIKUNJA_API_URL,
VIKUNJA_PROJECT_ID,
)
from devx.i18n import _
load_dotenv()
def is_branch_behind_master(branch: str) -> bool:
"""Check if the local branch is behind origin/master.
Fetches origin first (best-effort) then compares commit counts.
Returns ``True`` if master has commits not in branch.
"""
try:
subprocess.run( # nosec B603, B607
["git", "fetch", "origin", "master", "--quiet"],
check=False,
capture_output=True,
timeout=30,
)
result = subprocess.run( # nosec B603, B607
["git", "rev-list", "--count", f"origin/master..{branch}"],
capture_output=True,
text=True,
check=False,
timeout=10,
)
if result.returncode != 0:
return False # Can't determine — don't block
result = subprocess.run( # nosec B603, B607
["git", "rev-list", "--count", f"{branch}..origin/master"],
capture_output=True,
text=True,
check=False,
timeout=10,
)
if result.returncode != 0:
return False
behind = int(result.stdout.strip() or "0")
except (subprocess.TimeoutExpired, FileNotFoundError, ValueError):
return False # Don't block on git errors
return behind > 0
def get_pr_title_from_gitea(repo: str, pr_number: int) -> str | None:
"""Fetch the PR title from the Gitea API.
Returns ``None`` if ``REPO_TOKEN`` is not set or the PR cannot be fetched.
"""
token = os.environ.get("REPO_TOKEN", "")
if not token or "/" not in repo:
return None
owner, repo_name = repo.split("/", 1)
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
try:
pr = client.get_pr(pr_number)
return str(pr.get("title", ""))
except Exception:
return None
def get_vikunja_title_optional(task_id: str) -> str | None:
"""Fetch the Vikunja task title, returning None if token is not set.
Unlike :func:`devx.ci.auto_merge.get_vikunja_task_title`, this does NOT
raise when ``VIKUNJA_TOKEN`` is missing — it returns ``None`` so the
caller can skip the check in local mode.
"""
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
return None
client = VikunjaClient(VIKUNJA_API_URL, token)
from devx.config import DEFAULT_PER_PAGE
page = 1
while True:
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
if not tasks:
break
matches = [t for t in tasks if t.get("identifier") == task_id]
if matches:
return str(matches[0].get("title", ""))
if len(tasks) < DEFAULT_PER_PAGE:
break
page += 1
return None
@click.command()
@click.option("--branch", required=True, help=_("Branch name (e.g., DEVX-256-fix-foo)"))
@click.option("--pr-title", default=None, help=_("PR title (auto-fetched if --pr-number given)"))
@click.option("--repo", default=None, help=_("Repository in owner/name format"))
@click.option("--pr-number", type=int, default=None, help=_("PR number (to fetch title from Gitea)"))
@click.option("--skip-vikunja", is_flag=True, help=_("Skip Vikunja title match check"))
@click.option("--skip-behind-check", is_flag=True, help=_("Skip branch-behind-master check"))
def cli(
branch: str,
pr_title: str | None,
repo: str | None,
pr_number: int | None,
skip_vikunja: bool,
skip_behind_check: bool,
) -> None:
"""Validate auto-merge preconditions before expensive CI jobs."""
import re
from devx.config import TASK_PREFIX
pr_title_re = re.compile(rf"^{TASK_PREFIX}-\d+:\s+.+") # noqa: PLW1503
errors: list[str] = []
# 1. Branch task ID
task_id = extract_task_id(branch)
if not task_id:
errors.append(
_(
"No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
branch=branch,
prefix=TASK_PREFIX,
),
)
# Can't continue — no task ID to validate against
for e in errors:
click.echo(f"ERROR: {e}", err=True)
raise click.ClickException(_("Branch name must contain a task ID."))
click.echo(f"[pre-merge-check] Task ID: {task_id}")
# 2. Resolve PR title
if pr_title is None and pr_number is not None and repo is not None:
pr_title = get_pr_title_from_gitea(repo, pr_number)
if pr_title:
click.echo(f"[pre-merge-check] PR title (from Gitea): {pr_title}")
if pr_title is None:
# Local mode without PR — only validate branch name
if pr_number is not None:
raise click.ClickException(_("Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found)."))
click.echo("[pre-merge-check] No PR title provided — running branch-name-only check (local mode).")
click.echo("[pre-merge-check] Branch name OK. Push to create PR, then CI will validate the title.")
return
# 3. PR title format
if not pr_title_re.match(pr_title):
errors.append(
_(
"PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
prefix=TASK_PREFIX,
title=pr_title,
),
)
# 4. PR title task ID matches branch task ID
if not pr_title.startswith(f"{task_id}:"):
errors.append(
_(
"PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
task_id=task_id,
title=pr_title,
),
)
# 5. Vikunja task title match (skip if no token or --skip-vikunja)
if not skip_vikunja:
vikunja_title = get_vikunja_title_optional(task_id)
if vikunja_title is None:
token_set = bool(os.environ.get("VIKUNJA_TOKEN", ""))
if token_set:
errors.append(
_(
"Could not find Vikunja task {task_id} in project {project_id}.",
task_id=task_id,
project_id=VIKUNJA_PROJECT_ID,
),
)
else:
click.echo("[pre-merge-check] WARNING: VIKUNJA_TOKEN not set — skipping Vikunja title match check.")
else:
expected = f"{task_id}: {vikunja_title}"
if pr_title != expected:
errors.append(
_(
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
expected=expected,
title=pr_title,
),
)
else:
click.echo(f"[pre-merge-check] Vikunja title match OK: {expected}")
# 6. Branch behind master (skip if --skip-behind-check)
if not skip_behind_check:
if is_branch_behind_master(branch):
errors.append(
_("Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master")
)
else:
click.echo("[pre-merge-check] Branch is up-to-date with origin/master.")
if errors:
click.echo("", err=True)
click.echo("=" * 60, err=True)
click.echo("Pre-merge validation FAILED — fix these before pushing:", err=True)
click.echo("=" * 60, err=True)
for e in errors:
click.echo(f" - {e}", err=True)
raise click.ClickException(_("Pre-merge validation failed."))
click.echo("[pre-merge-check] All auto-merge preconditions satisfied.")
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+206 -7
View File
@@ -1,8 +1,12 @@
# devx.mak — Shared Makefile fragment for devx-integrated projects.
#
# This fragment provides common targets for Vikunja task management,
# PR creation, and pushing. It is designed to be included from a
# project's Makefile.
# This fragment provides common targets for:
# - Vikunja task management and PR creation
# - Workflow validation (actionlint, act_runner)
# - Linting (ruff, pyright, bandit, pip-audit)
# - CI failure notification
# - Environment setup (venv, .env, hooks)
# - Test execution and quality checks
#
# Project config (task prefix, Vikunja project ID, repo owner, repo name)
# is read from [tool.devx] in pyproject.toml by devx.config — no
@@ -10,7 +14,7 @@
#
# Usage in your Makefile:
#
# # Set DEVX_PYTHON if you need a specific interpreter
# # Set DEVX_PYTHON to your venv's Python
# DEVX_PYTHON := $(BIN)/python
#
# # Include the devx fragment (silent if devx not installed yet)
@@ -22,14 +26,51 @@
# If devx is not installed, the -include silently skips and the targets
# are simply unavailable (run 'make setup' first).
#
# Variables:
# DEVX_PYTHON — Python executable (default: python3)
# DEVX_PR_BASE — PR base branch (default: master)
# Variables (set BEFORE including this fragment):
# DEVX_PYTHON — Python executable (default: python3)
# DEVX_PR_BASE — PR base branch (default: master)
# DEVX_VENV — venv directory name (default: .venv)
# DEVX_BIN — venv bin directory (default: $(DEVX_VENV)/bin)
# DEVX_LINT_PATHS — paths for ruff/bandit (default: src/ tests/)
# DEVX_TYPECHECK_PATHS — paths for pyright (default: empty — uses pyright config)
# DEVX_COV_PKG — coverage package name (default: src/devx)
# DEVX_TEST_PATHS — pytest paths (default: tests/)
# DEVX_GITEA_PYPI_HOST — Gitea PyPI host (default: git.oblachno.oblachno.fyi)
# 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_PYTHON ?= python3
DEVX_PR_BASE ?= master
DEVX_VENV ?= .venv
DEVX_BIN ?= $(DEVX_VENV)/bin
DEVX_LINT_PATHS ?= src/ tests/
DEVX_COV_PKG ?= src/devx
DEVX_TEST_PATHS ?= tests/
DEVX_GITEA_PYPI_HOST ?= git.oblachno.oblachno.fyi
DEVX_GITEA_PYPI_ORG ?= oblachno-oss
DEVX_ACTIONLINT_CFG ?= .gitea/actionlint.yaml
DEVX_WORKFLOW_DIR ?= .gitea/workflows
# PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured.
# Usage: $(DEVX_PIP_INSTALL) install -e '.[ci,lint]'
# GITEA_PYPI_USER can be set in .env, as an env var, or as a Make variable.
DEVX_PIP_INSTALL := if [ -z "$$REPO_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
REPO_TOKEN="$${REPO_TOKEN:-$$GITEA_REGISTRY_TOKEN}"; \
_PYPI_USER="$${DEVX_GITEA_PYPI_USER:-$${GITEA_PYPI_USER}}"; \
if [ -n "$$REPO_TOKEN" ] && [ -n "$$_PYPI_USER" ]; then export PIP_EXTRA_INDEX_URL="https://$$_PYPI_USER:$$REPO_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
$(DEVX_BIN)/pip
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config
.PHONY: devx-configure-gitea-pypi devx-install-tools devx-install-checkmake devx-checkmake
.PHONY: devx-workflow-lint devx-workflow-dryrun devx-workflow-dryrun-safe devx-workflow-check
.PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts
.PHONY: devx-lint-ruff devx-lint-format devx-typecheck devx-lint-bandit devx-lint-deps devx-lint
.PHONY: devx-clean devx-pre-push
.PHONY: devx-check-mutable-globals devx-check-dep-docs devx-check-test-coverage devx-check-docs devx-check-test-speed
.PHONY: devx-test-unit devx-pytest-cov
# ── Vikunja task and PR management ────────────────────────────────────────────
# Create a Vikunja task (project ID read from [tool.devx] in pyproject.toml)
devx-create-task:
@@ -50,3 +91,161 @@ devx-check-config:
# Push and create PR in one step
devx-push-with-pr: devx-push devx-create-pr
# ── Environment setup ─────────────────────────────────────────────────────────
# Configure Gitea private PyPI registry so pip can find devx and other
# private packages. In CI, REPO_TOKEN is set as a secret. Locally, it's in .env.
devx-configure-gitea-pypi:
@if [ -z "$$REPO_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
REPO_TOKEN="$${REPO_TOKEN:-$$GITEA_REGISTRY_TOKEN}"; \
if [ -z "$$REPO_TOKEN" ]; then echo "[configure-gitea-pypi] REPO_TOKEN not set — skipping (devx must be on public PyPI)"; exit 0; fi; \
echo "[configure-gitea-pypi] Gitea PyPI registry configured (REPO_TOKEN present)."
# Create .env from .env.example if it doesn't exist
devx-env:
@if [ ! -f .env ]; then \
cp .env.example .env; \
echo "Created .env from .env.example — please edit it with your credentials."; \
fi
# Create Python venv with version check
devx-venv:
@python3 -c "import sys; v=sys.version_info; assert v >= (3, 12), f'Python 3.12+ required, found {v.major}.{v.minor}'; print(f'Python {v.major}.{v.minor}.{v.micro} OK')"
$(DEVX_PYTHON) -m venv $(DEVX_VENV)
$(DEVX_BIN)/pip install --upgrade pip setuptools wheel
# Create activate scripts for shell/fish/zsh
devx-activate-scripts:
@test -f activate.sh || (echo '#!/usr/bin/env bash' > activate.sh && echo 'source "$$(cd "$$(dirname "$${BASH_SOURCE[0]}")" && pwd)/.venv/bin/activate"' >> activate.sh && chmod +x activate.sh)
@test -f activate.fish || (echo '#!/usr/bin/env fish' > activate.fish && echo 'set -l script_dir (dirname (status --current-filename))' >> activate.fish && echo 'source "$$script_dir/.venv/bin/activate.fish"' >> activate.fish && chmod +x activate.fish)
@test -f activate.zsh || (echo '#!/usr/bin/env zsh' > activate.zsh && echo '0="$${ZERO:-$${0:#$$ZSH_ARGZERO}}"' >> activate.zsh && echo '0="$${$${(M)0:#/*}:-$$PWD/$$0}"' >> activate.zsh && echo 'source "$${0:A:h}/.venv/bin/activate"' >> activate.zsh && chmod +x activate.zsh)
# Set git hooks path to hooks/
devx-install-hooks:
@git config core.hooksPath hooks
@chmod +x hooks/pre-commit hooks/pre-push 2>/dev/null || true
@echo "core.hooksPath set to hooks/ — tracked hooks are now live."
# ── Tool installation ─────────────────────────────────────────────────────────
# Install CI/CD tools (actionlint, git-cliff, act_runner, tea) to ~/.local/bin
devx-install-tools:
@$(DEVX_PYTHON) -m devx.tools.install_tools
# Install checkmake (Makefile linter)
devx-install-checkmake:
@$(DEVX_PYTHON) -m devx.tools.install_checkmake
# Lint Makefiles with checkmake
devx-checkmake:
@CHECKMAKE_EXE="$$(command -v checkmake 2>/dev/null || echo $(HOME)/.local/bin/checkmake)"; \
if ! command -v "$$CHECKMAKE_EXE" >/dev/null 2>&1 && ! [ -x "$$CHECKMAKE_EXE" ]; then \
echo "[checkmake] checkmake not found. Run: make devx-install-checkmake"; exit 1; \
fi; \
"$$CHECKMAKE_EXE" $(CURDIR)/Makefile
# ── Workflow validation ───────────────────────────────────────────────────────
# Static lint of Gitea Actions workflow YAML files
devx-workflow-lint:
@command -v actionlint >/dev/null 2>&1 || { \
echo "actionlint not found. Install: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)"; \
exit 1; \
}
actionlint -config-file $(DEVX_ACTIONLINT_CFG) $(DEVX_WORKFLOW_DIR)/*.yml
# Dry-run all workflows (requires act_runner)
devx-workflow-dryrun:
@command -v act_runner >/dev/null 2>&1 || { echo "act_runner not found. Install: https://gitea.com/gitea/act_runner/releases"; exit 1; }
@echo "Dry-running all workflows (no Docker containers started)..."
act_runner exec --dryrun -W $(DEVX_WORKFLOW_DIR)/ 2>&1 | grep -E 'DRYRUN|ERROR|FAIL|Job'
# Best-effort dry-run (skips if act_runner is not installed)
devx-workflow-dryrun-safe:
@command -v act_runner >/dev/null 2>&1 && { echo "Dry-running workflows..."; act_runner exec --dryrun -W $(DEVX_WORKFLOW_DIR)/ 2>&1 | grep -E 'DRYRUN|ERROR|FAIL|Job'; } || echo "act_runner not found — skipping workflow dry-run (static lint still passed)"
# Static lint + dry-run
devx-workflow-check: devx-workflow-lint devx-workflow-dryrun
@echo "Workflow checks passed (static lint + dry-run)."
# ── CI failure notification ───────────────────────────────────────────────────
# Notify on CI failure — creates a Gitea issue via devx.ci.notify_failure.
# Usage: make devx-notify-failure WORKFLOW=post-merge/release
# Requires: REPO_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_SHA
devx-notify-failure:
@. $(DEVX_VENV)/bin/activate 2>/dev/null || true; \
export PATH="$(HOME)/.local/bin:$$PATH"; \
$(DEVX_PYTHON) -m devx.tools.install_tools --tool tea 2>/dev/null || true; \
$(DEVX_PYTHON) -m devx.ci.notify_failure --auto-login \
--repo "$${GITHUB_REPOSITORY}" \
--run-id "$${GITHUB_RUN_ID}" \
--workflow "$(WORKFLOW)" \
--commit "$${GITHUB_SHA}"
# ── Linting ───────────────────────────────────────────────────────────────────
devx-lint-ruff:
@$(DEVX_BIN)/ruff check $(DEVX_LINT_PATHS)
devx-lint-format:
@$(DEVX_BIN)/ruff format --check $(DEVX_LINT_PATHS)
devx-typecheck:
@$(DEVX_BIN)/pyright
devx-lint-bandit:
@$(DEVX_BIN)/bandit -r src/
devx-lint-deps:
@echo "Checking dependencies for known vulnerabilities..."
@$(DEVX_BIN)/python -m ensurepip 2>/dev/null || true
@PIPAPI_PYTHON_LOCATION=$$(pwd)/$(DEVX_VENV)/bin/python \
$(DEVX_BIN)/pip-audit --desc --skip-editable 2>&1 || true
devx-lint: devx-lint-ruff devx-lint-format devx-typecheck devx-lint-bandit
@echo "[devx-lint] Linting checks passed."
# ── Testing ───────────────────────────────────────────────────────────────────
devx-test-unit:
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -v --no-cov
devx-pytest-cov:
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -v --cov=$(DEVX_COV_PKG) --cov-report=term-missing --cov-fail-under=100
# ── Quality checks ────────────────────────────────────────────────────────────
# Scan for module-level mutable globals that cause test isolation bugs
devx-check-mutable-globals:
@$(DEVX_PYTHON) -m devx.tools.check_mutable_globals
# Validate that every dependency in pyproject.toml has a documented purpose
devx-check-dep-docs:
@$(DEVX_PYTHON) -m devx.tools.check_pyproject_deps
# Check that changed files have corresponding tests
devx-check-test-coverage:
@$(DEVX_PYTHON) -m devx.tools.check_test_coverage
# Validate agent and user docs for stale file references
devx-check-docs:
@$(DEVX_PYTHON) -m devx.tools.check_agent_docs
# Verify test suite timing
devx-check-test-speed:
@$(DEVX_PYTHON) -m devx.tools.check_test_speed
# ── Pre-push validation ───────────────────────────────────────────────────────
# Run lint + tests before push (projects can override with project-specific targets)
devx-pre-push: devx-lint devx-pytest-cov
@echo "[devx-pre-push] All checks passed. Proceeding with push."
# ── Cleanup ───────────────────────────────────────────────────────────────────
devx-clean:
@find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
@find . -type f -name "*.pyc" -delete 2>/dev/null || true
@rm -rf .coverage htmlcov/ dist/ build/ *.egg-info/ .molecule/ 2>/dev/null || true
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""Validate agent documentation and user docs for stale file references.
Scans documentation files (``.devin/``, ``docs/``, ``README.md``) for:
- References to files that no longer exist
- References to deleted files (configurable blocklist)
- References to deprecated patterns (configurable regex patterns)
Configuration (``[tool.devx.check_agent_docs]`` in pyproject.toml):
``scan_dirs`` — directories to scan for docs (default: ``[".devin", "docs"]``)
``scan_files`` — specific files to scan (default: ``["README.md", "README.rst"]``)
``scan_extensions`` — file extensions to scan (default: ``[".md", ".yml", ".yaml"]``)
``excluded_paths`` — paths to exclude from scanning (default: ``["docs/retrospectives"]``)
``deleted_files`` — list of file paths that should never be referenced
``deprecated_patterns`` — list of regex patterns for deprecated references
``legitimate_indicators`` — substrings that indicate a legitimate reference to a deprecated pattern
``repo_path_prefixes`` — path prefixes that indicate a repo-relative reference
(default: ``["ansible/", "scripts/", "tofu/", ".devin/", "src/"]``)
``min_path_ref_length`` — minimum length for a path reference to be checked (default: 5)
Usage::
python3 -m devx.tools.check_agent_docs
"""
from __future__ import annotations
import contextlib
import re
from pathlib import Path
import click
from devx.config import _load_pyproject_devx
from devx.i18n import _
MIN_PATH_REF_LENGTH_DEFAULT = 5
# Pattern that matches file path references in markdown or code
FILE_REF_RE = re.compile(
r"(?:`|\")?"
r"([\w\-./]+(?:\.[a-zA-Z0-9]+))"
r"(?:`|\))?"
)
DEFAULT_SCAN_DIRS = [".devin", "docs"]
DEFAULT_SCAN_FILES = ["README.md", "README.rst"]
DEFAULT_SCAN_EXTENSIONS = [".md", ".yml", ".yaml"]
DEFAULT_EXCLUDED_PATHS = ["docs/retrospectives"]
DEFAULT_REPO_PATH_PREFIXES = ["ansible/", "scripts/", "tofu/", ".devin/", "src/"]
def _load_config() -> dict[str, object]:
"""Load check_agent_docs configuration from pyproject.toml."""
devx_cfg = _load_pyproject_devx()
cfg_raw = devx_cfg.get("check_agent_docs", {})
if not isinstance(cfg_raw, dict):
return {}
return cfg_raw # type: ignore[return-value]
def _should_skip(path: Path, excluded_paths: list[str], repo_root: Path) -> bool:
"""Check if a path should be excluded from scanning."""
try:
rel = str(path.relative_to(repo_root))
except ValueError:
return False
return any(excluded in rel for excluded in excluded_paths)
def _is_legitimate_ref(line: str, legitimate_indicators: list[str]) -> bool:
"""Check if a line contains a legitimate reference to a deprecated pattern."""
line_lower = line.lower()
return any(legit.lower() in line_lower for legit in legitimate_indicators)
def _collect_doc_files(
repo_root: Path,
scan_dirs: list[str],
scan_files: list[str],
scan_extensions: list[str],
excluded_paths: list[str],
) -> list[Path]:
"""Collect all documentation files to scan."""
files: list[Path] = []
for scan_dir_name in scan_dirs:
scan_dir = repo_root / scan_dir_name
if not scan_dir.exists():
continue
for ext in scan_extensions:
for path in scan_dir.glob(f"**/*{ext}"):
if not _should_skip(path, excluded_paths, repo_root):
files.append(path)
for readme_name in scan_files:
path = repo_root / readme_name
if path.exists() and not _should_skip(path, excluded_paths, repo_root):
files.append(path)
# Deduplicate while preserving order
seen: set[Path] = set()
unique: list[Path] = []
for f in files:
if f not in seen:
seen.add(f)
unique.append(f)
return unique
def _check_file(
path: Path,
repo_root: Path,
deleted_files: set[str],
deprecated_patterns: list[re.Pattern[str]],
legitimate_indicators: list[str],
repo_path_prefixes: list[str],
min_path_ref_length: int,
) -> list[str]:
"""Check a single file for stale references."""
issues: list[str] = []
rel_path = path.relative_to(repo_root)
try:
content = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
return issues
for lineno, line in enumerate(content.splitlines(), start=1):
# Check for deleted file references
for deleted in deleted_files:
if deleted in line:
issues.append(f"{rel_path}:{lineno}: references deleted file '{deleted}'")
# Check for deprecated pattern references
for pattern in deprecated_patterns:
if pattern.search(line) and not _is_legitimate_ref(line, legitimate_indicators):
issues.append(f"{rel_path}:{lineno}: matches deprecated pattern '{pattern.pattern}'")
# Check for references to files that don't exist
for match in FILE_REF_RE.finditer(line):
ref = match.group(1)
# Skip URLs, bare words, and short strings
if "/" not in ref or len(ref) < min_path_ref_length:
continue
# Only check references that look like repo paths
if not any(ref.startswith(prefix) for prefix in repo_path_prefixes):
continue
candidate = repo_root / ref
if not candidate.exists():
issues.append(f"{rel_path}:{lineno}: references non-existent file '{ref}'")
return issues
@click.command()
def cli() -> None:
"""Validate agent documentation and user docs for stale file references."""
repo_root = Path.cwd()
cfg = _load_config()
scan_dirs_raw = cfg.get("scan_dirs")
scan_dirs: list[str] = [str(d) for d in scan_dirs_raw] if isinstance(scan_dirs_raw, list) else DEFAULT_SCAN_DIRS
scan_files_raw = cfg.get("scan_files")
scan_files: list[str] = [str(d) for d in scan_files_raw] if isinstance(scan_files_raw, list) else DEFAULT_SCAN_FILES
scan_ext_raw = cfg.get("scan_extensions")
scan_extensions: list[str] = (
[str(d) for d in scan_ext_raw] if isinstance(scan_ext_raw, list) else DEFAULT_SCAN_EXTENSIONS
)
excluded_raw = cfg.get("excluded_paths")
excluded_paths: list[str] = (
[str(d) for d in excluded_raw] if isinstance(excluded_raw, list) else DEFAULT_EXCLUDED_PATHS
)
prefixes_raw = cfg.get("repo_path_prefixes")
repo_path_prefixes: list[str] = (
[str(d) for d in prefixes_raw] if isinstance(prefixes_raw, list) else DEFAULT_REPO_PATH_PREFIXES
)
min_len_raw = cfg.get("min_path_ref_length")
min_path_ref_length: int = int(min_len_raw) if isinstance(min_len_raw, int) else MIN_PATH_REF_LENGTH_DEFAULT
deleted_files: set[str] = set()
deleted_raw = cfg.get("deleted_files", [])
if isinstance(deleted_raw, list):
deleted_files = {str(d) for d in deleted_raw}
deprecated_patterns: list[re.Pattern[str]] = []
deprecated_raw = cfg.get("deprecated_patterns", [])
if isinstance(deprecated_raw, list):
for pattern_str in deprecated_raw:
if isinstance(pattern_str, str):
with contextlib.suppress(re.error):
deprecated_patterns.append(re.compile(pattern_str))
legitimate_indicators: list[str] = []
legit_raw = cfg.get("legitimate_indicators", [])
if isinstance(legit_raw, list):
legitimate_indicators = [str(s) for s in legit_raw]
files = _collect_doc_files(repo_root, scan_dirs, scan_files, scan_extensions, excluded_paths)
all_issues: list[str] = []
for path in sorted(files):
issues = _check_file(
path,
repo_root,
deleted_files,
deprecated_patterns,
legitimate_indicators,
repo_path_prefixes,
min_path_ref_length,
)
all_issues.extend(issues)
if all_issues:
click.echo(f"[check_agent_docs] Found {len(all_issues)} issue(s):\n", err=True)
for issue in all_issues:
click.echo(issue, err=True)
click.echo(
f"\n[check_agent_docs] FAILED: {len(all_issues)} stale reference(s)",
err=True,
)
raise click.ClickException(_("Found {count} stale documentation reference(s)", count=len(all_issues)))
click.echo(_("[check_agent_docs] Passed: scanned {count} file(s), no stale references", count=len(files)))
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Detect module-level mutable globals that may cause test isolation bugs.
Scans Python files for patterns like::
_SEEN: set[Path] = set()
_CACHE: dict[Path, Any] = {}
PATHS: list[Path] = []
These are hazardous because one test mutates the container and the next
sees stale state. The script reports the file/line and suggests a factory
function or fixture replacement.
Configuration (``[tool.devx.check_mutable_globals]`` in pyproject.toml):
``scan_dirs`` — list of directories to scan (default: ``["scripts", "tests"]``)
``skip_dirs`` — directory names to skip (default: ``__pycache__``, ``.pytest_cache``, ``venv``, ``.venv``)
``known_safe`` — list of ``"path:line:var_name"`` entries to ignore
Usage::
python3 -m devx.tools.check_mutable_globals
python3 -m devx.tools.check_mutable_globals --scan-dir src --scan-dir tests
"""
from __future__ import annotations
import ast
import contextlib
from pathlib import Path
import click
from devx.config import _load_pyproject_devx
from devx.i18n import _
MUTABLE_TYPES = {"set", "dict", "list"}
PATH_HINTS = ("path", "paths", "seen", "cache", "memo", "registry")
DEFAULT_SCAN_DIRS = ["scripts", "tests"]
DEFAULT_SKIP_DIRS = {"__pycache__", ".pytest_cache", "venv", ".venv"}
def _load_config() -> tuple[list[str], set[str], set[tuple[str, int, str]]]:
"""Load configuration from pyproject.toml [tool.devx.check_mutable_globals]."""
devx_cfg = _load_pyproject_devx()
cfg_raw = devx_cfg.get("check_mutable_globals", {})
if not isinstance(cfg_raw, dict):
return DEFAULT_SCAN_DIRS, DEFAULT_SKIP_DIRS, set()
cfg: dict[str, object] = cfg_raw # type: ignore[assignment]
scan_dirs_raw = cfg.get("scan_dirs", DEFAULT_SCAN_DIRS)
scan_dirs: list[str] = [str(d) for d in scan_dirs_raw] if isinstance(scan_dirs_raw, list) else DEFAULT_SCAN_DIRS
skip_dirs_raw = cfg.get("skip_dirs", list(DEFAULT_SKIP_DIRS))
skip_dirs: set[str] = {str(d) for d in skip_dirs_raw} if isinstance(skip_dirs_raw, list) else DEFAULT_SKIP_DIRS
known_safe_raw = cfg.get("known_safe", [])
known_safe: set[tuple[str, int, str]] = set()
if isinstance(known_safe_raw, list):
for entry in known_safe_raw:
if isinstance(entry, str) and entry.count(":") >= 2:
parts = entry.rsplit(":", 2)
with contextlib.suppress(ValueError):
known_safe.add((parts[0], int(parts[1]), parts[2]))
return scan_dirs, skip_dirs, known_safe
def _should_skip(path: Path, skip_dirs: set[str]) -> bool:
return any(part in skip_dirs for part in path.parts)
def find_mutable_globals(
file_path: Path,
repo_root: Path,
known_safe: set[tuple[str, int, str]],
) -> list[str]:
"""Return a list of issue strings for mutable globals in *file_path*."""
issues: list[str] = []
try:
source = file_path.read_text(encoding="utf-8")
tree = ast.parse(source)
except SyntaxError:
return issues
for node in ast.iter_child_nodes(tree):
if not isinstance(node, ast.AnnAssign | ast.Assign):
continue
names: list[str] = []
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
names.append(node.target.id)
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name):
names.append(target.id)
for name in names:
name_lower = name.lower()
value = node.value
if value is None:
continue
is_mutable_literal = False
if isinstance(value, ast.Call):
if isinstance(value.func, ast.Name):
if value.func.id in MUTABLE_TYPES:
is_mutable_literal = True
elif isinstance(value.func, ast.Attribute):
# e.g. collections.defaultdict
pass
elif isinstance(value, (ast.Dict, ast.List, ast.Set)):
is_mutable_literal = True
if not is_mutable_literal:
continue
# Check if the name or type hint suggests Path usage
has_path_hint = any(hint in name_lower for hint in PATH_HINTS)
has_path_type = False
if isinstance(node, ast.AnnAssign) and node.annotation:
ann = ast.unparse(node.annotation)
has_path_type = "Path" in ann
if has_path_hint or has_path_type:
rel = str(file_path.relative_to(repo_root))
if (rel, node.lineno, name) in known_safe:
continue
value_str = ast.unparse(value) if value is not None else "..."
issues.append(
f"{rel}:{node.lineno}: mutable global {name!r} "
f"({value_str}) — use a factory function or pytest fixture"
)
return issues
@click.command()
@click.option(
"--scan-dir",
multiple=True,
help=_("Additional directory to scan (default: scripts, tests). Can be repeated."),
)
def cli(scan_dir: tuple[str, ...]) -> None:
"""Scan for module-level mutable globals that cause test isolation bugs."""
repo_root = Path.cwd()
config_scan_dirs, skip_dirs, known_safe = _load_config()
# CLI --scan-dir overrides config if provided
scan_dirs = list(scan_dir) if scan_dir else config_scan_dirs
all_issues: list[str] = []
for scan_dir_name in scan_dirs:
scan_path = repo_root / scan_dir_name
if not scan_path.exists():
continue
for py_file in scan_path.rglob("*.py"):
if _should_skip(py_file, skip_dirs):
continue
all_issues.extend(find_mutable_globals(py_file, repo_root, known_safe))
if all_issues:
click.echo(f"[check-mutable-globals] FAILED: {len(all_issues)} issue(s)", err=True)
for issue in all_issues:
click.echo(f" {issue}", err=True)
raise click.ClickException(
_("Found {count} mutable global(s) — use factory functions or pytest fixtures.", count=len(all_issues))
)
click.echo(_("[check-mutable-globals] Passed: no mutable path globals found"))
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Validate that every dependency in pyproject.toml has a documented purpose.
This script does NOT resolve versions or query PyPI. It only ensures that
every dependency listed in ``[project.dependencies]`` or
``[project.optional-dependencies]`` has a corresponding comment nearby
explaining why it is needed.
Failure means a dependency lacks documentation.
Usage::
python3 -m devx.tools.check_pyproject_deps
python3 -m devx.tools.check_pyproject_deps --file path/to/pyproject.toml
"""
from __future__ import annotations
from pathlib import Path
import click
from devx.i18n import _
def check_deps(pyproject_path: Path) -> list[str]:
"""Return a list of issue strings for undocumented dependencies.
An empty list means all dependencies are documented.
"""
if not pyproject_path.exists():
return [str(pyproject_path) + ": file not found"]
content = pyproject_path.read_text(encoding="utf-8")
lines = content.splitlines()
issues: list[str] = []
in_deps_section = False
prev_was_comment = False
for i, raw_line in enumerate(lines, start=1):
stripped = raw_line.strip()
# Detect section headers
if stripped in ("[project.dependencies]", "[project.optional-dependencies]"):
in_deps_section = True
continue
if stripped.startswith("[") and in_deps_section:
in_deps_section = False
continue
if not in_deps_section:
continue
if stripped == "":
continue
# We're inside a dependency list
if stripped.startswith("#"):
prev_was_comment = True
continue
if stripped.startswith("-") or stripped.startswith('"'):
if not prev_was_comment:
issues.append(f"{pyproject_path.name}:{i}: dependency lacks description comment: {stripped}")
prev_was_comment = False
else:
prev_was_comment = False
return issues
@click.command()
@click.option(
"--file",
"pyproject_file",
type=click.Path(path_type=Path),
default=Path("pyproject.toml"),
help=_("Path to pyproject.toml (default: pyproject.toml in CWD)."),
)
def cli(pyproject_file: Path) -> None:
"""Validate that every dependency in pyproject.toml has a documented purpose."""
issues = check_deps(pyproject_file)
if issues:
click.echo(
_("FAILED: {count} undocumented dependency/ies", count=len(issues)),
err=True,
)
for issue in issues:
click.echo(f" {issue}", err=True)
raise click.ClickException(_("Dependencies must have documentation comments."))
click.echo(_("[check-dep-docs] Passed: all dependencies are documented"))
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""Pre-commit / CI check: ensure every changed or new file has corresponding tests.
Configuration (``[tool.devx.check_test_coverage]`` in pyproject.toml):
``rules`` — list of mapping rules, each with:
``source_pattern`` — glob pattern for source files (e.g. ``"scripts/*.py"``)
``test_paths`` — list of test path templates (e.g. ``["scripts/tests/test_{name}", "tests/unit/test_{name}"]``)
``description`` — human-readable description for error messages
``skip_patterns`` — list of file patterns to skip (e.g. ``["__init__.py", "config.py"]``)
``test_file_indicators`` — substrings that identify a file as a test (default: ``["tests/", "/test_", "_test.py"]``)
``skip_extensions`` — file extensions to skip (default: .md, .yml, .yaml, .json, .tf, .sh, .conf, .service)
Built-in defaults cover common Python project layouts (``scripts/*.py``, ``src/**/*.py``).
Project-specific rules are merged with defaults (first match wins).
Usage::
python3 -m devx.tools.check_test_coverage [--staged-only] [--warn-only]
"""
from __future__ import annotations
import argparse
import fnmatch
import subprocess # nosec B404
import sys
from pathlib import Path
from devx.config import _load_pyproject_devx
from devx.i18n import _
DEFAULT_TEST_INDICATORS = ["tests/", "/test_", "_test.py"]
DEFAULT_SKIP_EXTENSIONS = (".md", ".yml", ".yaml", ".json", ".tf", ".sh", ".conf", ".service")
# Built-in rules for common Python project layouts
BUILTIN_RULES: list[dict[str, object]] = [
{
"source_pattern": "scripts/*.py",
"test_paths": ["scripts/tests/test_{name}", "tests/unit/test_{name}"],
"description": "Missing unit test: scripts/tests/test_{name} or tests/unit/test_{name}",
},
{
"source_pattern": "src/**/*.py",
"test_paths": ["tests/unit/test_{name}", "tests/unit/test_{module}_{name}"],
"description": "Missing unit test: tests/unit/test_{name}",
},
]
def _load_rules() -> tuple[list[dict[str, object]], list[str], list[str], tuple[str, ...]]:
"""Load test coverage rules from pyproject.toml."""
devx_cfg = _load_pyproject_devx()
cfg_raw = devx_cfg.get("check_test_coverage", {})
if not isinstance(cfg_raw, dict):
return BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS
cfg: dict[str, object] = cfg_raw # type: ignore[assignment]
rules_raw = cfg.get("rules", BUILTIN_RULES)
rules: list[dict[str, object]] = [dict(r) for r in rules_raw] if isinstance(rules_raw, list) else BUILTIN_RULES
skip_raw = cfg.get("skip_patterns", [])
skip_patterns: list[str] = [str(s) for s in skip_raw] if isinstance(skip_raw, list) else []
indicators_raw = cfg.get("test_file_indicators", DEFAULT_TEST_INDICATORS)
indicators: list[str] = (
[str(s) for s in indicators_raw] if isinstance(indicators_raw, list) else DEFAULT_TEST_INDICATORS
)
skip_ext_raw = cfg.get("skip_extensions", list(DEFAULT_SKIP_EXTENSIONS))
if isinstance(skip_ext_raw, list):
skip_ext: tuple[str, ...] = tuple(str(s) for s in skip_ext_raw)
else:
skip_ext = DEFAULT_SKIP_EXTENSIONS
return rules, skip_patterns, indicators, skip_ext
def _changed_files(staged_only: bool, repo_root: Path) -> list[str]:
"""Return list of changed file paths relative to repo root."""
if staged_only:
cmd = ["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"]
else:
# Compare against origin/master for CI usage
cmd = ["git", "diff", "origin/master...HEAD", "--name-only", "--diff-filter=ACMR"]
result = subprocess.run( # nosec B603, B607
cmd, capture_output=True, text=True, check=False, cwd=repo_root
)
if result.returncode != 0:
# fallback: just use staged files
result = subprocess.run( # nosec B603, B607
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
capture_output=True,
text=True,
check=False,
cwd=repo_root,
)
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
def _is_test_file(filepath: str, indicators: list[str]) -> bool:
"""Check if a file is a test file."""
return any(indicator in filepath for indicator in indicators)
def _should_skip_file(
filepath: str,
skip_patterns: list[str],
skip_extensions: tuple[str, ...],
) -> bool:
"""Check if a file should be skipped."""
if filepath.startswith("."):
return True
if filepath.endswith(skip_extensions):
return True
name = Path(filepath).name
return any(fnmatch.fnmatch(name, pattern) or fnmatch.fnmatch(filepath, pattern) for pattern in skip_patterns)
def _resolve_test_path(template: str, source_path: str, repo_root: Path) -> Path:
"""Resolve a test path template to an actual path.
Templates can use:
- ``{name}`` — the source file's name (without extension)
- ``{module}`` — the source file's parent directory name
- ``{package_prefix}`` — underscore-joined subdirectories (for nested modules)
"""
path = Path(source_path)
name = path.stem
module = path.parent.name
# Build package prefix for nested modules (e.g. scripts/utils/secrets.py -> utils)
parts = path.parts
package_prefix = ""
if len(parts) > 2:
package_prefix = "_".join(parts[1:-1])
resolved = template.format(
name=name,
module=module,
package_prefix=package_prefix,
)
# Normalize hyphens to underscores (Python module naming)
resolved = resolved.replace("-", "_")
return repo_root / resolved
def _find_missing_tests(
files: list[str],
repo_root: Path,
rules: list[dict[str, object]],
skip_patterns: list[str],
test_indicators: list[str],
skip_extensions: tuple[str, ...],
) -> dict[str, str]:
"""Map each untested file to the reason it's untested."""
missing: dict[str, str] = {}
for f in files:
# Skip test files themselves
if _is_test_file(f, test_indicators):
continue
# Skip config, docs, meta files
if _should_skip_file(f, skip_patterns, skip_extensions):
continue
for rule in rules:
pattern = str(rule.get("source_pattern", ""))
if not fnmatch.fnmatch(f, pattern):
continue
test_templates = rule.get("test_paths", [])
if not isinstance(test_templates, list):
continue
description_template = str(rule.get("description", "Missing test for {f}"))
test_paths = [_resolve_test_path(str(t), f, repo_root) for t in test_templates]
# Check if any test path exists (with .py extension)
found = False
for tp in test_paths:
if tp.with_suffix(".py").exists() or tp.exists():
found = True
break
if not found:
# Format description with file info
name = Path(f).stem
missing[f] = description_template.format(
name=name,
f=f,
test_name=f"test_{name}".replace("-", "_"),
)
break
# If no rule matched, the file is not checked (no test requirement)
# This is intentional — only files matching a rule need tests
return missing
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description=_("Check that changed files have corresponding tests"),
)
parser.add_argument(
"--staged-only",
action="store_true",
help=_("Only check staged files (for pre-commit)"),
)
parser.add_argument(
"--warn-only",
action="store_true",
help=_("Print warnings but always exit 0"),
)
args = parser.parse_args(argv)
repo_root = Path.cwd()
rules, skip_patterns, test_indicators, skip_extensions = _load_rules()
files = _changed_files(args.staged_only, repo_root)
if not files:
print(_("[check_test_coverage] No changed files to check."))
return 0
missing = _find_missing_tests(files, repo_root, rules, skip_patterns, test_indicators, skip_extensions)
if not missing:
print(f"[check_test_coverage] All {len(files)} changed file(s) have tests.")
return 0
print("[check_test_coverage] FAILED: missing tests for changed files:\n", file=sys.stderr)
for f, reason in missing.items():
print(f" {f}", file=sys.stderr)
print(f" -> {reason}", file=sys.stderr)
print(
"\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
file=sys.stderr,
)
if args.warn_only:
return 0
return 1
if __name__ == "__main__": # pragma: no cover
sys.exit(main())
+216
View File
@@ -1630,5 +1630,221 @@
"pl": "OSTRZEŻENIE: VIKUNJA_TOKEN nie jest ustawiony — pomijanie sprawdzania istnienia zadania. Ustaw w .env, aby włączyć pełną walidację.",
"ru": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не установлен — пропуск проверки существования задачи. Установите в .env для полной проверки.",
"zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。"
},
"[check-mutable-globals] Passed: no mutable path globals found": {
"bg": "[check-mutable-globals] Passed: no mutable path globals found",
"de": "[check-mutable-globals] Passed: no mutable path globals found",
"en": "[check-mutable-globals] Passed: no mutable path globals found",
"pl": "[check-mutable-globals] Passed: no mutable path globals found",
"ru": "[check-mutable-globals] Passed: no mutable path globals found",
"zh": "[check-mutable-globals] Passed: no mutable path globals found"
},
"[check_agent_docs] Passed: scanned {count} file(s), no stale references": {
"bg": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"de": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"en": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"pl": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"ru": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"zh": "[check_agent_docs] Passed: scanned {count} file(s), no stale references"
},
"[check_test_coverage] No changed files to check.": {
"bg": "[check_test_coverage] No changed files to check.",
"de": "[check_test_coverage] No changed files to check.",
"en": "[check_test_coverage] No changed files to check.",
"pl": "[check_test_coverage] No changed files to check.",
"ru": "[check_test_coverage] No changed files to check.",
"zh": "[check_test_coverage] No changed files to check."
},
"Additional directory to scan (default: scripts, tests). Can be repeated.": {
"bg": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"de": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"en": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"pl": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"ru": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"zh": "Additional directory to scan (default: scripts, tests). Can be repeated."
},
"Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master": {
"bg": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"de": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"en": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"pl": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"ru": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"zh": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master"
},
"Branch name (e.g., DEVX-256-fix-foo)": {
"bg": "Branch name (e.g., DEVX-256-fix-foo)",
"de": "Branch name (e.g., DEVX-256-fix-foo)",
"en": "Branch name (e.g., DEVX-256-fix-foo)",
"pl": "Branch name (e.g., DEVX-256-fix-foo)",
"ru": "Branch name (e.g., DEVX-256-fix-foo)",
"zh": "Branch name (e.g., DEVX-256-fix-foo)"
},
"Branch name must contain a task ID.": {
"bg": "Branch name must contain a task ID.",
"de": "Branch name must contain a task ID.",
"en": "Branch name must contain a task ID.",
"pl": "Branch name must contain a task ID.",
"ru": "Branch name must contain a task ID.",
"zh": "Branch name must contain a task ID."
},
"Check that changed files have corresponding tests": {
"bg": "Check that changed files have corresponding tests",
"de": "Check that changed files have corresponding tests",
"en": "Check that changed files have corresponding tests",
"pl": "Check that changed files have corresponding tests",
"ru": "Check that changed files have corresponding tests",
"zh": "Check that changed files have corresponding tests"
},
"Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).": {
"bg": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
"de": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
"en": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
"pl": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
"ru": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
"zh": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found)."
},
"Dependencies must have documentation comments.": {
"bg": "Dependencies must have documentation comments.",
"de": "Dependencies must have documentation comments.",
"en": "Dependencies must have documentation comments.",
"pl": "Dependencies must have documentation comments.",
"ru": "Dependencies must have documentation comments.",
"zh": "Dependencies must have documentation comments."
},
"FAILED: {count} undocumented dependency/ies": {
"bg": "FAILED: {count} undocumented dependency/ies",
"de": "FAILED: {count} undocumented dependency/ies",
"en": "FAILED: {count} undocumented dependency/ies",
"pl": "FAILED: {count} undocumented dependency/ies",
"ru": "FAILED: {count} undocumented dependency/ies",
"zh": "FAILED: {count} undocumented dependency/ies"
},
"Found {count} mutable global(s) — use factory functions or pytest fixtures.": {
"bg": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"de": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"en": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"pl": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"ru": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"zh": "Found {count} mutable global(s) — use factory functions or pytest fixtures."
},
"Found {count} stale documentation reference(s)": {
"bg": "Found {count} stale documentation reference(s)",
"de": "Found {count} stale documentation reference(s)",
"en": "Found {count} stale documentation reference(s)",
"pl": "Found {count} stale documentation reference(s)",
"ru": "Found {count} stale documentation reference(s)",
"zh": "Found {count} stale documentation reference(s)"
},
"No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.": {
"bg": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"de": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"en": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"pl": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"ru": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"zh": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description."
},
"Only check staged files (for pre-commit)": {
"bg": "Only check staged files (for pre-commit)",
"de": "Only check staged files (for pre-commit)",
"en": "Only check staged files (for pre-commit)",
"pl": "Only check staged files (for pre-commit)",
"ru": "Only check staged files (for pre-commit)",
"zh": "Only check staged files (for pre-commit)"
},
"PR number (to fetch title from Gitea)": {
"bg": "PR number (to fetch title from Gitea)",
"de": "PR number (to fetch title from Gitea)",
"en": "PR number (to fetch title from Gitea)",
"pl": "PR number (to fetch title from Gitea)",
"ru": "PR number (to fetch title from Gitea)",
"zh": "PR number (to fetch title from Gitea)"
},
"PR title (auto-fetched if --pr-number given)": {
"bg": "PR title (auto-fetched if --pr-number given)",
"de": "PR title (auto-fetched if --pr-number given)",
"en": "PR title (auto-fetched if --pr-number given)",
"pl": "PR title (auto-fetched if --pr-number given)",
"ru": "PR title (auto-fetched if --pr-number given)",
"zh": "PR title (auto-fetched if --pr-number given)"
},
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}": {
"bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"pl": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}"
},
"PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}": {
"bg": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"de": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"en": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"pl": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"ru": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"zh": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}"
},
"PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}": {
"bg": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"de": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"en": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"pl": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"ru": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"zh": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}"
},
"Path to pyproject.toml (default: pyproject.toml in CWD).": {
"bg": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"de": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"en": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"pl": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"ru": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"zh": "Path to pyproject.toml (default: pyproject.toml in CWD)."
},
"Pre-merge validation failed.": {
"bg": "Pre-merge validation failed.",
"de": "Pre-merge validation failed.",
"en": "Pre-merge validation failed.",
"pl": "Pre-merge validation failed.",
"ru": "Pre-merge validation failed.",
"zh": "Pre-merge validation failed."
},
"Print warnings but always exit 0": {
"bg": "Print warnings but always exit 0",
"de": "Print warnings but always exit 0",
"en": "Print warnings but always exit 0",
"pl": "Print warnings but always exit 0",
"ru": "Print warnings but always exit 0",
"zh": "Print warnings but always exit 0"
},
"Repository in owner/name format": {
"bg": "Repository in owner/name format",
"de": "Repository in owner/name format",
"en": "Repository in owner/name format",
"pl": "Repository in owner/name format",
"ru": "Repository in owner/name format",
"zh": "Repository in owner/name format"
},
"Skip Vikunja title match check": {
"bg": "Skip Vikunja title match check",
"de": "Skip Vikunja title match check",
"en": "Skip Vikunja title match check",
"pl": "Skip Vikunja title match check",
"ru": "Skip Vikunja title match check",
"zh": "Skip Vikunja title match check"
},
"Skip branch-behind-master check": {
"bg": "Skip branch-behind-master check",
"de": "Skip branch-behind-master check",
"en": "Skip branch-behind-master check",
"pl": "Skip branch-behind-master check",
"ru": "Skip branch-behind-master check",
"zh": "Skip branch-behind-master check"
},
"[check-dep-docs] Passed: all dependencies are documented": {
"bg": "[check-dep-docs] Passed: all dependencies are documented",
"de": "[check-dep-docs] Passed: all dependencies are documented",
"en": "[check-dep-docs] Passed: all dependencies are documented",
"pl": "[check-dep-docs] Passed: all dependencies are documented",
"ru": "[check-dep-docs] Passed: all dependencies are documented",
"zh": "[check-dep-docs] Passed: all dependencies are documented"
}
}