Files
cleveragents-core/noxfile.py
CoreRasurae e8d2f76466
CI / push-validation (pull_request) Successful in 45s
CI / helm (pull_request) Successful in 57s
CI / build (pull_request) Successful in 1m8s
CI / lint (pull_request) Successful in 1m31s
CI / tdd_quality_gate (pull_request) Failing after 1m26s
CI / quality (pull_request) Successful in 1m30s
CI / typecheck (pull_request) Successful in 1m43s
CI / security (pull_request) Successful in 1m42s
CI / e2e_tests (pull_request) Failing after 4m10s
CI / integration_tests (pull_request) Successful in 5m2s
CI / unit_tests (pull_request) Successful in 6m6s
CI / docker (pull_request) Successful in 1m32s
CI / coverage (pull_request) Successful in 12m12s
CI / status-check (push) Blocked by required conditions
CI / status-check (pull_request) Failing after 3s
CI / tdd_quality_gate (push) Has been skipped
CI / benchmark-regression (push) Failing after 1m8s
CI / build (push) Successful in 1m2s
CI / lint (push) Successful in 1m16s
CI / helm (push) Successful in 45s
CI / push-validation (push) Successful in 45s
CI / quality (push) Successful in 1m37s
CI / typecheck (push) Successful in 2m1s
CI / security (push) Successful in 2m1s
CI / integration_tests (push) Successful in 3m34s
CI / unit_tests (push) Successful in 5m14s
CI / docker (push) Successful in 1m37s
CI / coverage (push) Failing after 19m17s
CI / benchmark-publish (push) Successful in 1h20m43s
CI / e2e_tests (push) Successful in 4m13s
feat(ci): implement TDD bug tag quality gate for bug fix PRs
Add an automated quality gate that enforces TDD bug fix workflow rules
on pull requests. The gate parses PR descriptions for bug-closing
keywords (Closes/Fixes/Resolves #N, ISSUES CLOSED: #N), searches the
codebase for corresponding TDD tests tagged @tdd_bug_N, and verifies
that @tdd_expected_fail tags have been removed.

Key components:
- scripts/tdd_quality_gate.py: Main quality gate script with PR
  description parsing, TDD test discovery, and tag removal verification.
  All public functions validate arguments fail-fast and are statically
  typed.
- noxfile.py: New tdd_quality_gate session that reads PR_DESCRIPTION
  from the environment and runs the quality gate script.
- .forgejo/workflows/ci.yml: New tdd_quality_gate CI job that runs
  only on pull_request events, passing the PR body as PR_DESCRIPTION.
- features/tdd_quality_gate.feature: 46 Behave scenarios covering PR
  parsing, TDD test search, tag removal verification, full gate logic,
  robot diff handling, edge cases, argument validation, bool guards,
  co-located bug false-positive guard, and main() CLI entry point.
- features/steps/tdd_quality_gate_steps.py: Step definitions for all
  Behave scenarios using temporary directories for isolation.
- robot/tdd_quality_gate.robot: 15 Robot Framework integration tests
  exercising the gate end-to-end via a helper subprocess.
- robot/helper_tdd_quality_gate.py: Helper script for Robot tests with
  sentinel-based sub-commands.

Review-round fixes applied:
- check_expected_fail_removed now uses _contains_tag_token for
  word-boundary matching (avoids false positives on partial tag names)
- Diff expected-fail removal detection tracks flags at file level
  instead of per-hunk (fixes false negatives when tags span hunks)
- parse_bug_refs filters out issue number zero
- Redundant double error reporting eliminated (file-level check
  short-circuits the diff-level check)
- run_quality_gate returns (errors, bug_refs) tuple to avoid
  redundant re-parsing in main()
- Regex compilation cached via functools.lru_cache
- Nox session no longer installs the full project (stdlib only)
- CI checkout uses fetch-depth: 0 for reliable merge-base resolution

Review-round 2 fixes applied:
- _diff_has_expected_fail_removal_for_bug now requires the removed
  line to contain both the expected-fail tag and the specific bug tag
  (fixes false positives when two bugs share the same test file)
- check_expected_fail_removed error messages use the correct tag
  prefix per file type (@tdd_bug_N for .feature, tdd_bug_N for .robot)
- bool values rejected by bug-number validation guards in
  find_tdd_tests, check_expected_fail_removed, and
  _diff_has_expected_fail_removal_for_bug
- File-read error handling catches UnicodeDecodeError alongside OSError
  (root-safe unreadable-file handling via invalid-UTF-8 test fixture)
- Temp directory cleanup added to after_scenario hook in environment.py
- 8 new Behave scenarios: bool type guards (2), co-located bug
  false-positive regression (1), run_quality_gate argument validation
  (3), and main() CLI entry point exit codes (2)

Review-round 3 fixes applied:
- Synthetic PR diff helper (_default_pr_diff_for_bug_refs) now
  auto-detects .robot vs .feature file type from the temp search
  tree and generates the matching diff format (fixes under-tested
  robot-format diff code path in multi-bug integration scenarios)
- check_expected_fail_removed test step now filters files by bug
  tag via find_tdd_tests before checking (matches production path
  in run_quality_gate)
- after_scenario temp directory cleanup no longer sets
  context.temp_dir = None (fixes cleanup conflict with
  cli_init_yes_flag_steps.py cleanup functions that run after hooks)
- 2 new Behave scenarios: multi-line PR description parsing, and
  non-string pr_diff type guard for run_quality_gate

ISSUES CLOSED: #629
2026-05-12 00:22:49 +01:00

922 lines
31 KiB
Python

import json
import os
import sys
from pathlib import Path
import nox
# Global configuration
DEFAULT_PYTHON = "3.13"
SUPPORTED_PYTHONS = ["3.13"]
nox.options.reuse_existing_virtualenvs = True
nox.options.error_on_external_run = True
BEHAVE_PARALLEL_VERSION = "2.0.0"
def _default_processes() -> int:
env_override = os.environ.get("TEST_PROCESSES")
if env_override:
return int(env_override)
try:
cpus = len(os.sched_getaffinity(0)) or 1
except AttributeError:
cpus = os.cpu_count() or 1
# Keep default parallelism conservative to avoid timeout/OOM flakes
# under heavy Robot/pabot subprocess fan-out in CI and shared runners.
# Callers can still override with TEST_PROCESSES / --processes.
return cpus
def _behave_parallel_args(posargs: list[str]) -> list[str]:
has_custom_processes = any(
arg in {"--processes", "-j"} or arg.startswith(("--processes=", "-j"))
for arg in posargs
)
if has_custom_processes:
return []
return ["--processes", str(_default_processes())]
def _pabot_parallel_args(posargs: list[str]) -> list[str]:
has_custom_processes = any(
arg in {"--processes"} or arg.startswith("--processes=") for arg in posargs
)
if has_custom_processes:
return []
return ["--processes", str(_default_processes())]
def _split_pabot_args(posargs: list[str]) -> tuple[list[str], list[str]]:
pabot_args: list[str] = []
robot_args: list[str] = []
i = 0
while i < len(posargs):
arg = posargs[i]
if arg == "--processes" and i + 1 < len(posargs):
pabot_args.extend([arg, posargs[i + 1]])
i += 2
continue
if arg.startswith("--processes="):
pabot_args.append(arg)
i += 1
continue
robot_args.append(arg)
i += 1
return pabot_args, robot_args
def _create_template_db(session: nox.Session) -> str:
"""Build the pre-migrated SQLite template and return its path.
Runs ``scripts/create_template_db.py`` to produce a SQLite file with all
tables already created via ``Base.metadata.create_all()`` and the
alembic_version table stamped at HEAD. Each test scenario can then
``shutil.copy`` this file instead of running 25 Alembic migrations.
"""
template_path = str(Path("build/.template-migrated.db").resolve())
session.run(
"python",
"scripts/create_template_db.py",
template_path,
silent=True,
)
return template_path
def _install_behave_parallel(session: nox.Session) -> None:
"""Install behave-parallel with an in-process parallel runner.
Instead of spawning one subprocess per feature file (the old model),
the new CLI runs features in-process via behave's Python API. Step
definitions and environment hooks are loaded once; each feature file
is then parsed and executed without interpreter startup overhead.
Parallel execution uses ``multiprocessing.Pool`` with the ``fork``
start method so that already-imported modules are shared (copy-on-write)
across workers.
The runner script is read from ``scripts/run_behave_parallel.py`` so
that the noxfile stays concise and the script can be linted and typed
independently.
"""
tmp_dir = Path(session.create_tmp())
source_dir = tmp_dir / "behave-parallel-inprocess"
source_dir.mkdir(parents=True, exist_ok=True)
pkg_dir = source_dir / "behave_parallel"
pkg_dir.mkdir(parents=True, exist_ok=True)
(pkg_dir / "__init__.py").write_text("\n")
runner_script = Path(__file__).parent / "scripts" / "run_behave_parallel.py"
(pkg_dir / "cli.py").write_text(runner_script.read_text(encoding="utf-8"))
formatter_script = (
Path(__file__).parent / "scripts" / "behave_pass_suppress_formatter.py"
)
(pkg_dir / "behave_pass_suppress_formatter.py").write_text(
formatter_script.read_text(encoding="utf-8")
)
setup_path = source_dir / "setup.py"
setup_path.write_text(
"from setuptools import find_packages, setup\n"
"setup(\n"
' name="behave-parallel",\n'
f' version="{BEHAVE_PARALLEL_VERSION}",\n'
" packages=find_packages(),\n"
' install_requires=["behave>=1.2.6"],\n'
" entry_points={\n"
' "console_scripts": '
'["behave-parallel=behave_parallel.cli:main"],\n'
" },\n"
")\n"
)
session.install("setuptools", "wheel")
session.install(str(source_dir))
# =============================================================================
# QUICK DEVELOPMENT SESSIONS (Fast - Run daily)
# =============================================================================
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
def lint(session: nox.Session):
"""Check code formatting and linting."""
session.install("ruff>=0.15,<0.16")
session.run("ruff", "check", "src/", "scripts/", "examples/", "features/", "robot/")
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
def format(session: nox.Session):
"""Format code with ruff. Pass --check to verify without modifying."""
session.install("ruff>=0.15,<0.16")
session.run("ruff", "format", *session.posargs, ".")
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
def typecheck(session: nox.Session):
"""Check types with pyright."""
session.install("pyright")
session.install("-e", ".")
session.run("pyright", stderr=sys.stdout)
@nox.session(python=SUPPORTED_PYTHONS, reuse_venv=True, venv_backend="uv")
def unit_tests(session: nox.Session):
"""Run BDD tests with Behave."""
session.install("-e", ".[tests]")
# Explicitly ensure a2a-sdk is installed for A2A SDK dependency tests
session.install("a2a-sdk>=0.3.0")
_install_behave_parallel(session)
# Build a pre-migrated template DB so each scenario can copy it
# instead of running 25 Alembic migrations from scratch.
template_path = _create_template_db(session)
session.env["CLEVERAGENTS_TEMPLATE_DB"] = template_path
behave_cmd = session.bin + "/behave-parallel"
parallel_args = _behave_parallel_args(session.posargs)
# If specific feature files are passed, run only those files
has_feature_files = any(arg.endswith(".feature") for arg in session.posargs)
if has_feature_files:
args = [
behave_cmd,
"-q",
*parallel_args,
*session.posargs,
]
else:
args = [
behave_cmd,
"-q",
*parallel_args,
"features/",
*session.posargs,
]
session.env["PYTHONPATH"] = str(Path("src").resolve())
# Prevent Rich from injecting ANSI escape codes into machine-readable
# CLI output (JSON/YAML) which causes json.loads() failures on CI.
session.env["NO_COLOR"] = "1"
# Pre-compile features/ bytecode so forked parallel workers don't race
# to write .pyc files simultaneously. Overlayfs copy-up locks cause
# open() to deadlock when N workers all compile uncached step files at
# the same time (thundering-herd on __pycache__).
session.run("python", "-m", "compileall", "-q", "features/")
session.run(*args)
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
def docs(session: nox.Session):
"""Build documentation with MkDocs."""
session.install("-e", ".[docs]")
# Suppress DeprecationWarning from the third-party `rx` package which calls
# datetime.datetime.utcfromtimestamp() (deprecated since Python 3.12).
session.env["PYTHONWARNINGS"] = "ignore::DeprecationWarning:rx"
session.run("mkdocs", "build")
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
def serve_docs(session: nox.Session):
"""Serve docs locally for development."""
session.install("-e", ".[docs]")
# Suppress DeprecationWarning from the third-party `rx` package which calls
# datetime.datetime.utcfromtimestamp() (deprecated since Python 3.12).
session.env["PYTHONWARNINGS"] = "ignore::DeprecationWarning:rx"
session.run("mkdocs", "serve", "--dev-addr", "0.0.0.0:8000")
@nox.session(python=SUPPORTED_PYTHONS, reuse_venv=True, venv_backend="uv")
def build(session: nox.Session):
"""Build the wheel distribution."""
session.install("build", "pip")
session.run("python", "-m", "build", "--wheel")
@nox.session(python=SUPPORTED_PYTHONS, reuse_venv=True, venv_backend="uv")
def integration_tests(session: nox.Session):
"""Run Robot Framework integration tests (parallel via pabot).
Defaults to conservative parallelism (<=2 processes) to avoid
resource pressure in CI. Override via PABOT_PROCESSES or by passing
--processes/--processes=N in session arguments.
"""
session.install("-e", ".[tests]")
session.env["CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"] = "true"
session.env["NO_COLOR"] = "1"
# Override PYTHONPATH so the editable install (src/) for this project
# is used instead of any inherited PYTHONPATH from the outer environment.
session.env["PYTHONPATH"] = "src"
# Propagate venv bin to PATH so Run Process in robot files finds
# the venv's python/robot rather than the system copies.
venv_bin = os.path.join(session.virtualenv.location, "bin")
session.env["PATH"] = venv_bin + os.pathsep + os.environ.get("PATH", "")
# Ensure output directory exists (CI starts with a clean checkout)
os.makedirs("build/reports/robot", exist_ok=True)
# Pass the venv Python path explicitly so Run Process calls use it
# instead of relying on PATH (which may not propagate to subprocesses).
venv_python = os.path.join(venv_bin, "python")
# Pre-compile bytecode so that parallel pabot workers (and the
# Python sub-processes they spawn via ``Run Python Script``) can
# read cached .pyc files instead of each cold-compiling every
# module from source simultaneously — avoids a thundering-herd
# race on CI runners with high core counts.
session.run("python", "-m", "compileall", "-q", "src/")
# Build a pre-migrated template DB so helper scripts that call
# setup_workspace() can copy it instead of running 25+ Alembic
# migrations per test — critical for parallel pabot execution.
template_path = _create_template_db(session)
session.env["CLEVERAGENTS_TEMPLATE_DB"] = template_path
pabot_args, robot_args = _split_pabot_args(session.posargs)
parallel_args = _pabot_parallel_args(pabot_args)
# TDD expected-fail listener — inverts results for @tdd_expected_fail
# tagged tests and validates TDD tag combinations.
# See CONTRIBUTING.md > TDD Issue Test Tags.
# Resolved relative to this file (not CWD) so ``nox`` invocations from
# a non-root directory still find the listener module.
tdd_listener = str(
Path(__file__).parent / "robot" / "tdd_expected_fail_listener.py"
)
session.run(
"pabot",
*parallel_args,
*pabot_args,
"--outputdir",
"build/reports/robot",
"--loglevel",
"INFO",
"--report",
"report.html",
"--log",
"log.html",
"--xunit",
"xunit.xml",
"--variable",
f"PYTHON:{venv_python}",
"--listener",
tdd_listener,
"--exclude",
"slow",
"--exclude",
"discovery",
"--exclude",
"code_blocks",
"--exclude",
"wip",
"--exclude",
"E2E",
"--exclude",
"tdd_fixture",
*robot_args,
"robot/",
)
@nox.session(python=SUPPORTED_PYTHONS, reuse_venv=True, venv_backend="uv")
def slow_integration_tests(session: nox.Session):
"""Run Robot Framework slow integration tests (parallel via pabot).
Runs all tests tagged ``slow`` that are excluded from the standard
``integration_tests`` session. Defaults to conservative parallelism
(<=2 processes) to avoid resource pressure in CI. Override via
TEST_PROCESSES or by passing --processes/--processes=N in session
arguments.
"""
session.install("-e", ".[tests]")
session.env["CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"] = "true"
session.env["NO_COLOR"] = "1"
# Override PYTHONPATH so the editable install (src/) for this project
# is used instead of any inherited PYTHONPATH from the outer environment.
session.env["PYTHONPATH"] = "src"
# Propagate venv bin to PATH so Run Process in robot files finds
# the venv's python/robot rather than the system copies.
venv_bin = os.path.join(session.virtualenv.location, "bin")
session.env["PATH"] = venv_bin + os.pathsep + os.environ.get("PATH", "")
# Ensure output directory exists (CI starts with a clean checkout)
os.makedirs("build/reports/robot", exist_ok=True)
# Pass the venv Python path explicitly so Run Process calls use it
# instead of relying on PATH (which may not propagate to subprocesses).
venv_python = os.path.join(venv_bin, "python")
# Pre-compile bytecode so that parallel pabot workers (and the
# Python sub-processes they spawn via ``Run Python Script``) can
# read cached .pyc files instead of each cold-compiling every
# module from source simultaneously — avoids a thundering-herd
# race on CI runners with high core counts.
session.run("python", "-m", "compileall", "-q", "src/")
# Build a pre-migrated template DB so helper scripts that call
# setup_workspace() can copy it instead of running 25+ Alembic
# migrations per test — critical for parallel pabot execution.
template_path = _create_template_db(session)
session.env["CLEVERAGENTS_TEMPLATE_DB"] = template_path
pabot_args, robot_args = _split_pabot_args(session.posargs)
parallel_args = _pabot_parallel_args(pabot_args)
# TDD expected-fail listener — inverts results for @tdd_expected_fail
# tagged tests and validates TDD tag combinations.
# See CONTRIBUTING.md > TDD Issue Test Tags.
# Resolved relative to this file (not CWD) so ``nox`` invocations from
# a non-root directory still find the listener module.
tdd_listener = str(
Path(__file__).parent / "robot" / "tdd_expected_fail_listener.py"
)
session.run(
"pabot",
*parallel_args,
*pabot_args,
"--outputdir",
"build/reports/robot",
"--loglevel",
"INFO",
"--report",
"report.html",
"--log",
"log.html",
"--xunit",
"xunit.xml",
"--variable",
f"PYTHON:{venv_python}",
"--listener",
tdd_listener,
"--include",
"slow",
"--exclude",
"discovery",
"--exclude",
"code_blocks",
"--exclude",
"wip",
"--exclude",
"E2E",
"--exclude",
"tdd_fixture",
*robot_args,
"robot/",
)
@nox.session(python=SUPPORTED_PYTHONS, reuse_venv=True, venv_backend="uv")
def e2e_tests(session: nox.Session):
"""Run end-to-end Robot Framework tests with real LLM API keys.
E2E tests use zero mocking — they exercise the real CleverAgents CLI
against real LLM API keys (Anthropic/OpenAI). Tests are tagged with
``E2E`` and live in the ``robot/e2e/`` directory.
This session is NOT included in the default ``nox`` run because it
requires real API keys. Run explicitly via ``nox -s e2e_tests``.
Tests that require LLM API keys will skip gracefully when the keys
are not present in the environment.
Parallelism is controlled by the ``TEST_PROCESSES`` environment variable
(default: min(cpu_count, 2)). Override via ``TEST_PROCESSES=N nox -s
e2e_tests`` or by passing ``--processes N`` as a session argument.
"""
session.install("-e", ".[tests]")
session.env["CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"] = "true"
session.env["NO_COLOR"] = "1"
session.env["PYTHONPATH"] = "src"
# Propagate venv bin to PATH so Run Process in robot files finds
# the venv's python/robot rather than the system copies.
venv_bin = os.path.join(session.virtualenv.location, "bin")
session.env["PATH"] = venv_bin + os.pathsep + os.environ.get("PATH", "")
venv_python = os.path.join(venv_bin, "python")
# Ensure output directory exists
os.makedirs("build/reports/robot-e2e", exist_ok=True)
# Pre-compile bytecode so that parallel pabot workers (and the
# Python sub-processes they spawn via ``Run Process``) can read
# cached .pyc files instead of each cold-compiling every module
# from source simultaneously — avoids a thundering-herd race on
# CI runners with high core counts.
session.run("python", "-m", "compileall", "-q", "src/")
# Build a pre-migrated template DB so helper scripts that call
# setup_workspace() can copy it instead of running 25+ Alembic
# migrations per test — reduces per-test setup from ~1-3 s to ~1 ms.
# Critical for parallel pabot execution where many suites start
# simultaneously and would otherwise all race to run migrations.
template_path = _create_template_db(session)
session.env["CLEVERAGENTS_TEMPLATE_DB"] = template_path
# Propagate LLM API keys from the environment into the session
# so that real E2E tests can authenticate with providers.
for key in (
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"GOOGLE_API_KEY",
"GEMINI_API_KEY",
):
value = os.environ.get(key)
if value:
session.env[key] = value
# Split posargs into pabot-specific args (--processes) and robot args.
pabot_args, robot_args = _split_pabot_args(session.posargs)
parallel_args = _pabot_parallel_args(pabot_args)
# TDD expected-fail listener — inverts results for @tdd_expected_fail
# tagged tests and validates TDD tag combinations.
# See CONTRIBUTING.md > TDD Issue Test Tags.
tdd_listener = str(
Path(__file__).parent / "robot" / "tdd_expected_fail_listener.py"
)
# Use pabot (parallel Robot Framework runner) instead of the sequential
# ``robot`` runner. Each E2E suite file runs in its own pabot worker
# process, so independent suites execute concurrently rather than
# serially. Suite-level isolation (separate CLEVERAGENTS_HOME per
# suite) ensures workers do not share database state.
session.run(
"pabot",
*parallel_args,
*pabot_args,
"--outputdir",
"build/reports/robot-e2e",
"--loglevel",
"INFO",
"--report",
"report.html",
"--log",
"log.html",
"--xunit",
"xunit.xml",
"--variable",
f"PYTHON:{venv_python}",
"--include",
"E2E",
"--listener",
tdd_listener,
*robot_args,
"robot/e2e/",
)
COVERAGE_THRESHOLD = 96.5 # Temporarily lowered due to many @tdd_expected_fail tests
# see issues #4183 and #4184
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
def coverage_report(session: nox.Session):
"""Generate coverage report from Behave tests.
Runs all behave features in a single process under slipcover.
The in-process behave-parallel runner avoids subprocess overhead,
so a single slipcover invocation collects coverage for the entire
suite -- no per-worker files or merge step required.
Coverage threshold is enforced at >=96.5%.
On success, emits: COVERAGE OK: <pct>% (threshold: 97%)
On failure, emits: COVERAGE FAILED: <pct>% < 97% threshold
Both are single-line, CI-parseable summary strings.
"""
session.install("-e", ".[tests]")
_install_behave_parallel(session)
os.makedirs("build", exist_ok=True)
# Build a pre-migrated template DB so each scenario can copy it
# instead of running 25 Alembic migrations from scratch.
template_path = _create_template_db(session)
session.env["CLEVERAGENTS_TEMPLATE_DB"] = template_path
session.env["PYTHONPATH"] = str(Path("src").resolve())
session.env["NO_COLOR"] = "1"
source_paths = "src"
omit_patterns = ",".join(
[
"*/tests/*",
"*/test_*",
"features/*",
"*/features/*",
"*/__pycache__/*",
"*/site-packages/*",
"*/dependency_injector/*",
"*/venv/*",
"*/.venv/*",
"*/.nox/*",
"src/cleveragents/discovery/*",
]
)
# Force sequential mode so slipcover wraps the entire process.
session.env["BEHAVE_PARALLEL_COVERAGE"] = "1"
# Clean up any existing slipcover data
for path in Path("build").glob(".slipcover.*.json"):
path.unlink()
# Build behave-parallel args (sequential for coverage).
behave_cmd = session.bin + "/behave-parallel"
has_feature_files = any(arg.endswith(".feature") for arg in session.posargs)
if has_feature_files:
behave_args = [
behave_cmd,
"-q",
"--no-capture",
*session.posargs,
]
else:
behave_args = [
behave_cmd,
"-q",
"--no-capture",
"features/",
*session.posargs,
]
# Wrap the entire behave-parallel run under slipcover.
# A single process produces a single JSON output file directly.
# Allow exit code 1 (test failures) — coverage data is still produced.
session.run(
"python",
"-m",
"slipcover",
"--json",
"--out",
"build/coverage.json",
"--source",
source_paths,
"--omit",
omit_patterns,
"--",
*behave_args,
success_codes=[0, 1],
)
# Generate XML report for CI
session.run(
"python",
"-m",
"slipcover",
"--merge",
"build/coverage.json",
"--xml",
"--out",
"build/coverage.xml",
)
# Generate terminal report and check threshold.
# slipcover exits with code 2 if below threshold; nox intercepts the exit.
report_path = "build/coverage-report.txt"
session.run(
"python",
"-m",
"slipcover",
"--merge",
"build/coverage.json",
"--out",
report_path,
"--source",
source_paths,
"--omit",
omit_patterns,
f"--fail-under={COVERAGE_THRESHOLD}",
success_codes=[0, 2],
silent=False,
)
try:
with open(report_path) as report_file:
print(report_file.read(), end="")
except FileNotFoundError:
session.log(f"Slipcover report not found at {report_path}")
# Parse the total percentage from the slipcover JSON report for the summary
total_pct: float = 0.0
try:
with open("build/coverage.json") as f:
payload = json.load(f)
summary = payload.get("summary") or payload.get("totals") or {}
percent_value = None
for key in (
"percent_covered",
"percent_covered_display",
"line_coverage_percent",
):
percent_value = summary.get(key)
if percent_value is not None:
break
if percent_value is None:
covered = (
summary.get("lines_covered")
or summary.get("covered_lines")
or summary.get("covered")
)
total = (
summary.get("lines")
or summary.get("total_lines")
or summary.get("num_lines")
or summary.get("total")
)
if covered is not None and total:
percent_value = (float(covered) / float(total)) * 100.0
if isinstance(percent_value, str):
percent_value = percent_value.strip().rstrip("%")
if percent_value is not None:
total_pct = float(percent_value)
except (FileNotFoundError, KeyError, TypeError, ValueError, json.JSONDecodeError):
total_pct = 0.0
rounded_pct = round(total_pct, 1)
if rounded_pct >= COVERAGE_THRESHOLD:
session.log(f"COVERAGE OK: {rounded_pct}% (threshold: {COVERAGE_THRESHOLD}%)")
else:
session.error(
f"COVERAGE FAILED: {rounded_pct}% < {COVERAGE_THRESHOLD}% threshold"
)
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
def pre_commit(session: nox.Session):
"""Run all pre-commit hooks on all files."""
session.install("-e", ".[dev]")
session.run("pre-commit", "run", "--all-files", *session.posargs)
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
def security_scan(session: nox.Session):
"""Run security checks matching Forgejo CI: bandit + semgrep + vulture.
Steps:
1. Bandit medium-severity report (non-blocking JSON export to build/)
2. Bandit high-severity gate (blocks on findings)
3. Semgrep custom rules from .semgrep.yml (blocks on ERROR-level findings)
4. Vulture dead-code detection (blocks on confidence >=80%)
Severity gates:
- Bandit HIGH: hard fail (blocks merge)
- Bandit MEDIUM: report-only (review, do not block)
- Semgrep ERROR: hard fail (eval/exec/os.system detection)
- Semgrep WARNING: report-only (pickle.loads)
- Vulture >=80% confidence: hard fail
"""
session.install("-e", ".[dev]")
# Python 3.13 no longer bundles pkg_resources. Semgrep transitively imports
# it via opentelemetry instrumentation, so pin setuptools to a version that
# still provides pkg_resources.
session.install("setuptools<81")
# Ensure output directory exists (CI starts with a clean checkout)
os.makedirs("build", exist_ok=True)
# Step 1: Bandit - medium-severity report (non-blocking, mirrors CI JSON export)
session.run(
"bandit",
"-c",
"pyproject.toml",
"-r",
"src/cleveragents",
"--severity-level",
"medium",
"--format",
"json",
"--output",
"build/bandit-report.json",
success_codes=[0, 1],
)
# Step 2: Bandit - high-severity gate (blocks on findings)
session.run(
"bandit",
"-c",
"pyproject.toml",
"-r",
"src/cleveragents",
"--severity-level",
"high",
)
# Step 3: Semgrep custom rules (blocks on ERROR-severity matches)
semgrep_config = Path(".semgrep.yml")
if semgrep_config.exists():
session.run(
"semgrep",
"--config=.semgrep.yml",
"--error",
"--quiet",
"src/",
success_codes=[0, 1],
)
else:
session.warn("No .semgrep.yml found, skipping semgrep scan")
# Step 4: Vulture dead-code detection
session.run(
"vulture",
"src/cleveragents",
"vulture_whitelist.py",
"--min-confidence",
"80",
"--exclude",
"src/cleveragents/discovery",
)
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
def dead_code(session: nox.Session):
"""Run vulture to detect dead code."""
session.install("-e", ".[dev]")
session.run(
"vulture",
"src/cleveragents",
"vulture_whitelist.py",
"--min-confidence",
"80",
"--exclude",
"src/cleveragents/discovery",
)
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
def complexity(session: nox.Session):
"""Check code complexity using radon."""
session.install("-e", ".[dev]")
session.run(
"radon",
"cc",
"src/cleveragents",
"--min",
"C",
"--show-complexity",
"--total-average",
)
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
def adr_compliance(session: nox.Session):
"""Check code compliance with Architecture Decision Records."""
session.install("-e", ".[dev]")
session.run("python", "scripts/check-adr-compliance.py")
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
def benchmark(session: nox.Session):
"""Run Airspeed Velocity benchmarks and publish results."""
session.install("-e", ".[tests]")
config_path = "asv.conf.json"
session.run(
"asv",
"machine",
"--machine=forgejo-runner",
"--os=Linux_6.x",
"--arch=x86_64",
"--num_cpu=32",
"--ram=32GB",
"--cpu=AMD",
f"--config={config_path}",
)
session.run(
"asv",
"run",
"--machine=forgejo-runner",
"--append-samples",
"--launch-method=spawn",
"--show-stderr",
"--verbose",
f"--config={config_path}",
success_codes=[0, 2],
)
session.run("asv", "publish", f"--config={config_path}")
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
def benchmark_regression(session: nox.Session):
"""Run Airspeed Velocity benchmarks regression test."""
session.install("-e", ".[tests]")
config_path = "asv.conf.json"
asv_base_sha = os.environ.get("ASV_BASE_SHA", "master")
session.run(
"asv",
"machine",
"--machine=forgejo-runner",
"--os=Linux_6.x",
"--arch=x86_64",
"--num_cpu=32",
"--ram=32GB",
"--cpu=AMD",
f"--config={config_path}",
)
session.run(
"asv",
"continuous",
"--machine=forgejo-runner",
"--append-samples",
"--show-stderr",
"--verbose",
"--factor=1.50",
f"--config={config_path}",
asv_base_sha,
"HEAD",
success_codes=[0, 2],
)
session.run("asv", "publish", f"--config={config_path}")
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
def tdd_quality_gate(session: nox.Session):
"""Enforce TDD bug fix workflow rules on PRs.
Reads the PR description from the ``PR_DESCRIPTION`` environment
variable and verifies that:
1. Every bug referenced via closing keywords (``Fixes #N``,
``Closes #N``, ``Resolves #N``, ``ISSUES CLOSED: #N``) has
a corresponding TDD test tagged ``@tdd_bug_N``.
2. The ``@tdd_expected_fail`` / ``tdd_expected_fail`` tag has been
removed from each of those tests in the PR diff.
If no bug references are found the gate passes trivially.
"""
# The quality gate script uses only standard library; no install needed.
pr_description = os.environ.get("PR_DESCRIPTION", "")
pr_base_ref = os.environ.get("PR_BASE_REF", "master")
session.env["PR_DESCRIPTION"] = pr_description
session.env["PR_BASE_REF"] = pr_base_ref
session.run("python", "scripts/tdd_quality_gate.py")
# Sessions to run by default when running `nox` without arguments
nox.options.sessions = [
"lint", # ~5-10 seconds
"format", # ~5-10 seconds (auto-fixes issues)
"typecheck", # ~10-20 seconds
"security_scan", # bandit + vulture (mirrors Forgejo CI security job)
"dead_code", # vulture dead-code detection
"unit_tests", # ~30-60 seconds (without ML deps)
"integration_tests",
"docs", # ~10-30 seconds
"build", # ~5-10 seconds
"benchmark", # ASV benchmarks for performance tracking
"coverage_report", # ~30-60 seconds (without ML deps)
]