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/", success_codes=[0, 1], ) @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..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: % (threshold: %) On failure, emits: COVERAGE FAILED: % < % 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) ]