Files
cleveragents-core/noxfile.py
T
HAL9000 747513bc59 chore(testing): enforce semgrep gate for suppressed exceptions
Fix broken escape hatch mechanism and address reviewer feedback:

- Replace non-functional comment-based pattern-not clauses with Semgrep's native # nosemgrep mechanism for the escape hatch. Semgrep strips comments from the AST so pattern-not clauses matching inline comments never fire; # nosemgrep is the only reliable per-line suppression mechanism.

- Require both # nosemgrep: <rule-id> AND # error-propagation: allow on the same line: the former is the actual suppression, the latter is the mandatory human-readable audit annotation.

- Add raise $EXC from $CAUSE pattern-not entries for both Exception and BaseException variants to prevent false positives on legitimate exception chaining (raise ServiceError from e).

- Switch nox -s lint Semgrep invocation to audit mode (no --error) for the phased rollout: the codebase has ~337 existing suppressions that must be triaged before enforcement mode is enabled. A comment in noxfile.py documents the migration path and references #9103.

- Update CONTRIBUTING.md examples and enforcement description to reflect the dual-comment requirement.
2026-06-14 19:05:21 -04:00

1262 lines
44 KiB
Python

import json
import os
import re
import shutil
import subprocess
import sys
import time
import tomllib
from concurrent.futures import ThreadPoolExecutor
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 []
# Integration tests are significantly heavier than unit tests and can
# become unstable on shared runners when pabot fans out aggressively.
# Cap parallelism at 6: at the prior <=2 the ~47min suite serialized to
# ~24min wall on a 32-core runner (2x speedup == 2 lanes); 6 lanes cut
# that to ~6-8min while staying well short of the per-core fan-out that
# triggers LLM 429 rate-limits / OOM flakes. Override via
# TEST_PROCESSES / --processes.
pabot_default = min(6, _default_processes())
return ["--processes", str(pabot_default)]
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", "semgrep>=1.45.0")
session.run(
"ruff",
"check",
"src/",
"scripts/",
"examples/",
"features/",
"robot/",
".opencode/",
)
# NOTE: Semgrep runs in audit mode (without --error) during the phased rollout.
# The codebase currently has ~337 existing broad-exception suppressions that must
# be triaged before enforcement mode is enabled. Once existing violations are
# annotated or fixed, change this to:
# session.run("semgrep", "--config=.semgrep.yml", "--error", "src/")
# See issue #9103 for the migration plan.
session.run("semgrep", "--config=.semgrep.yml", "src/")
@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
# Remove ANSI escape codes.
session.env["TERM"] = "dumb"
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 parallelism capped at 6 processes (min(6, cpus)) to avoid
resource pressure / LLM 429 flakes in CI while still using the runner.
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)
# and clear stale pabot worker artifacts from previous interrupted runs.
os.makedirs("build/reports/robot", exist_ok=True)
shutil.rmtree("build/reports/robot/pabot_results", ignore_errors=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 parallelism capped at 6
processes (min(6, cpus)) to avoid resource pressure / LLM 429 flakes 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/",
)
def _read_coverage_fail_under() -> float:
"""Single source of truth for the coverage floor (plan decision 2a):
``pyproject.toml [tool.coverage.report].fail_under``. slipcover does not
auto-read pyproject, so this session passes ``--fail-under`` explicitly
and reports this value in the summary line. Falls back to 96.5 (the
canonical floor, temporarily lowered for the @tdd_expected_fail backlog --
issues #4183/#4184) if the key is missing/unreadable.
Ratchet (decision 2b): the objective is 97% -- raise ``fail_under`` only
after observed master coverage has held >= target+buffer for N green
commits. The 100%-patch diff-gate keeps total coverage monotonic, so the
floor trails real coverage upward; it is not a precondition for shipping.
"""
try:
data = tomllib.loads((Path(__file__).parent / "pyproject.toml").read_text())
return float(data["tool"]["coverage"]["report"]["fail_under"])
except (OSError, KeyError, TypeError, ValueError, tomllib.TOMLDecodeError):
return 96.5
COVERAGE_THRESHOLD = _read_coverage_fail_under()
# Engine tunables (WS1). ``K`` bounds concurrent slipcover processes; ``N`` is
# the number of bin-packed chunks, decoupled from K (default ~3xK). ``N`` sets
# per-chunk peak RSS; ``K`` sets the concurrency multiplier (ceiling ~ K x peak).
COVERAGE_PROCESSES_DEFAULT = 4
_COUNT_SCENARIOS_SCRIPT = """
import json, sys
from behave.parser import parse_file
out = {}
for fp in sys.argv[2:]:
try:
f = parse_file(fp)
out[fp] = 1 if f is None else max(1, sum(1 for _ in f.walk_scenarios()))
except Exception:
out[fp] = 1
with open(sys.argv[1], "w") as fh:
json.dump(out, fh)
"""
def _count_scenarios_per_feature(
session: nox.Session, feature_paths: list[str]
) -> dict[str, int]:
"""Map each ``.feature`` path to its scenario count (outline-expanded).
Parses each feature with behave's gherkin parser and walks its scenarios
(so a ``Scenario Outline`` contributes one unit per ``Examples`` row, the
same unit the runner executes). Used as the bin-packing weight so chunks
balance by real work, not file count (naive contiguous split measured a
1.94 max/mean imbalance -- see plan V10).
behave lives in the *session* venv, not the nox orchestrator process, so
the parse runs via the session python in a subprocess writing JSON to a
file (keeps stdout clean). On any failure every file is weighted 1 so none
is silently dropped (bin-packing still runs, just by file count).
"""
counts_path = str(Path("build/scenario_counts.json").resolve())
proc = subprocess.run(
[
session.bin + "/python",
"-c",
_COUNT_SCENARIOS_SCRIPT,
counts_path,
*feature_paths,
],
env=_child_env(session),
capture_output=True,
text=True,
)
if proc.returncode != 0:
session.log(
"scenario count failed; falling back to file-count bins: "
f"{proc.stderr.strip()[-300:]}"
)
return {fp: 1 for fp in feature_paths}
with open(counts_path) as fh:
return json.load(fh)
def _bin_pack(
feature_paths: list[str], counts: dict[str, int], n_bins: int
) -> list[list[str]]:
"""Greedy largest-first bin-packing into ``n_bins`` least-loaded bins.
Sorts features by scenario count descending, then drops each into the
currently-lightest bin. Returns only non-empty bins (so the chunk count
never exceeds the feature count). This keeps per-chunk scenario load
balanced, bounding per-process peak RSS and tail wall-clock.
"""
n_bins = max(1, min(n_bins, len(feature_paths)))
ordered = sorted(feature_paths, key=lambda p: counts.get(p, 1), reverse=True)
bins: list[list[str]] = [[] for _ in range(n_bins)]
loads = [0] * n_bins
for fp in ordered:
i = loads.index(min(loads))
bins[i].append(fp)
loads[i] += counts.get(fp, 1)
return [b for b in bins if b]
def _child_env(session: nox.Session) -> dict[str, str]:
"""Environment for a slipcover child process.
Inherits the session env (PYTHONPATH, NO_COLOR, BEHAVE_PARALLEL_COVERAGE,
CLEVERAGENTS_TEMPLATE_DB) but DEFENSIVELY strips any inherited
``CLEVERAGENTS_DATABASE_URL``: ``before_all`` mktemps a per-process DB
only-if-unset, so a fixed value would make all K children share one DB and
collide (plan V15). Only the template DB is passed through.
"""
merged = {**os.environ, **session.env}
merged.pop("CLEVERAGENTS_DATABASE_URL", None)
# session.env may carry None-valued keys (nox unset markers); subprocess
# rejects non-str env values, so drop them.
env = {k: str(v) for k, v in merged.items() if v is not None}
return env
_MAXRSS_RE = re.compile(r"Maximum resident set size \(kbytes\):\s*(\d+)")
def _run_coverage_chunk(
venv_python: str,
source_paths: str,
omit_patterns: str,
behave_cmd: str,
chunk: list[str],
index: int,
env: dict[str, str],
) -> dict:
"""Run one slipcover subprocess over ``chunk`` and report its result.
Wraps the child in ``/usr/bin/time -v`` (when available) to capture peak
RSS. Child stdout+stderr are tee'd to ``build/coverage.<i>.log`` so a
failing chunk's diagnostics survive without buffering the whole suite's
output in memory. Returns a result dict for the caller to validate.
"""
out_json = f"build/coverage.{index}.json"
log_path = f"build/coverage.{index}.log"
Path(out_json).unlink(missing_ok=True)
slip_cmd = [
venv_python,
"-m",
"slipcover",
"--json",
"--out",
out_json,
"--source",
source_paths,
"--omit",
omit_patterns,
"--",
behave_cmd,
"-q",
"--no-capture",
*chunk,
]
time_bin = "/usr/bin/time"
cmd = [time_bin, "-v", *slip_cmd] if os.path.exists(time_bin) else slip_cmd
start = time.monotonic()
with open(log_path, "wb") as log_file:
proc = subprocess.Popen(cmd, stdout=log_file, stderr=subprocess.STDOUT, env=env)
returncode = proc.wait()
wall = time.monotonic() - start
try:
log_text = Path(log_path).read_text(errors="replace")
except OSError:
log_text = ""
rss_match = _MAXRSS_RE.search(log_text)
peak_rss_kb = int(rss_match.group(1)) if rss_match else None
return {
"index": index,
"returncode": returncode,
"out_json": out_json,
"log_path": log_path,
"wall": wall,
"peak_rss_kb": peak_rss_kb,
"n_features": len(chunk),
}
def _validate_coverage_chunk(result: dict) -> str | None:
"""Three-part liveness check for a chunk. Returns a diagnostic or None.
A chunk is alive iff: exit code in {0, 1} (0 = pass, 1 = test failures,
both still emit coverage) AND its JSON parses AND it recorded a non-empty
executed-line set. Anything else (OOM/137, crash, truncated JSON, empty
data) is a DEAD chunk -- the caller must fail the whole job rather than
merge survivors, which would report false-low coverage.
"""
idx = result["index"]
rc = result["returncode"]
if rc not in (0, 1):
return f"chunk {idx} exited {rc} (e.g. 137 = OOM/kill)"
out_json = result["out_json"]
if not os.path.exists(out_json):
return f"chunk {idx} produced no JSON ({out_json} missing)"
try:
with open(out_json) as f:
payload = json.load(f)
except (OSError, json.JSONDecodeError) as exc:
return f"chunk {idx} JSON did not parse: {exc}"
files = payload.get("files") or {}
executed_total = sum(
len(meta.get("executed_lines") or []) for meta in files.values()
)
if executed_total == 0:
return f"chunk {idx} recorded an empty executed-line set"
return None
def _log_tail(log_path: str, max_lines: int = 40) -> str:
try:
lines = Path(log_path).read_text(errors="replace").splitlines()
except OSError:
return "(log unavailable)"
return "\n".join(lines[-max_lines:])
def _run_parallel_coverage(
session: nox.Session,
behave_cmd: str,
source_paths: str,
omit_patterns: str,
) -> None:
"""Full-suite coverage via K concurrent slipcover processes over N chunks.
Enumerates ``features/``, bin-packs by scenario count into N chunks, runs
them K-at-a-time, fails loud on any dead chunk, and merges the survivors'
JSON into ``build/coverage.json``. The downstream xml/report/summary steps
consume the merged JSON unchanged.
"""
# Enumerate features directly rather than importing run_behave_parallel:
# that module's top level imports behave/behave_parallel, which live only in
# the *session* venv, not the nox orchestrator process -- importing it here
# would raise ModuleNotFoundError. This glob mirrors the runner's
# ``_iter_features`` directory branch and keeps the engine self-contained.
feature_paths = sorted(str(fp) for fp in Path("features").rglob("*.feature"))
if not feature_paths:
session.error("COVERAGE FAILED: no feature files found under features/")
k = max(1, int(os.environ.get("COVERAGE_PROCESSES", COVERAGE_PROCESSES_DEFAULT)))
n = int(os.environ.get("COVERAGE_CHUNKS", 3 * k))
n = max(k, min(n, len(feature_paths)))
counts = _count_scenarios_per_feature(session, feature_paths)
total_scenarios = sum(counts.values())
chunks = _bin_pack(feature_paths, counts, n)
chunk_loads = [sum(counts.get(fp, 1) for fp in c) for c in chunks]
session.log(
f"coverage engine: K={k} processes, N={len(chunks)} chunks, "
f"{len(feature_paths)} features / {total_scenarios} scenarios; "
f"chunk loads min/max={min(chunk_loads)}/{max(chunk_loads)}"
)
venv_python = session.bin + "/python"
env = _child_env(session)
suite_start = time.monotonic()
with ThreadPoolExecutor(max_workers=k) as pool:
results = list(
pool.map(
lambda item: _run_coverage_chunk(
venv_python,
source_paths,
omit_patterns,
behave_cmd,
item[1],
item[0],
env,
),
enumerate(chunks),
)
)
suite_wall = time.monotonic() - suite_start
# Per-chunk telemetry (peak RSS + wall) for the equivalence report.
for r in sorted(results, key=lambda r: r["index"]):
rss = f"{r['peak_rss_kb'] / 1024:.0f}MB" if r["peak_rss_kb"] else "n/a"
session.log(
f" chunk {r['index']}: rc={r['returncode']} "
f"features={r['n_features']} wall={r['wall']:.0f}s peakRSS={rss}"
)
peaks = [r["peak_rss_kb"] for r in results if r["peak_rss_kb"]]
if peaks:
session.log(
f"coverage engine wall={suite_wall:.0f}s "
f"max per-chunk peakRSS={max(peaks) / 1024:.0f}MB "
f"(ceiling ~ K x peak = {k * max(peaks) / 1024:.0f}MB)"
)
# Fail loud on ANY dead chunk -- never merge survivors (a partial merge
# reports artificially-low coverage = the exact false-failure we kill).
diagnostics = [d for d in (_validate_coverage_chunk(r) for r in results) if d]
if diagnostics:
detail = "\n".join(diagnostics)
worst = next(r for r in results if _validate_coverage_chunk(r) is not None)
session.error(
"COVERAGE FAILED: dead chunk(s) -- refusing to merge survivors "
f"(would report false-low coverage).\n{detail}\n"
f"--- tail of {worst['log_path']} ---\n{_log_tail(worst['log_path'])}"
)
# Merge the per-chunk JSON into the canonical coverage.json (verified
# correct union -- plan V1). The downstream xml/report steps run on this.
merge_inputs = [r["out_json"] for r in sorted(results, key=lambda r: r["index"])]
session.run(
"python",
"-m",
"slipcover",
"--merge",
*merge_inputs,
"--json",
"--out",
"build/coverage.json",
)
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
def coverage_report(session: nox.Session):
"""Generate coverage report from Behave tests.
Full-suite runs use a K-way parallel engine: ``features/`` is bin-packed
by scenario count into N chunks, each measured by its own short-lived
``slipcover --json`` process (K concurrent), then the per-chunk JSON is
merged. This bounds per-process peak RSS (killing the single-process
OOM/reaper collapse) and cuts wall-clock, while remaining bit-for-bit
equivalent to the single-process measurement. K = env ``COVERAGE_PROCESSES``
(default 4); N = env ``COVERAGE_CHUNKS`` (default ~3xK).
A *targeted* run (posargs naming ``.feature``) keeps the single-process
path, preserving the scenario-specifier recipe.
Coverage threshold is enforced at >= the pyproject
``[tool.coverage.report].fail_under`` floor (plan decision 2a).
On success, emits: COVERAGE OK: <pct>% (threshold: <floor>%)
On failure, emits: COVERAGE FAILED: <pct>% < <floor>% 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/*",
"src/cleveragents/tui/materializer.py",
# TYPE_CHECKING-only re-export shim: 100% of its "missing" lines are
# inside an `if TYPE_CHECKING:` block (never executes at runtime),
# and slipcover has no per-line pragma to exclude them. Omit the
# whole module so those uncoverable lines stop dragging the total.
"src/cleveragents/application/services/__init__.py",
]
)
# Force sequential mode so slipcover wraps the entire process.
session.env["BEHAVE_PARALLEL_COVERAGE"] = "1"
# Clean up any existing slipcover / per-chunk data
for path in Path("build").glob(".slipcover.*.json"):
path.unlink()
for path in Path("build").glob("coverage.*.json"):
path.unlink()
behave_cmd = session.bin + "/behave-parallel"
has_feature_files = any(arg.endswith(".feature") for arg in session.posargs)
if has_feature_files:
# Targeted run: a small, scoped selection. Keep the single-process
# path -- chunking adds startup overhead for no balance benefit. 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_cmd,
"-q",
"--no-capture",
*session.posargs,
success_codes=[0, 1],
)
else:
# Full suite: K-way parallel fan-out -> merged build/coverage.json.
_run_parallel_coverage(session, behave_cmd, source_paths, omit_patterns)
# 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:g}%)")
else:
session.error(
f"COVERAGE FAILED: {rounded_pct}% < {COVERAGE_THRESHOLD:g}% 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, 1, 2],
)
session.run("asv", "publish", f"--config={config_path}")
# 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)
]