diff --git a/AGENTS.md b/AGENTS.md index be6ac3e..dba5c6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,7 @@ src/devx/ │ ├── release.py # Automated versioning, tagging, changelog │ ├── publish.py # Build and publish to Gitea PyPI registry (--skip-build for non-Python repos) │ ├── auto_merge.py # Squash-merge PRs with task ID validation +│ ├── check_auto_merge_ready.py # Pre-merge validation gate (branch, PR title, Vikunja, behind-master) │ ├── _shared.py # Shared utilities (get_latest_tag) │ ├── classify_changes.py # User-facing vs workflow-only change detection │ ├── detect_release_commit.py # Detect release commits on master @@ -74,6 +75,10 @@ src/devx/ │ ├── setup.py # Environment setup (venv, deps, hooks) │ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea │ ├── check_test_speed.py # Measure unit test execution time +│ ├── check_mutable_globals.py # Detect module-level mutable globals (test isolation bugs) +│ ├── check_pyproject_deps.py # Validate pyproject.toml deps have documentation comments +│ ├── check_test_coverage.py # Ensure changed files have corresponding tests (configurable rules) +│ ├── check_agent_docs.py # Validate docs for stale file references (configurable patterns) │ ├── configure_repo.py # Branch protection and label setup │ └── generate_badges.py # Badge SVG generation ├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field) @@ -358,6 +363,90 @@ Projects using devx can override the default API URLs and language by setting `DEVX_*` environment variables or entries in their `.env` file. The config system loads `.env` automatically via `python-dotenv`. +### pyproject.toml [tool.devx] Configuration + +In addition to `DEVX_` env vars, several devx tools read configuration from +the `[tool.devx]` section in `pyproject.toml`. This allows per-project +customization without environment variables. + +**Base config** (`[tool.devx]`): +- `task_prefix` — Task ID prefix (e.g. `"DEVX"`, `"GRM"`, `"OBL-INFRA"`) +- `vikunja_project_id` — Vikunja project ID +- `repo_owner` / `repo_name` — Gitea repository coordinates +- `gitea_api_url` / `vikunja_api_url` — API endpoints + +**Tool-specific config**: +- `[tool.devx.check_mutable_globals]` — `scan_dirs`, `skip_dirs`, `known_safe` +- `[tool.devx.check_test_coverage]` — `rules` (source_pattern → test_paths mapping), `skip_patterns` +- `[tool.devx.check_agent_docs]` — `scan_dirs`, `deleted_files`, `deprecated_patterns`, `legitimate_indicators` + +## devx.mak — Shared Makefile Fragment + +`devx.mak` provides common Makefile targets that projects can include +via `-include $(DEVX_MAK)`. This eliminates Makefile duplication across +projects. + +**Available targets** (all prefixed with `devx-`): + +| Target | Purpose | +|--------|---------| +| `devx-create-task` | Create a Vikunja task | +| `devx-create-pr` | Create a PR with auto-derived title | +| `devx-push` | Push current branch to origin | +| `devx-push-with-pr` | Push and create PR in one step | +| `devx-check-config` | Validate devx configuration | +| `devx-configure-gitea-pypi` | Configure Gitea private PyPI registry | +| `devx-env` | Create .env from .env.example | +| `devx-venv` | Create Python venv with version check | +| `devx-activate-scripts` | Create shell/fish/zsh activate scripts | +| `devx-install-hooks` | Set git hooks path to hooks/ | +| `devx-install-tools` | Install actionlint, git-cliff, act_runner, tea | +| `devx-install-checkmake` | Install checkmake (Makefile linter) | +| `devx-checkmake` | Lint Makefiles with checkmake | +| `devx-workflow-lint` | Static lint of Gitea Actions YAML (actionlint) | +| `devx-workflow-dryrun` | Dry-run all workflows (act_runner) | +| `devx-workflow-dryrun-safe` | Best-effort dry-run (skips if act_runner missing) | +| `devx-workflow-check` | Static lint + dry-run | +| `devx-notify-failure` | Create Gitea issue on CI failure | +| `devx-lint-ruff` | Run ruff check | +| `devx-lint-format` | Run ruff format --check | +| `devx-typecheck` | Run pyright | +| `devx-lint-bandit` | Run bandit security scan | +| `devx-lint-deps` | Check dependencies for vulnerabilities (pip-audit) | +| `devx-lint` | Run all lint targets | +| `devx-test-unit` | Run unit tests without coverage | +| `devx-pytest-cov` | Run pytest with coverage enforcement | +| `devx-check-mutable-globals` | Scan for mutable path globals | +| `devx-check-dep-docs` | Validate pyproject.toml deps are documented | +| `devx-check-test-coverage` | Check changed files have corresponding tests | +| `devx-check-docs` | Validate docs for stale references | +| `devx-check-test-speed` | Verify test suite timing | +| `devx-pre-push` | Run lint + tests before push | +| `devx-clean` | Remove caches, build artifacts, coverage data | + +**Variables** (set BEFORE including devx.mak): +- `DEVX_PYTHON` — Python executable (default: `python3`) +- `DEVX_VENV` — venv directory (default: `.venv`) +- `DEVX_BIN` — venv bin directory (default: `$(DEVX_VENV)/bin`) +- `DEVX_LINT_PATHS` — paths for ruff/bandit (default: `src/ tests/`) +- `DEVX_COV_PKG` — coverage package (default: `src/devx`) +- `DEVX_TEST_PATHS` — pytest paths (default: `tests/`) +- `DEVX_PR_BASE` — PR base branch (default: `master`) + +**Usage in project Makefile**: +```makefile +DEVX_PYTHON := $(BIN)/python +DEVX_MAK := $(shell $(BIN)/python -c \ + "from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \ + 2>/dev/null) +-include $(DEVX_MAK) + +# Aliases for project-specific names +lint-ruff: devx-lint-ruff +workflow-lint: devx-workflow-lint +create-task: devx-create-task +``` + ## Key Conventions - Python 3.12+ required (ruff/pyright target `py312`) diff --git a/LICENSE b/LICENSE index c71be2f..0e5ae83 100644 --- a/LICENSE +++ b/LICENSE @@ -208,8 +208,8 @@ If you develop a new program, and you want it to be of the greatest possible use To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found. - grm - Copyright (C) 2026 emil + devx + Copyright (C) 2026 oblachno-oss This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. @@ -221,7 +221,7 @@ Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: - grm Copyright (C) 2026 emil + devx Copyright (C) 2026 oblachno-oss This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. diff --git a/Makefile b/Makefile index 4bd629e..cf6e215 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all setup setup-ci setup-quality setup-release install update lint lint-ruff lint-format typecheck lint-bandit lint-deps lint-all test test-unit pytest-cov clean workflow-lint workflow-dryrun workflow-check install-tools install-hooks activate-scripts +.PHONY: all setup setup-ci setup-quality setup-release install update lint lint-all test test-unit pytest-cov clean install-tools install-hooks activate-scripts checkmake check-mutable-globals check-dep-docs check-test-speed PYTHON := python3 VENV := .venv @@ -52,48 +52,56 @@ install-tools: $(VENV)/bin/activate @$(BIN)/pip install -e '.' 2>/dev/null; \ $(BIN)/python -m devx.tools.install_tools -lint-ruff: - $(BIN)/ruff check src/ tests/ +# --- devx.mak integration ---------------------------------------------------- +# Include shared targets from the devx package itself (workflow-lint, +# notify-failure, checkmake, lint targets, quality checks, etc.) +# Since devx IS the package, we can include its own devx.mak. +DEVX_PYTHON := $(BIN)/python +DEVX_VENV := $(VENV) +DEVX_BIN := $(BIN) +DEVX_LINT_PATHS := src/ tests/ +DEVX_COV_PKG := src/devx +DEVX_TEST_PATHS := tests/ -lint-format: - $(BIN)/ruff format --check src/ tests/ +DEVX_MAK := $(shell $(BIN)/python -c \ + "from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \ + 2>/dev/null) +-include $(DEVX_MAK) -typecheck: - $(BIN)/pyright - -lint-bandit: - $(BIN)/bandit -r src/ - -lint: lint-ruff lint-format typecheck lint-bandit - -lint-deps: - @echo "Checking dependencies for known vulnerabilities..." - @.venv/bin/python -m ensurepip 2>/dev/null || true - @PIPAPI_PYTHON_LOCATION=$$(pwd)/.venv/bin/python .venv/bin/pip-audit --desc --skip-editable 2>&1 || true +# Aliases — project-specific names map to devx.mak targets +lint-ruff: devx-lint-ruff +lint-format: devx-lint-format +typecheck: devx-typecheck +lint-bandit: devx-lint-bandit +lint-deps: devx-lint-deps +lint: devx-lint +workflow-lint: devx-workflow-lint +workflow-dryrun: devx-workflow-dryrun +workflow-dryrun-safe: devx-workflow-dryrun-safe +workflow-check: devx-workflow-check +notify-failure: devx-notify-failure +checkmake: devx-checkmake +check-mutable-globals: devx-check-mutable-globals +check-dep-docs: devx-check-dep-docs +check-test-speed: devx-check-test-speed +check-test-coverage: devx-check-test-coverage +check-docs: devx-check-docs +create-task: devx-create-task +create-pr: devx-create-pr +push-with-pr: devx-push-with-pr +git-push: devx-push lint-all: lint workflow-lint + @echo "[lint-all] All linting checks passed." -workflow-lint: - @command -v actionlint >/dev/null 2>&1 || { echo "actionlint not found."; exit 1; } - actionlint -config-file .gitea/actionlint.yaml .gitea/workflows/*.yml +test-unit: devx-test-unit -workflow-dryrun: - @command -v act_runner >/dev/null 2>&1 || { echo "act_runner not found."; exit 1; } - @echo "Dry-running all workflows..." - act_runner exec --dryrun -W .gitea/workflows/ 2>&1 | grep -E 'DRYRUN|ERROR|FAIL|Job' - -workflow-check: workflow-lint workflow-dryrun - @echo "Workflow checks passed." - -test-unit: - $(BIN)/pytest tests/unit/ -v --no-cov - -pytest-cov: - $(BIN)/pytest tests/ -v --cov=src/devx --cov-report=term-missing --cov-fail-under=100 +pytest-cov: devx-pytest-cov test: pytest-cov -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/ +pre-push: lint-all pytest-cov + @echo "[pre-push] All checks passed. Proceeding with push." + +clean: devx-clean + @echo "[clean] Done." diff --git a/src/devx/ci/check_auto_merge_ready.py b/src/devx/ci/check_auto_merge_ready.py new file mode 100644 index 0000000..0e6ad2f --- /dev/null +++ b/src/devx/ci/check_auto_merge_ready.py @@ -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: `` 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 diff --git a/src/devx/make/devx.mak b/src/devx/make/devx.mak index f3b4e23..bc32f77 100644 --- a/src/devx/make/devx.mak +++ b/src/devx/make/devx.mak @@ -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 diff --git a/src/devx/tools/check_agent_docs.py b/src/devx/tools/check_agent_docs.py new file mode 100644 index 0000000..19c2ae0 --- /dev/null +++ b/src/devx/tools/check_agent_docs.py @@ -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 diff --git a/src/devx/tools/check_mutable_globals.py b/src/devx/tools/check_mutable_globals.py new file mode 100644 index 0000000..297e9a3 --- /dev/null +++ b/src/devx/tools/check_mutable_globals.py @@ -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 diff --git a/src/devx/tools/check_pyproject_deps.py b/src/devx/tools/check_pyproject_deps.py new file mode 100644 index 0000000..38d6219 --- /dev/null +++ b/src/devx/tools/check_pyproject_deps.py @@ -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 diff --git a/src/devx/tools/check_test_coverage.py b/src/devx/tools/check_test_coverage.py new file mode 100644 index 0000000..84fe7ab --- /dev/null +++ b/src/devx/tools/check_test_coverage.py @@ -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()) diff --git a/src/devx/translations.json b/src/devx/translations.json index 0ee80af..f5ee6d3 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -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" } } diff --git a/tests/unit/test_check_agent_docs.py b/tests/unit/test_check_agent_docs.py new file mode 100644 index 0000000..2080acd --- /dev/null +++ b/tests/unit/test_check_agent_docs.py @@ -0,0 +1,222 @@ +"""Unit tests for devx.tools.check_agent_docs.""" + +import re +from pathlib import Path +from unittest.mock import patch + +from click.testing import CliRunner + +from devx.tools.check_agent_docs import ( + DEFAULT_REPO_PATH_PREFIXES, + DEFAULT_SCAN_DIRS, + DEFAULT_SCAN_EXTENSIONS, + DEFAULT_SCAN_FILES, + MIN_PATH_REF_LENGTH_DEFAULT, + _check_file, + _collect_doc_files, + _is_legitimate_ref, + _should_skip, + cli, +) + + +class TestShouldSkip: + def test_skips_excluded_path(self, tmp_path: Path) -> None: + f = tmp_path / "docs" / "retrospectives" / "r.md" + f.parent.mkdir(parents=True) + f.write_text("") + assert _should_skip(f, ["docs/retrospectives"], tmp_path) is True + + def test_does_not_skip_normal(self, tmp_path: Path) -> None: + f = tmp_path / "docs" / "guide.md" + f.parent.mkdir(parents=True) + f.write_text("") + assert _should_skip(f, ["docs/retrospectives"], tmp_path) is False + + def test_returns_false_for_path_outside_repo(self, tmp_path: Path) -> None: + f = Path("/tmp/some_other_path/guide.md") + assert _should_skip(f, [], tmp_path) is False + + +class TestIsLegitimateRef: + def test_legitimate_legacy(self) -> None: + assert _is_legitimate_ref("This is legacy code", ["legacy"]) is True + + def test_not_legitimate(self) -> None: + assert _is_legitimate_ref("Use this file", ["legacy"]) is False + + def test_case_insensitive(self) -> None: + assert _is_legitimate_ref("This is LEGACY", ["legacy"]) is True + + +class TestCollectDocFiles: + def test_collects_devin_and_docs(self, tmp_path: Path) -> None: + (tmp_path / ".devin").mkdir() + (tmp_path / ".devin" / "guide.md").write_text("") + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "api.md").write_text("") + (tmp_path / "README.md").write_text("") + + files = _collect_doc_files(tmp_path, DEFAULT_SCAN_DIRS, DEFAULT_SCAN_FILES, DEFAULT_SCAN_EXTENSIONS, []) + names = {f.name for f in files} + assert "guide.md" in names + assert "api.md" in names + assert "README.md" in names + + def test_excludes_paths(self, tmp_path: Path) -> None: + (tmp_path / "docs" / "retrospectives").mkdir(parents=True) + (tmp_path / "docs" / "retrospectives" / "r.md").write_text("") + (tmp_path / "docs" / "guide.md").write_text("") + + files = _collect_doc_files( + tmp_path, DEFAULT_SCAN_DIRS, DEFAULT_SCAN_FILES, DEFAULT_SCAN_EXTENSIONS, ["docs/retrospectives"] + ) + names = {f.name for f in files} + assert "guide.md" in names + assert "r.md" not in names + + def test_deduplicates(self, tmp_path: Path) -> None: + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "api.md").write_text("") + + files = _collect_doc_files(tmp_path, ["docs", "docs"], DEFAULT_SCAN_FILES, DEFAULT_SCAN_EXTENSIONS, []) + assert len(files) == 1 + + +class TestCheckFile: + def test_detects_deleted_file_ref(self, tmp_path: Path) -> None: + doc = tmp_path / "docs" / "guide.md" + doc.parent.mkdir(parents=True) + doc.write_text("See scripts/old.py for details.\n") + issues = _check_file( + doc, tmp_path, {"scripts/old.py"}, [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT + ) + assert any("deleted file" in i for i in issues) + + def test_detects_nonexistent_file_ref(self, tmp_path: Path) -> None: + doc = tmp_path / "docs" / "guide.md" + doc.parent.mkdir(parents=True) + doc.write_text("See scripts/nonexistent.py for details.\n") + issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT) + assert any("non-existent file" in i for i in issues) + + def test_does_not_flag_existing_file(self, tmp_path: Path) -> None: + (tmp_path / "scripts").mkdir() + (tmp_path / "scripts" / "exists.py").write_text("") + doc = tmp_path / "docs" / "guide.md" + doc.parent.mkdir(parents=True) + doc.write_text("See scripts/exists.py for details.\n") + issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT) + assert issues == [] + + def test_detects_deprecated_pattern(self, tmp_path: Path) -> None: + doc = tmp_path / "docs" / "guide.md" + doc.parent.mkdir(parents=True) + doc.write_text("Use ansible/envs/prod/secrets.yml for config.\n") + patterns = [re.compile(r"ansible/envs/[^/]+/secrets\.yml")] + issues = _check_file( + doc, tmp_path, set(), patterns, [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT + ) + assert any("deprecated pattern" in i for i in issues) + + def test_legitimate_ref_skips_deprecated(self, tmp_path: Path) -> None: + # Create the referenced file so the non-existent check doesn't trigger + secrets = tmp_path / "ansible" / "envs" / "prod" / "secrets.yml" + secrets.parent.mkdir(parents=True) + secrets.write_text("") + doc = tmp_path / "docs" / "guide.md" + doc.parent.mkdir(parents=True) + doc.write_text("The legacy ansible/envs/prod/secrets.yml is deprecated.\n") + patterns = [re.compile(r"ansible/envs/[^/]+/secrets\.yml")] + issues = _check_file( + doc, tmp_path, set(), patterns, ["deprecated"], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT + ) + assert issues == [] + + def test_unicode_error_returns_empty(self, tmp_path: Path) -> None: + doc = tmp_path / "docs" / "guide.md" + doc.parent.mkdir(parents=True) + doc.write_bytes(b"\xff\xfe\x00\x00") + issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT) + assert issues == [] + + def test_skips_short_ref(self, tmp_path: Path) -> None: + doc = tmp_path / "docs" / "guide.md" + doc.parent.mkdir(parents=True) + doc.write_text("See a.py for details.\n") + issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, 5) + # "a.py" is only 4 chars, below min_path_ref_length + assert issues == [] + + def test_skips_ref_without_repo_prefix(self, tmp_path: Path) -> None: + doc = tmp_path / "docs" / "guide.md" + doc.parent.mkdir(parents=True) + doc.write_text("See vendor/some/long/path.py for details.\n") + issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT) + # "vendor/" is not in repo_path_prefixes + assert issues == [] + + +class TestCli: + def test_passes_when_no_issues(self, tmp_path: Path) -> None: + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "guide.md").write_text("All good.\n") + runner = CliRunner() + with ( + patch("devx.tools.check_agent_docs._load_config", return_value={}), + patch("devx.tools.check_agent_docs.Path.cwd", return_value=tmp_path), + ): + result = runner.invoke(cli, []) + assert result.exit_code == 0 + assert "Passed" in result.output + + def test_fails_when_stale_ref(self, tmp_path: Path) -> None: + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "guide.md").write_text("See scripts/deleted.py\n") + cfg = {"deleted_files": ["scripts/deleted.py"]} + runner = CliRunner() + with ( + patch("devx.tools.check_agent_docs._load_config", return_value=cfg), + patch("devx.tools.check_agent_docs.Path.cwd", return_value=tmp_path), + ): + result = runner.invoke(cli, []) + assert result.exit_code != 0 + assert "FAILED" in result.output + + def test_load_config_returns_empty_when_not_dict(self) -> None: + from devx.tools.check_agent_docs import _load_config + + with patch("devx.tools.check_agent_docs._load_pyproject_devx", return_value={"check_agent_docs": "not a dict"}): + assert _load_config() == {} + + def test_load_config_returns_dict_when_valid(self) -> None: + from devx.tools.check_agent_docs import _load_config + + cfg = {"scan_dirs": ["custom"]} + with patch("devx.tools.check_agent_docs._load_pyproject_devx", return_value={"check_agent_docs": cfg}): + assert _load_config() == cfg + + def test_invalid_regex_pattern_skipped(self, tmp_path: Path) -> None: + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "guide.md").write_text("All good.\n") + cfg = {"deprecated_patterns": ["[invalid"]} + runner = CliRunner() + with ( + patch("devx.tools.check_agent_docs._load_config", return_value=cfg), + patch("devx.tools.check_agent_docs.Path.cwd", return_value=tmp_path), + ): + result = runner.invoke(cli, []) + assert result.exit_code == 0 + + def test_custom_scan_dirs(self, tmp_path: Path) -> None: + custom = tmp_path / "custom_docs" + custom.mkdir() + (custom / "guide.md").write_text("See scripts/deleted.py\n") + cfg = {"scan_dirs": ["custom_docs"], "deleted_files": ["scripts/deleted.py"]} + runner = CliRunner() + with ( + patch("devx.tools.check_agent_docs._load_config", return_value=cfg), + patch("devx.tools.check_agent_docs.Path.cwd", return_value=tmp_path), + ): + result = runner.invoke(cli, []) + assert result.exit_code != 0 diff --git a/tests/unit/test_check_auto_merge_ready.py b/tests/unit/test_check_auto_merge_ready.py new file mode 100644 index 0000000..9b10936 --- /dev/null +++ b/tests/unit/test_check_auto_merge_ready.py @@ -0,0 +1,292 @@ +"""Unit tests for devx.ci.check_auto_merge_ready.""" + +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +from devx.ci.check_auto_merge_ready import ( + cli, + get_pr_title_from_gitea, + get_vikunja_title_optional, + is_branch_behind_master, +) + + +class TestIsBranchBehindMaster: + @patch("devx.ci.check_auto_merge_ready.subprocess.run") + def test_returns_false_when_ahead(self, mock_run: MagicMock) -> None: + # First: fetch (ok), second: ahead count (ok), third: behind count = 0 + mock_run.side_effect = [ + MagicMock(returncode=0, stdout="", stderr=""), + MagicMock(returncode=0, stdout="3\n", stderr=""), + MagicMock(returncode=0, stdout="0\n", stderr=""), + ] + assert is_branch_behind_master("feature") is False + + @patch("devx.ci.check_auto_merge_ready.subprocess.run") + def test_returns_true_when_behind(self, mock_run: MagicMock) -> None: + mock_run.side_effect = [ + MagicMock(returncode=0, stdout="", stderr=""), + MagicMock(returncode=0, stdout="0\n", stderr=""), + MagicMock(returncode=0, stdout="5\n", stderr=""), + ] + assert is_branch_behind_master("feature") is True + + @patch("devx.ci.check_auto_merge_ready.subprocess.run") + def test_returns_false_on_git_error(self, mock_run: MagicMock) -> None: + mock_run.side_effect = [ + MagicMock(returncode=0, stdout="", stderr=""), + MagicMock(returncode=1, stdout="", stderr="error"), + ] + assert is_branch_behind_master("feature") is False + + @patch("devx.ci.check_auto_merge_ready.subprocess.run") + def test_returns_false_on_timeout(self, mock_run: MagicMock) -> None: + import subprocess + + mock_run.side_effect = subprocess.TimeoutExpired(cmd="git", timeout=30) + assert is_branch_behind_master("feature") is False + + @patch("devx.ci.check_auto_merge_ready.subprocess.run") + def test_returns_false_on_value_error(self, mock_run: MagicMock) -> None: + mock_run.side_effect = [ + MagicMock(returncode=0, stdout="", stderr=""), + MagicMock(returncode=0, stdout="3\n", stderr=""), + MagicMock(returncode=0, stdout="not_a_number\n", stderr=""), + ] + assert is_branch_behind_master("feature") is False + + @patch("devx.ci.check_auto_merge_ready.subprocess.run") + def test_returns_false_on_file_not_found(self, mock_run: MagicMock) -> None: + mock_run.side_effect = FileNotFoundError("git not found") + assert is_branch_behind_master("feature") is False + + @patch("devx.ci.check_auto_merge_ready.subprocess.run") + def test_returns_false_when_behind_check_fails(self, mock_run: MagicMock) -> None: + # fetch ok, ahead count ok, behind count command fails + mock_run.side_effect = [ + MagicMock(returncode=0, stdout="", stderr=""), + MagicMock(returncode=0, stdout="3\n", stderr=""), + MagicMock(returncode=1, stdout="", stderr="error"), + ] + assert is_branch_behind_master("feature") is False + + +class TestGetPrTitleFromGitea: + def test_returns_none_without_token(self) -> None: + with patch.dict("os.environ", {}, clear=True): + assert get_pr_title_from_gitea("owner/repo", 1) is None + + def test_returns_none_with_invalid_repo(self) -> None: + with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True): + assert get_pr_title_from_gitea("invalid", 1) is None + + @patch("devx.ci.check_auto_merge_ready.GiteaClient") + def test_fetches_title(self, mock_client_cls: MagicMock) -> None: + mock_client = MagicMock() + mock_client.get_pr.return_value = {"title": "DEVX-1: Fix bug"} + mock_client_cls.return_value = mock_client + with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True): + result = get_pr_title_from_gitea("owner/repo", 1) + assert result == "DEVX-1: Fix bug" + + @patch("devx.ci.check_auto_merge_ready.GiteaClient") + def test_returns_none_on_exception(self, mock_client_cls: MagicMock) -> None: + mock_client = MagicMock() + mock_client.get_pr.side_effect = Exception("API error") + mock_client_cls.return_value = mock_client + with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True): + result = get_pr_title_from_gitea("owner/repo", 1) + assert result is None + + +class TestGetVikunjaTitleOptional: + def test_returns_none_without_token(self) -> None: + with patch.dict("os.environ", {}, clear=True): + assert get_vikunja_title_optional("DEVX-1") is None + + @patch("devx.ci.check_auto_merge_ready.VikunjaClient") + def test_returns_title_when_found(self, mock_client_cls: MagicMock) -> None: + mock_client = MagicMock() + mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-1", "title": "Fix bug"}] + mock_client_cls.return_value = mock_client + with patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True): + result = get_vikunja_title_optional("DEVX-1") + assert result == "Fix bug" + + @patch("devx.ci.check_auto_merge_ready.VikunjaClient") + def test_returns_none_when_not_found(self, mock_client_cls: MagicMock) -> None: + mock_client = MagicMock() + mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-2", "title": "Other task"}] + mock_client_cls.return_value = mock_client + with patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True): + result = get_vikunja_title_optional("DEVX-1") + assert result is None + + @patch("devx.ci.check_auto_merge_ready.VikunjaClient") + def test_paginates_until_found(self, mock_client_cls: MagicMock) -> None: + from devx.config import DEFAULT_PER_PAGE + + mock_client = MagicMock() + # First page: full page of non-matching tasks, second page: match + page1 = [{"identifier": f"DEVX-{i}", "title": f"Task {i}"} for i in range(DEFAULT_PER_PAGE)] + page2 = [{"identifier": "DEVX-99", "title": "Found it"}] + mock_client.list_project_tasks.side_effect = [page1, page2] + mock_client_cls.return_value = mock_client + with patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True): + result = get_vikunja_title_optional("DEVX-99") + assert result == "Found it" + + @patch("devx.ci.check_auto_merge_ready.VikunjaClient") + def test_returns_none_when_empty_first_page(self, mock_client_cls: MagicMock) -> None: + mock_client = MagicMock() + mock_client.list_project_tasks.return_value = [] + mock_client_cls.return_value = mock_client + with patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True): + result = get_vikunja_title_optional("DEVX-1") + assert result is None + + +class TestCli: + def test_fails_without_task_id(self) -> None: + runner = CliRunner() + with patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX"}, clear=True): + result = runner.invoke(cli, ["--branch", "no-task-id-here"]) + assert result.exit_code != 0 + + def test_local_mode_no_pr_title(self) -> None: + runner = CliRunner() + with ( + patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True), + patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False), + ): + result = runner.invoke(cli, ["--branch", "DEVX-1-fix-foo"]) + assert result.exit_code == 0 + assert "local mode" in result.output + + def test_validates_pr_title_format(self) -> None: + runner = CliRunner() + with ( + patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True), + patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False), + ): + result = runner.invoke(cli, ["--branch", "DEVX-1-fix-foo", "--pr-title", "Bad title"]) + assert result.exit_code != 0 + assert "format" in result.output.lower() or "mismatch" in result.output.lower() + + def test_passes_with_valid_title(self) -> None: + runner = CliRunner() + with ( + patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True), + patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False), + ): + result = runner.invoke(cli, ["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"]) + assert result.exit_code == 0 + assert "satisfied" in result.output + + def test_skip_behind_check(self) -> None: + runner = CliRunner() + with ( + patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True), + patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=True), + ): + result = runner.invoke( + cli, + ["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo", "--skip-behind-check"], + ) + assert result.exit_code == 0 + + def test_fails_when_behind_master(self) -> None: + runner = CliRunner() + with ( + patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True), + patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=True), + ): + result = runner.invoke( + cli, + ["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"], + ) + assert result.exit_code != 0 + assert "behind" in result.output.lower() + + def test_skip_vikunja(self) -> None: + runner = CliRunner() + with ( + patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True), + patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False), + patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value="Different title"), + ): + result = runner.invoke( + cli, + ["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo", "--skip-vikunja"], + ) + assert result.exit_code == 0 + + def test_fetches_pr_title_from_gitea(self) -> None: + runner = CliRunner() + with ( + patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True), + patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False), + patch("devx.ci.check_auto_merge_ready.get_pr_title_from_gitea", return_value="DEVX-1: Fix foo"), + ): + result = runner.invoke( + cli, + ["--branch", "DEVX-1-fix-foo", "--repo", "owner/repo", "--pr-number", "1"], + ) + assert result.exit_code == 0 + assert "from Gitea" in result.output + + def test_fails_when_pr_number_but_no_title(self) -> None: + runner = CliRunner() + with ( + patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True), + patch("devx.ci.check_auto_merge_ready.get_pr_title_from_gitea", return_value=None), + ): + result = runner.invoke( + cli, + ["--branch", "DEVX-1-fix-foo", "--repo", "owner/repo", "--pr-number", "1"], + ) + assert result.exit_code != 0 + assert "Could not fetch" in result.output + + def test_fails_when_vikunja_token_set_but_task_not_found(self) -> None: + runner = CliRunner() + with ( + patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True), + patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False), + patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value=None), + ): + result = runner.invoke( + cli, + ["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"], + ) + assert result.exit_code != 0 + assert "Could not find Vikunja task" in result.output + + def test_passes_with_vikunja_title_match(self) -> None: + runner = CliRunner() + with ( + patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True), + patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False), + patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value="Fix foo"), + ): + result = runner.invoke( + cli, + ["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"], + ) + assert result.exit_code == 0 + assert "Vikunja title match OK" in result.output + + def test_fails_with_vikunja_title_mismatch(self) -> None: + runner = CliRunner() + with ( + patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True), + patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False), + patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value="Different title"), + ): + result = runner.invoke( + cli, + ["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"], + ) + assert result.exit_code != 0 + assert "does not match Vikunja" in result.output diff --git a/tests/unit/test_check_mutable_globals.py b/tests/unit/test_check_mutable_globals.py new file mode 100644 index 0000000..c7fe1d5 --- /dev/null +++ b/tests/unit/test_check_mutable_globals.py @@ -0,0 +1,249 @@ +"""Unit tests for devx.tools.check_mutable_globals.""" + +from pathlib import Path +from unittest.mock import patch + +from click.testing import CliRunner + +from devx.tools.check_mutable_globals import ( + DEFAULT_SCAN_DIRS, + DEFAULT_SKIP_DIRS, + _load_config, + _should_skip, + cli, + find_mutable_globals, +) + + +class TestFindMutableGlobals: + def test_detects_set_global_with_path_hint(self, tmp_path: Path) -> None: + source = "_SEEN: set[Path] = set()\n" + f = tmp_path / "mod.py" + f.write_text(source) + issues = find_mutable_globals(f, tmp_path, set()) + assert len(issues) == 1 + assert "_SEEN" in issues[0] + assert "set()" in issues[0] + + def test_detects_dict_global_with_path_hint(self, tmp_path: Path) -> None: + source = "_CACHE: dict[Path, Any] = {}\n" + f = tmp_path / "mod.py" + f.write_text(source) + issues = find_mutable_globals(f, tmp_path, set()) + assert len(issues) == 1 + assert "_CACHE" in issues[0] + + def test_detects_list_global_with_path_hint(self, tmp_path: Path) -> None: + source = "PATHS: list[Path] = []\n" + f = tmp_path / "mod.py" + f.write_text(source) + issues = find_mutable_globals(f, tmp_path, set()) + assert len(issues) == 1 + assert "PATHS" in issues[0] + + def test_skips_non_mutable_globals(self, tmp_path: Path) -> None: + source = "_MAX: int = 10\n_SEEN: set[Path] = set()\n" + f = tmp_path / "mod.py" + f.write_text(source) + issues = find_mutable_globals(f, tmp_path, set()) + assert len(issues) == 1 + assert "_SEEN" in issues[0] + + def test_skips_globals_without_path_hint(self, tmp_path: Path) -> None: + source = "_DATA: dict[str, int] = {}\n" + f = tmp_path / "mod.py" + f.write_text(source) + issues = find_mutable_globals(f, tmp_path, set()) + assert len(issues) == 0 + + def test_detects_path_type_annotation(self, tmp_path: Path) -> None: + source = "_FILES: set[Path] = set()\n" + f = tmp_path / "mod.py" + f.write_text(source) + issues = find_mutable_globals(f, tmp_path, set()) + assert len(issues) == 1 + + def test_known_safe_exception(self, tmp_path: Path) -> None: + source = "_SEEN: set[Path] = set()\n" + f = tmp_path / "mod.py" + f.write_text(source) + known_safe = {("mod.py", 1, "_SEEN")} + issues = find_mutable_globals(f, tmp_path, known_safe) + assert len(issues) == 0 + + def test_syntax_error_returns_empty(self, tmp_path: Path) -> None: + f = tmp_path / "mod.py" + f.write_text("def broken(:\n") + issues = find_mutable_globals(f, tmp_path, set()) + assert issues == [] + + def test_detects_mutable_literal_dict(self, tmp_path: Path) -> None: + source = "_CACHE: dict[Path, Any] = {}\n" + f = tmp_path / "mod.py" + f.write_text(source) + issues = find_mutable_globals(f, tmp_path, set()) + assert len(issues) == 1 + + def test_detects_mutable_literal_list(self, tmp_path: Path) -> None: + source = "SEEN_PATHS: list[Path] = []\n" + f = tmp_path / "mod.py" + f.write_text(source) + issues = find_mutable_globals(f, tmp_path, set()) + assert len(issues) == 1 + + def test_detects_mutable_literal_set(self, tmp_path: Path) -> None: + source = "REGISTRY: set[Path] = set()\n" + f = tmp_path / "mod.py" + f.write_text(source) + issues = find_mutable_globals(f, tmp_path, set()) + assert len(issues) == 1 + + def test_skips_function_definitions(self, tmp_path: Path) -> None: + source = "def foo():\n pass\n" + f = tmp_path / "mod.py" + f.write_text(source) + issues = find_mutable_globals(f, tmp_path, set()) + assert issues == [] + + def test_handles_assign_with_name_target(self, tmp_path: Path) -> None: + source = "SEEN_PATHS = set()\n" + f = tmp_path / "mod.py" + f.write_text(source) + issues = find_mutable_globals(f, tmp_path, set()) + assert len(issues) == 1 + assert "SEEN_PATHS" in issues[0] + + def test_skips_annotation_without_value(self, tmp_path: Path) -> None: + source = "_CACHE: dict[Path, Any]\n" + f = tmp_path / "mod.py" + f.write_text(source) + issues = find_mutable_globals(f, tmp_path, set()) + assert issues == [] + + def test_skips_attribute_call(self, tmp_path: Path) -> None: + # collections.defaultdict is an Attribute call, not a Name call + source = "_CACHE: dict[Path, Any] = collections.defaultdict(list)\n" + f = tmp_path / "mod.py" + f.write_text(source) + issues = find_mutable_globals(f, tmp_path, set()) + # Attribute calls are skipped (pass), so not flagged as mutable literal + assert issues == [] + + def test_multiple_assign_targets(self, tmp_path: Path) -> None: + source = "SEEN = CACHE = set()\n" + f = tmp_path / "mod.py" + f.write_text(source) + issues = find_mutable_globals(f, tmp_path, set()) + # Both SEEN and CACHE should be flagged + assert len(issues) == 2 + + +class TestShouldSkip: + def test_skips_pycache(self) -> None: + assert _should_skip(Path("/a/__pycache__/b.py"), DEFAULT_SKIP_DIRS) is True + + def test_skips_venv(self) -> None: + assert _should_skip(Path("/a/.venv/b.py"), DEFAULT_SKIP_DIRS) is True + + def test_does_not_skip_normal(self) -> None: + assert _should_skip(Path("/a/src/b.py"), DEFAULT_SKIP_DIRS) is False + + +class TestLoadConfig: + def test_defaults_when_no_pyproject(self, tmp_path: Path) -> None: + with patch("devx.tools.check_mutable_globals._load_pyproject_devx", return_value={}): + scan_dirs, skip_dirs, known_safe = _load_config() + assert scan_dirs == DEFAULT_SCAN_DIRS + assert skip_dirs == DEFAULT_SKIP_DIRS + assert known_safe == set() + + def test_reads_config_from_pyproject(self) -> None: + cfg = { + "check_mutable_globals": { + "scan_dirs": ["src", "tests"], + "skip_dirs": ["__pycache__", ".tox"], + "known_safe": ["src/mod.py:10:_CACHE"], + } + } + with patch("devx.tools.check_mutable_globals._load_pyproject_devx", return_value=cfg): + scan_dirs, skip_dirs, known_safe = _load_config() + assert scan_dirs == ["src", "tests"] + assert ".tox" in skip_dirs + assert ("src/mod.py", 10, "_CACHE") in known_safe + + def test_returns_defaults_when_cfg_not_dict(self) -> None: + with patch( + "devx.tools.check_mutable_globals._load_pyproject_devx", + return_value={"check_mutable_globals": "not a dict"}, + ): + scan_dirs, skip_dirs, known_safe = _load_config() + assert scan_dirs == DEFAULT_SCAN_DIRS + assert skip_dirs == DEFAULT_SKIP_DIRS + assert known_safe == set() + + def test_known_safe_with_invalid_line_number(self) -> None: + cfg = {"check_mutable_globals": {"known_safe": ["mod.py:abc:_CACHE"]}} + with patch("devx.tools.check_mutable_globals._load_pyproject_devx", return_value=cfg): + _, _, known_safe = _load_config() + assert known_safe == set() + + def test_scan_dirs_not_list_returns_default(self) -> None: + cfg = {"check_mutable_globals": {"scan_dirs": "not a list"}} + with patch("devx.tools.check_mutable_globals._load_pyproject_devx", return_value=cfg): + scan_dirs, _, _ = _load_config() + assert scan_dirs == DEFAULT_SCAN_DIRS + + +class TestCli: + def test_passes_when_no_issues(self, tmp_path: Path) -> None: + runner = CliRunner() + with ( + patch("devx.tools.check_mutable_globals._load_config", return_value=(["empty_dir"], set(), set())), + patch("devx.tools.check_mutable_globals.Path.cwd", return_value=tmp_path), + ): + result = runner.invoke(cli, []) + assert result.exit_code == 0 + assert "Passed" in result.output + + def test_fails_when_issues_found(self, tmp_path: Path) -> None: + scan_dir = tmp_path / "src" + scan_dir.mkdir() + (scan_dir / "mod.py").write_text("_SEEN: set[Path] = set()\n") + + runner = CliRunner() + with ( + patch("devx.tools.check_mutable_globals._load_config", return_value=(["src"], set(), set())), + patch("devx.tools.check_mutable_globals.Path.cwd", return_value=tmp_path), + ): + result = runner.invoke(cli, []) + assert result.exit_code != 0 + assert "FAILED" in result.output + + def test_scan_dir_option_overrides_config(self, tmp_path: Path) -> None: + scan_dir = tmp_path / "custom" + scan_dir.mkdir() + (scan_dir / "mod.py").write_text("_SEEN: set[Path] = set()\n") + + runner = CliRunner() + with ( + patch("devx.tools.check_mutable_globals._load_config", return_value=(["other"], set(), set())), + patch("devx.tools.check_mutable_globals.Path.cwd", return_value=tmp_path), + ): + result = runner.invoke(cli, ["--scan-dir", "custom"]) + assert result.exit_code != 0 + assert "FAILED" in result.output + + def test_skips_files_in_skip_dirs(self, tmp_path: Path) -> None: + scan_dir = tmp_path / "src" + pycache = scan_dir / "__pycache__" + pycache.mkdir(parents=True) + (pycache / "mod.py").write_text("_SEEN: set[Path] = set()\n") + + runner = CliRunner() + with ( + patch("devx.tools.check_mutable_globals._load_config", return_value=(["src"], {"__pycache__"}, set())), + patch("devx.tools.check_mutable_globals.Path.cwd", return_value=tmp_path), + ): + result = runner.invoke(cli, []) + assert result.exit_code == 0 + assert "Passed" in result.output diff --git a/tests/unit/test_check_pyproject_deps.py b/tests/unit/test_check_pyproject_deps.py new file mode 100644 index 0000000..32650b6 --- /dev/null +++ b/tests/unit/test_check_pyproject_deps.py @@ -0,0 +1,208 @@ +"""Unit tests for devx.tools.check_pyproject_deps.""" + +from pathlib import Path + +from click.testing import CliRunner + +from devx.tools.check_pyproject_deps import check_deps, cli + + +class TestCheckDeps: + def test_no_issues_when_all_documented(self, tmp_path: Path) -> None: + content = """\ +[project.dependencies] +# HTTP client +"requests>=2.0" +# CLI framework +"click>=8.0" +""" + f = tmp_path / "pyproject.toml" + f.write_text(content) + issues = check_deps(f) + assert issues == [] + + def test_finds_undocumented_dependency(self, tmp_path: Path) -> None: + content = """\ +[project.dependencies] +# HTTP client +"requests>=2.0" +"click>=8.0" +""" + f = tmp_path / "pyproject.toml" + f.write_text(content) + issues = check_deps(f) + assert len(issues) == 1 + assert "click" in issues[0] + + def test_finds_multiple_undocumented(self, tmp_path: Path) -> None: + content = """\ +[project.dependencies] +"requests>=2.0" +"click>=8.0" +""" + f = tmp_path / "pyproject.toml" + f.write_text(content) + issues = check_deps(f) + assert len(issues) == 2 + + def test_handles_optional_dependencies(self, tmp_path: Path) -> None: + content = """\ +[project.optional-dependencies] +ci = [ + # Test runner + "pytest>=8", + "pytest-cov>=4", +] +""" + f = tmp_path / "pyproject.toml" + f.write_text(content) + issues = check_deps(f) + assert len(issues) == 1 + assert "pytest-cov" in issues[0] + + def test_returns_file_not_found_for_missing_file(self, tmp_path: Path) -> None: + issues = check_deps(tmp_path / "nonexistent.toml") + assert len(issues) == 1 + assert "not found" in issues[0] + + def test_empty_deps_section_no_issues(self, tmp_path: Path) -> None: + content = """\ +[project.dependencies] +""" + f = tmp_path / "pyproject.toml" + f.write_text(content) + issues = check_deps(f) + assert issues == [] + + def test_skips_non_deps_sections(self, tmp_path: Path) -> None: + content = """\ +[project] +name = "test" +version = "0.1.0" + +[project.dependencies] +# HTTP +"requests>=2.0" +""" + f = tmp_path / "pyproject.toml" + f.write_text(content) + issues = check_deps(f) + assert issues == [] + + def test_handles_dash_prefixed_deps(self, tmp_path: Path) -> None: + content = """\ +[project.dependencies] +# HTTP client +-requests>=2.0 +""" + f = tmp_path / "pyproject.toml" + f.write_text(content) + issues = check_deps(f) + assert issues == [] + + def test_empty_lines_in_deps_section(self, tmp_path: Path) -> None: + content = """\ +[project.dependencies] + +# HTTP client +"requests>=2.0" + +# CLI +"click>=8.0" +""" + f = tmp_path / "pyproject.toml" + f.write_text(content) + issues = check_deps(f) + assert issues == [] + + def test_non_dep_non_comment_line_resets_prev(self, tmp_path: Path) -> None: + # A line that's not a comment, not a dep, not empty — resets prev_was_comment + content = """\ +[project.dependencies] +# Comment +ci = [ +"requests>=2.0", +] +""" + f = tmp_path / "pyproject.toml" + f.write_text(content) + issues = check_deps(f) + # "requests" is preceded by a comment, but the `ci = [` line resets prev_was_comment + # Actually `ci = [` doesn't start with - or ", so it hits the else branch + assert len(issues) == 1 + + def test_section_transition_exits_deps(self, tmp_path: Path) -> None: + content = """\ +[project.dependencies] +# HTTP +"requests>=2.0" + +[project.optional-dependencies] +# Test runner +"pytest>=8" +""" + f = tmp_path / "pyproject.toml" + f.write_text(content) + issues = check_deps(f) + # Both deps are documented + assert issues == [] + + def test_deps_after_other_section_not_checked(self, tmp_path: Path) -> None: + content = """\ +[project] +name = "test" + +[project.dependencies] +# Documented +"requests>=2.0" + +[tool.ruff] +line-length = 120 +"undocumented-dep>=1.0" +""" + f = tmp_path / "pyproject.toml" + f.write_text(content) + issues = check_deps(f) + # The "undocumented-dep" is in [tool.ruff], not a deps section + assert issues == [] + + +class TestCli: + def test_passes_when_all_documented(self, tmp_path: Path) -> None: + content = """\ +[project.dependencies] +# HTTP client +"requests>=2.0" +""" + f = tmp_path / "pyproject.toml" + f.write_text(content) + runner = CliRunner() + with __import__("contextlib").chdir(tmp_path): + result = runner.invoke(cli, []) + assert result.exit_code == 0 + assert "Passed" in result.output + + def test_fails_when_undocumented(self, tmp_path: Path) -> None: + content = """\ +[project.dependencies] +"requests>=2.0" +""" + f = tmp_path / "pyproject.toml" + f.write_text(content) + runner = CliRunner() + with __import__("contextlib").chdir(tmp_path): + result = runner.invoke(cli, []) + assert result.exit_code != 0 + assert "FAILED" in result.output + + def test_custom_file_option(self, tmp_path: Path) -> None: + content = """\ +[project.dependencies] +# Documented +"requests>=2.0" +""" + f = tmp_path / "custom.toml" + f.write_text(content) + runner = CliRunner() + result = runner.invoke(cli, ["--file", str(f)]) + assert result.exit_code == 0 diff --git a/tests/unit/test_check_test_coverage.py b/tests/unit/test_check_test_coverage.py new file mode 100644 index 0000000..e0cb60a --- /dev/null +++ b/tests/unit/test_check_test_coverage.py @@ -0,0 +1,239 @@ +"""Unit tests for devx.tools.check_test_coverage.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from devx.tools.check_test_coverage import ( + BUILTIN_RULES, + DEFAULT_SKIP_EXTENSIONS, + DEFAULT_TEST_INDICATORS, + _changed_files, + _find_missing_tests, + _is_test_file, + _load_rules, + _resolve_test_path, + _should_skip_file, + main, +) + + +class TestIsTestFile: + def test_tests_dir(self) -> None: + assert _is_test_file("tests/unit/test_foo.py", DEFAULT_TEST_INDICATORS) is True + + def test_test_prefix(self) -> None: + assert _is_test_file("src/test_foo.py", DEFAULT_TEST_INDICATORS) is True + + def test_test_suffix(self) -> None: + assert _is_test_file("src/foo_test.py", DEFAULT_TEST_INDICATORS) is True + + def test_non_test_file(self) -> None: + assert _is_test_file("src/foo.py", DEFAULT_TEST_INDICATORS) is False + + +class TestShouldSkipFile: + def test_skips_dotfiles(self) -> None: + assert _should_skip_file(".gitignore", [], DEFAULT_SKIP_EXTENSIONS) is True + + def test_skips_markdown(self) -> None: + assert _should_skip_file("README.md", [], DEFAULT_SKIP_EXTENSIONS) is True + + def test_skips_yaml(self) -> None: + assert _should_skip_file("config.yml", [], DEFAULT_SKIP_EXTENSIONS) is True + + def test_does_not_skip_python(self) -> None: + assert _should_skip_file("src/foo.py", [], DEFAULT_SKIP_EXTENSIONS) is False + + def test_skips_by_pattern(self) -> None: + assert _should_skip_file("src/__init__.py", ["__init__.py"], DEFAULT_SKIP_EXTENSIONS) is True + + def test_skips_by_glob_pattern(self) -> None: + assert _should_skip_file("src/config.py", ["config.py"], DEFAULT_SKIP_EXTENSIONS) is True + + +class TestResolveTestPath: + def test_resolves_name(self, tmp_path: Path) -> None: + result = _resolve_test_path("tests/unit/test_{name}", "src/foo.py", tmp_path) + assert result == tmp_path / "tests" / "unit" / "test_foo" + + def test_resolves_module(self, tmp_path: Path) -> None: + result = _resolve_test_path("tests/unit/test_{module}_{name}", "src/pkg/foo.py", tmp_path) + assert result == tmp_path / "tests" / "unit" / "test_pkg_foo" + + def test_resolves_package_prefix(self, tmp_path: Path) -> None: + result = _resolve_test_path( + "tests/unit/test_{package_prefix}_{name}", + "scripts/utils/secrets.py", + tmp_path, + ) + assert result == tmp_path / "tests" / "unit" / "test_utils_secrets" + + def test_normalizes_hyphens(self, tmp_path: Path) -> None: + result = _resolve_test_path("tests/test_{name}", "scripts/my-script.py", tmp_path) + assert result == tmp_path / "tests" / "test_my_script" + + +class TestFindMissingTests: + def test_finds_missing_test(self, tmp_path: Path) -> None: + files = ["scripts/foo.py"] + rules = BUILTIN_RULES + missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS) + assert "scripts/foo.py" in missing + + def test_no_missing_when_test_exists(self, tmp_path: Path) -> None: + (tmp_path / "scripts" / "tests").mkdir(parents=True) + (tmp_path / "scripts" / "tests" / "test_foo.py").write_text("") + files = ["scripts/foo.py"] + rules = BUILTIN_RULES + missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS) + assert missing == {} + + def test_skips_test_files(self, tmp_path: Path) -> None: + files = ["tests/unit/test_foo.py"] + rules = BUILTIN_RULES + missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS) + assert missing == {} + + def test_skips_non_python_files(self, tmp_path: Path) -> None: + files = ["README.md", "config.yml"] + rules = BUILTIN_RULES + missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS) + assert missing == {} + + def test_no_rule_no_requirement(self, tmp_path: Path) -> None: + files = ["unknown_type.xyz"] + rules = BUILTIN_RULES + missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS) + assert missing == {} + + +class TestChangedFiles: + @patch("devx.tools.check_test_coverage.subprocess.run") + def test_staged_only(self, mock_run: MagicMock, tmp_path: Path) -> None: + mock_run.return_value = MagicMock(stdout="file1.py\nfile2.py\n", returncode=0) + files = _changed_files(staged_only=True, repo_root=tmp_path) + assert files == ["file1.py", "file2.py"] + cmd = mock_run.call_args.args[0] + assert "--cached" in cmd + + @patch("devx.tools.check_test_coverage.subprocess.run") + def test_ci_mode(self, mock_run: MagicMock, tmp_path: Path) -> None: + mock_run.return_value = MagicMock(stdout="file1.py\n", returncode=0) + files = _changed_files(staged_only=False, repo_root=tmp_path) + assert files == ["file1.py"] + cmd = mock_run.call_args.args[0] + assert "origin/master...HEAD" in cmd + + @patch("devx.tools.check_test_coverage.subprocess.run") + def test_fallback_to_staged(self, mock_run: MagicMock, tmp_path: Path) -> None: + # First call fails, second succeeds + mock_run.side_effect = [ + MagicMock(stdout="", returncode=1), + MagicMock(stdout="file1.py\n", returncode=0), + ] + files = _changed_files(staged_only=False, repo_root=tmp_path) + assert files == ["file1.py"] + + +class TestLoadRules: + def test_defaults_when_no_config(self) -> None: + with patch("devx.tools.check_test_coverage._load_pyproject_devx", return_value={}): + rules, skip, indicators, skip_ext = _load_rules() + assert rules == BUILTIN_RULES + assert skip == [] + assert indicators == DEFAULT_TEST_INDICATORS + assert skip_ext == DEFAULT_SKIP_EXTENSIONS + + def test_custom_rules(self) -> None: + cfg = { + "check_test_coverage": { + "rules": [ + { + "source_pattern": "lib/*.py", + "test_paths": ["tests/test_{name}"], + "description": "Missing: tests/test_{name}", + } + ], + "skip_patterns": ["__init__.py"], + } + } + with patch("devx.tools.check_test_coverage._load_pyproject_devx", return_value=cfg): + rules, skip, indicators, skip_ext = _load_rules() + assert len(rules) == 1 + assert rules[0]["source_pattern"] == "lib/*.py" + assert "__init__.py" in skip + + def test_returns_defaults_when_cfg_not_dict(self) -> None: + with patch( + "devx.tools.check_test_coverage._load_pyproject_devx", return_value={"check_test_coverage": "not a dict"} + ): + rules, skip, indicators, skip_ext = _load_rules() + assert rules == BUILTIN_RULES + assert skip == [] + + def test_skip_extensions_not_list_returns_default(self) -> None: + cfg = {"check_test_coverage": {"skip_extensions": "not a list"}} + with patch("devx.tools.check_test_coverage._load_pyproject_devx", return_value=cfg): + _, _, _, skip_ext = _load_rules() + assert skip_ext == DEFAULT_SKIP_EXTENSIONS + + def test_test_paths_not_list_skips_rule(self, tmp_path: Path) -> None: + files = ["scripts/foo.py"] + rules = [ + { + "source_pattern": "scripts/*.py", + "test_paths": "not a list", + "description": "Missing test", + } + ] + missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS) + # Rule matches but test_paths is not a list, so it's skipped — no missing + assert missing == {} + + +class TestMain: + def test_no_changed_files(self, tmp_path: Path) -> None: + with ( + patch("devx.tools.check_test_coverage._changed_files", return_value=[]), + patch( + "devx.tools.check_test_coverage._load_rules", + return_value=(BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS), + ), + patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path), + ): + assert main([]) == 0 + + def test_all_have_tests(self, tmp_path: Path) -> None: + (tmp_path / "scripts" / "tests").mkdir(parents=True) + (tmp_path / "scripts" / "tests" / "test_foo.py").write_text("") + with ( + patch("devx.tools.check_test_coverage._changed_files", return_value=["scripts/foo.py"]), + patch( + "devx.tools.check_test_coverage._load_rules", + return_value=(BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS), + ), + patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path), + ): + assert main([]) == 0 + + def test_missing_test_returns_1(self, tmp_path: Path) -> None: + with ( + patch("devx.tools.check_test_coverage._changed_files", return_value=["scripts/foo.py"]), + patch( + "devx.tools.check_test_coverage._load_rules", + return_value=(BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS), + ), + patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path), + ): + assert main([]) == 1 + + def test_warn_only_returns_0(self, tmp_path: Path) -> None: + with ( + patch("devx.tools.check_test_coverage._changed_files", return_value=["scripts/foo.py"]), + patch( + "devx.tools.check_test_coverage._load_rules", + return_value=(BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS), + ), + patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path), + ): + assert main(["--warn-only"]) == 0 diff --git a/tests/unit/test_gitea_cli.py b/tests/unit/test_gitea_cli.py index d08e55a..5da27f7 100644 --- a/tests/unit/test_gitea_cli.py +++ b/tests/unit/test_gitea_cli.py @@ -356,6 +356,6 @@ class TestListBranches: class TestWhoami: def test_whoami(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") - mock_result = MagicMock(returncode=0, stdout="emil", stderr="") + mock_result = MagicMock(returncode=0, stdout="testuser", stderr="") with patch("subprocess.run", return_value=mock_result): - assert cli.whoami() == "emil" + assert cli.whoami() == "testuser"