From 1e84282349e1f94553ab380e2ba57d4fa6676cd1 Mon Sep 17 00:00:00 2001 From: jonahgabriel Date: Sat, 26 Sep 2026 07:34:53 -0400 Subject: [PATCH 1/7] ci(OMN-19711): chain-replacement parity tool scripts/ci/chain_replacement_parity.py turns the coverage-parity rule for deleting tests in favour of chain cases into a receipt with P1 to P5 (mutation kill set by mutant id, line and branch-arc set supersets per handler file, walker ERROR paths, 20-seed determinism, cost), each PASS, FAIL or MISSING. Any FAIL or MISSING, and any unreadable or invalid input, exits 1; zero mutants is MISSING, never a vacuous pass. The verdict layer (evaluate) is pure and unit-tested with planted inputs. The measure subcommand is lab-only: it runs coverage and mutmut over the handler modules in scratch copies, runs the chain cases under 20 seeds (PYTHONHASHSEED plus a seeded case order, since pytest-randomly is not a dependency here), and records junit times. Its default comparison suite is T plus C, which is stricter than a full-suite comparison; --baseline adds paths to both sides. mutmut 3 is added in a new parity dependency group, so the dev sync that CI runs is unchanged. --- pyproject.toml | 3 + scripts/ci/chain_replacement_parity.py | 1027 +++++++++++++++++ .../ci/test_chain_replacement_parity.py | 313 +++++ uv.lock | 157 +++ 4 files changed, 1500 insertions(+) create mode 100644 scripts/ci/chain_replacement_parity.py create mode 100644 tests/unit/scripts/ci/test_chain_replacement_parity.py diff --git a/pyproject.toml b/pyproject.toml index c002325232..dd6d7250d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -908,6 +908,9 @@ force-include = { "config/ci_bus_lanes.yaml" = "omnimarket/config/ci_bus_lanes.y artifacts = ["src/omnimarket/**/*.yaml", "src/omnimarket/**/*.sql"] [dependency-groups] +parity = [ + "mutmut>=3.3,<4", +] dev = [ # OMN-16024: floor raised 8.4.0 -> 9.0.3 (CVE-2025-71176, insecure tmpdir DoS). "pytest>=9.0.3", diff --git a/scripts/ci/chain_replacement_parity.py b/scripts/ci/chain_replacement_parity.py new file mode 100644 index 0000000000..becb75fe87 --- /dev/null +++ b/scripts/ci/chain_replacement_parity.py @@ -0,0 +1,1027 @@ +# SPDX-FileCopyrightText: 2026 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +"""Prove parity when replacing individual tests with chain tests. + +The pure :func:`evaluate` layer accepts already-collected measurements and is +the authority for the P1--P5 receipt. The ``measure`` subcommand is a thin, +lab-only collector. It runs coverage and mutmut in isolated runs, checks +determinism, records pytest JUnit timings, writes ``inputs.json``, and then +calls the same pure evaluator. + +Determinism intentionally does not depend on pytest-randomly. For seeds +1..20, the collector sets ``PYTHONHASHSEED`` and shuffles the explicit chain +node ids with ``random.Random(seed)`` before passing them to pytest. + +Chain cases that support event-order verification must append one JSON object +per case to the path named by ``CHAIN_EVENT_LOG``:: + + {"case_id": "tests/chains/test_x.py::test_y", + "event_types": ["Started", "Completed"]} + +A chain case missing from a run's log is a missing P4 measurement. JUnit +``classname`` values are mapped to pytest ids by replacing dots with slashes, +appending ``.py``, and then appending ``::``. For example, +``tests.unit.test_x`` plus ``test_y`` becomes +``tests/unit/test_x.py::test_y``. + +The mutmut collector requires mutmut 3 from the ``parity`` dependency group. +It copies the repository to scratch directories and appends a temporary +``[tool.mutmut]`` section there; it never edits this worktree's configuration. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import random +import re +import shlex +import shutil +import subprocess +import sys +import tempfile +import xml.etree.ElementTree as ET +from collections.abc import Sequence +from pathlib import Path, PurePosixPath +from typing import Any, Literal, cast + +import yaml +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator + +CheckStatus = Literal["PASS", "FAIL", "MISSING"] +Verdict = Literal["PASS", "FAIL"] +CheckId = Literal["P1", "P2", "P3", "P4", "P5"] +MutmutStatus = Literal[ + "killed", + "survived", + "timeout", + "suspicious", + "no tests", + "skipped", + "segfault", + "not checked", +] + +_KILLED_STATUSES: frozenset[MutmutStatus] = frozenset({"killed", "timeout"}) +_ALL_CHECK_IDS: frozenset[CheckId] = frozenset({"P1", "P2", "P3", "P4", "P5"}) +_MUTMUT_STATUSES = ( + "killed", + "survived", + "timeout", + "suspicious", + "no tests", + "skipped", + "segfault", + "not checked", +) + + +class FileCoverage(BaseModel): + """Executed statements and branch arcs for one source file.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + executed_lines: list[int] + executed_branches: list[tuple[int, int]] + + +class DeterminismRun(BaseModel): + """One explicitly seeded execution of all chain cases.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + seed: int + passed: bool + event_lists: dict[str, list[str]] + + +class ParityInputs(BaseModel): + """Identity and measurements consumed by the pure parity evaluator.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + flow: str = Field(min_length=1) + pinned_sha: str = Field(pattern=r"^[0-9a-fA-F]{40}$") + handler_modules: tuple[str, ...] = Field(min_length=1) + deleted_cases: tuple[str, ...] = Field(min_length=1) + chain_cases: tuple[str, ...] = Field(min_length=1) + mutation_before: dict[str, MutmutStatus] | None + mutation_after: dict[str, MutmutStatus] | None + mutation_killers: dict[str, list[str]] | None = None + coverage_before: dict[str, FileCoverage] | None + coverage_after: dict[str, FileCoverage] | None + walker_error_paths_asserted_by_T: list[str] | None # noqa: N815 - specified schema + walker_error_paths_asserted_by_C: list[str] | None # noqa: N815 - specified schema + determinism_runs: list[DeterminismRun] | None + case_seconds_T: dict[str, float] | None # noqa: N815 - specified schema + case_seconds_C: dict[str, float] | None # noqa: N815 - specified schema + + @field_validator("handler_modules") + @classmethod + def _validate_handler_paths(cls, values: tuple[str, ...]) -> tuple[str, ...]: + for value in values: + path = PurePosixPath(value) + if path.is_absolute() or ".." in path.parts or path.suffix != ".py": + raise ValueError( + f"handler_modules must be repo-relative .py paths: {value!r}" + ) + return values + + +class CheckResult(BaseModel): + """One parity-check result and its machine-readable evidence.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + status: CheckStatus + detail: dict[str, Any] + + +class ParityReceipt(BaseModel): + """Versioned P1--P5 parity verdict.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + schema_version: Literal[1] = 1 + flow: str + pinned_sha: str + handler_modules: tuple[str, ...] + deleted_cases: tuple[str, ...] + chain_cases: tuple[str, ...] + checks: dict[CheckId, CheckResult] + verdict: Verdict + generated_by: Literal["scripts/ci/chain_replacement_parity.py"] = ( + "scripts/ci/chain_replacement_parity.py" + ) + tool_version: Literal[1] = 1 + + @field_validator("checks") + @classmethod + def _require_all_checks( + cls, checks: dict[CheckId, CheckResult] + ) -> dict[CheckId, CheckResult]: + if frozenset(checks) != _ALL_CHECK_IDS: + raise ValueError("checks must contain exactly P1, P2, P3, P4, and P5") + return checks + + +def _p1_mutation(inputs: ParityInputs) -> CheckResult: + missing = [ + name + for name, value in ( + ("mutation_before", inputs.mutation_before), + ("mutation_after", inputs.mutation_after), + ) + if value is None + ] + if missing: + return CheckResult(status="MISSING", detail={"missing": missing}) + + before = cast(dict[str, MutmutStatus], inputs.mutation_before) + after = cast(dict[str, MutmutStatus], inputs.mutation_after) + if not before: + # Zero mutants over H is not a measurement: mutmut found nothing to + # mutate or the run collected nothing. It never passes vacuously. + return CheckResult( + status="MISSING", detail={"missing": ["mutation_before (no mutants)"]} + ) + killed_before = { + key for key, status in before.items() if status in _KILLED_STATUSES + } + killed_after = {key for key, status in after.items() if status in _KILLED_STATUSES} + escaped = sorted(killed_before - killed_after) + + only_t_killed: dict[str, str] = {} + if inputs.mutation_killers is not None: + deleted = set(inputs.deleted_cases) + chain = set(inputs.chain_cases) + for mutant_id in sorted(killed_before): + killers = inputs.mutation_killers.get(mutant_id, []) + if killers and set(killers) <= deleted: + only_t_killed[mutant_id] = next( + (case_id for case_id in killers if case_id in chain), "unknown" + ) + + return CheckResult( + status="PASS" if not escaped else "FAIL", + detail={ + "killed_before_count": len(killed_before), + "killed_after_count": len(killed_after), + "escaped_mutants": escaped, + "only_T_killed": only_t_killed, + }, + ) + + +def _p2_coverage(inputs: ParityInputs) -> CheckResult: + missing_inputs = [ + name + for name, value in ( + ("coverage_before", inputs.coverage_before), + ("coverage_after", inputs.coverage_after), + ) + if value is None + ] + if missing_inputs: + return CheckResult(status="MISSING", detail={"missing": missing_inputs}) + + before = cast(dict[str, FileCoverage], inputs.coverage_before) + after = cast(dict[str, FileCoverage], inputs.coverage_after) + missing_files: dict[str, list[str]] = {} + lost_lines: dict[str, list[int]] = {} + lost_arcs: dict[str, list[list[int]]] = {} + + for handler in inputs.handler_modules: + absent_from = [ + report_name + for report_name, report in (("before", before), ("after", after)) + if handler not in report + ] + if absent_from: + missing_files[handler] = absent_from + continue + before_file = before[handler] + after_file = after[handler] + lines = sorted(set(before_file.executed_lines) - set(after_file.executed_lines)) + arcs = sorted( + set(before_file.executed_branches) - set(after_file.executed_branches) + ) + if lines: + lost_lines[handler] = lines + if arcs: + lost_arcs[handler] = [[source, destination] for source, destination in arcs] + + detail: dict[str, Any] = { + "missing_files": missing_files, + "lost_lines": lost_lines, + "lost_arcs": lost_arcs, + } + if missing_files: + return CheckResult(status="MISSING", detail=detail) + return CheckResult( + status="FAIL" if lost_lines or lost_arcs else "PASS", + detail=detail, + ) + + +def _p3_walker(inputs: ParityInputs) -> CheckResult: + missing = [ + name + for name, value in ( + ( + "walker_error_paths_asserted_by_T", + inputs.walker_error_paths_asserted_by_T, + ), + ( + "walker_error_paths_asserted_by_C", + inputs.walker_error_paths_asserted_by_C, + ), + ) + if value is None + ] + if missing: + return CheckResult(status="MISSING", detail={"missing": missing}) + + asserted_t = set(cast(list[str], inputs.walker_error_paths_asserted_by_T)) + asserted_c = set(cast(list[str], inputs.walker_error_paths_asserted_by_C)) + lost = sorted(asserted_t - asserted_c) + return CheckResult( + status="PASS" if not lost else "FAIL", + detail={ + "missing_error_paths": lost, + "asserted_by_T_count": len(asserted_t), + "asserted_by_C_count": len(asserted_c), + }, + ) + + +def _p4_determinism(inputs: ParityInputs) -> CheckResult: + if inputs.determinism_runs is None: + return CheckResult(status="MISSING", detail={"missing": ["determinism_runs"]}) + + runs = inputs.determinism_runs + required_cases = set(inputs.chain_cases) + missing_event_logs: dict[str, list[int]] = {} + for case_id in sorted(required_cases): + absent_seeds = sorted( + run.seed for run in runs if case_id not in run.event_lists + ) + if absent_seeds: + missing_event_logs[case_id] = absent_seeds + + distinct_seeds = {run.seed for run in runs} + failed_seeds = sorted(run.seed for run in runs if not run.passed) + all_cases = sorted( + required_cases.union(*(set(run.event_lists) for run in runs)) + if runs + else required_cases + ) + non_deterministic_cases: list[str] = [] + for case_id in all_cases: + observed = { + tuple(run.event_lists[case_id]) + for run in runs + if case_id in run.event_lists + } + if len(observed) > 1: + non_deterministic_cases.append(case_id) + + detail: dict[str, Any] = { + "run_count": len(runs), + "distinct_seed_count": len(distinct_seeds), + "failed_seeds": failed_seeds, + "non_deterministic_cases": non_deterministic_cases, + "missing_event_logs": missing_event_logs, + } + if missing_event_logs: + return CheckResult(status="MISSING", detail=detail) + passes = ( + len(runs) >= 20 + and len(distinct_seeds) >= 20 + and not failed_seeds + and not non_deterministic_cases + ) + return CheckResult(status="PASS" if passes else "FAIL", detail=detail) + + +def _p5_cost(inputs: ParityInputs) -> CheckResult: + missing_inputs = [ + name + for name, value in ( + ("case_seconds_T", inputs.case_seconds_T), + ("case_seconds_C", inputs.case_seconds_C), + ) + if value is None + ] + if missing_inputs: + return CheckResult(status="MISSING", detail={"missing": missing_inputs}) + + seconds_t = cast(dict[str, float], inputs.case_seconds_T) + seconds_c = cast(dict[str, float], inputs.case_seconds_C) + missing_t = sorted( + case_id for case_id in inputs.deleted_cases if case_id not in seconds_t + ) + missing_c = sorted( + case_id for case_id in inputs.chain_cases if case_id not in seconds_c + ) + if missing_t or missing_c: + return CheckResult( + status="MISSING", + detail={ + "missing_deleted_cases": missing_t, + "missing_chain_cases": missing_c, + }, + ) + + total_t = sum(seconds_t[case_id] for case_id in inputs.deleted_cases) + total_c = sum(seconds_c[case_id] for case_id in inputs.chain_cases) + return CheckResult( + status="PASS" if total_c <= total_t else "FAIL", + detail={"deleted_seconds": total_t, "chain_seconds": total_c}, + ) + + +def evaluate(inputs: ParityInputs) -> ParityReceipt: + """Evaluate P1--P5 without performing I/O or invoking external tools.""" + + checks: dict[CheckId, CheckResult] = { + "P1": _p1_mutation(inputs), + "P2": _p2_coverage(inputs), + "P3": _p3_walker(inputs), + "P4": _p4_determinism(inputs), + "P5": _p5_cost(inputs), + } + verdict: Verdict = ( + "PASS" if all(result.status == "PASS" for result in checks.values()) else "FAIL" + ) + return ParityReceipt( + flow=inputs.flow, + pinned_sha=inputs.pinned_sha, + handler_modules=inputs.handler_modules, + deleted_cases=inputs.deleted_cases, + chain_cases=inputs.chain_cases, + checks=checks, + verdict=verdict, + ) + + +def coverage_from_json(path: str | Path) -> dict[str, FileCoverage]: + """Load coverage.py JSON's per-file executed lines and branch arcs.""" + + raw = json.loads(Path(path).read_text(encoding="utf-8")) + if not isinstance(raw, dict) or not isinstance(raw.get("files"), dict): + raise ValueError("coverage JSON must contain an object at files") + files = cast(dict[str, Any], raw["files"]) + result: dict[str, FileCoverage] = {} + for filename, file_data in files.items(): + if not isinstance(file_data, dict): + raise ValueError(f"coverage entry for {filename!r} is not an object") + result[filename] = FileCoverage.model_validate( + { + "executed_lines": file_data.get("executed_lines"), + "executed_branches": file_data.get("executed_branches"), + } + ) + return result + + +def junit_case_seconds(path: str | Path) -> dict[str, float]: + """Read JUnit times using ``classname-as-path.py::name`` node ids. + + The mapping is intentionally exact and documented in the module docstring: + dots in ``classname`` become slashes, ``.py`` is appended, and ``name`` is + appended after ``::``. Chain/deleted input lists must use that same form. + """ + + root = ET.parse(path).getroot() + result: dict[str, float] = {} + for case in root.iter("testcase"): + classname = case.attrib.get("classname") + name = case.attrib.get("name") + time = case.attrib.get("time") + if classname is None or name is None or time is None: + raise ValueError("every JUnit testcase needs classname, name, and time") + classname_path = classname.replace(".", "/") + if not classname_path.endswith(".py"): + classname_path += ".py" + result[f"{classname_path}::{name}"] = float(time) + return result + + +def _write_model(path: Path, model: BaseModel) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(model.model_dump(mode="json"), sort_keys=True, indent=2) + "\n", + encoding="utf-8", + ) + + +def _evaluate_files(inputs_path: Path, out_path: Path) -> int: + try: + inputs = ParityInputs.model_validate_json( + inputs_path.read_text(encoding="utf-8") + ) + receipt = evaluate(inputs) + _write_model(out_path, receipt) + except (OSError, ValueError, TypeError, ValidationError) as exc: + print(f"chain replacement parity: {exc}", file=sys.stderr) + return 1 + return 0 if receipt.verdict == "PASS" else 1 + + +def _read_lines(path: Path) -> list[str]: + return [ + line + for raw_line in path.read_text(encoding="utf-8").splitlines() + if (line := raw_line.strip()) and not line.startswith("#") + ] + + +def _load_delegation_config(repo_root: Path) -> dict[str, Any]: + config_path = ( + repo_root + / "validation" + / "chain_replacement_receipts" + / "delegation.inputs.yaml" + ) + if not config_path.is_file(): + raise ValueError( + "delegation shorthand requires " + "validation/chain_replacement_receipts/delegation.inputs.yaml" + ) + loaded = yaml.safe_load(config_path.read_text(encoding="utf-8")) + if not isinstance(loaded, dict): + raise ValueError(f"delegation config is not a mapping: {config_path}") + return cast(dict[str, Any], loaded) + + +def _string_list(config: dict[str, Any], *keys: str) -> list[str]: + for key in keys: + value = config.get(key) + if isinstance(value, list) and all(isinstance(item, str) for item in value): + return cast(list[str], value) + raise ValueError( + f"configuration needs a string list named one of: {', '.join(keys)}" + ) + + +def _run_logged( + command: Sequence[str], + *, + cwd: Path, + log_path: Path, + env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + completed = subprocess.run( + list(command), + cwd=cwd, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + log_path.write_text(completed.stdout, encoding="utf-8") + return completed + + +def _measure_coverage( + *, + repo_root: Path, + out_dir: Path, + label: str, + selection: list[str], + deselect: list[str], + pytest_args: list[str], +) -> dict[str, FileCoverage] | None: + data_path = out_dir / f".coverage.{label}" + json_path = out_dir / f"coverage-{label}.json" + env = os.environ.copy() + env["COVERAGE_FILE"] = str(data_path) + run = _run_logged( + [ + sys.executable, + "-m", + "coverage", + "run", + "--branch", + "-m", + "pytest", + *pytest_args, + *selection, + *(f"--deselect={case_id}" for case_id in deselect), + ], + cwd=repo_root, + log_path=out_dir / f"coverage-{label}.log", + env=env, + ) + report = _run_logged( + [sys.executable, "-m", "coverage", "json", "-o", str(json_path)], + cwd=repo_root, + log_path=out_dir / f"coverage-{label}-json.log", + env=env, + ) + if run.returncode != 0 or report.returncode != 0: + return None + try: + return coverage_from_json(json_path) + except (OSError, ValueError, TypeError, ValidationError, json.JSONDecodeError): + return None + + +def _mutmut_executable() -> str | None: + if importlib.util.find_spec("mutmut") is None: + return None + sibling = Path(sys.executable).with_name("mutmut") + if sibling.is_file(): + return str(sibling) + return shutil.which("mutmut") + + +def _toml_array(values: Sequence[str]) -> str: + return json.dumps(list(values), ensure_ascii=False) + + +def _copy_for_mutmut(repo_root: Path, destination: Path) -> None: + ignored = shutil.ignore_patterns( + ".git", + ".venv", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + "__pycache__", + ".coverage*", + ".mutmut-cache", + "mutants", + ) + shutil.copytree(repo_root, destination, ignore=ignored) + + +def _append_mutmut_config( + scratch_repo: Path, + *, + handlers: list[str], + pytest_args: list[str], + selection: list[str], +) -> None: + pyproject = scratch_repo / "pyproject.toml" + original = pyproject.read_text(encoding="utf-8") + if "[tool.mutmut]" in original: + raise ValueError("scratch pyproject already contains [tool.mutmut]") + config = ( + "\n[tool.mutmut]\n" + f"paths_to_mutate = {_toml_array(handlers)}\n" + f"pytest_add_cli_args = {_toml_array(pytest_args)}\n" + "pytest_add_cli_args_test_selection = " + f"{_toml_array(selection)}\n" + ) + pyproject.write_text(original + config, encoding="utf-8") + + +def _parse_mutmut_results(output: str) -> dict[str, MutmutStatus]: + statuses = "|".join(re.escape(status) for status in _MUTMUT_STATUSES) + after_id = re.compile( + rf"^\s*(?P\S.*?):\s*(?P{statuses})\b", re.IGNORECASE + ) + before_id = re.compile( + rf"^\s*(?P{statuses})\s+(?P\S.*?)\s*$", re.IGNORECASE + ) + parsed: dict[str, MutmutStatus] = {} + for line in output.splitlines(): + match = after_id.match(line) or before_id.match(line) + if match is None: + continue + status = match.group("status").lower() + parsed[match.group("id").strip()] = cast(MutmutStatus, status) + if not parsed: + raise ValueError("mutmut results did not contain per-mutant statuses") + return parsed + + +def _measure_mutation( + *, + mutmut: str, + repo_root: Path, + scratch_parent: Path, + out_dir: Path, + label: str, + handlers: list[str], + pytest_args: list[str], + selection: list[str], +) -> dict[str, MutmutStatus] | None: + scratch_repo = scratch_parent / f"repo-{label}" + try: + _copy_for_mutmut(repo_root, scratch_repo) + _append_mutmut_config( + scratch_repo, + handlers=handlers, + pytest_args=pytest_args, + selection=selection, + ) + run = _run_logged( + [mutmut, "run"], + cwd=scratch_repo, + log_path=out_dir / f"mutmut-{label}.log", + ) + if run.returncode != 0: + return None + results = _run_logged( + [mutmut, "results", "--all", "true"], + cwd=scratch_repo, + log_path=out_dir / f"mutmut-{label}-results.log", + ) + if results.returncode != 0: + return None + return _parse_mutmut_results(results.stdout) + except (OSError, ValueError): + return None + + +def _event_lists_from_jsonl(path: Path) -> dict[str, list[str]]: + if not path.is_file(): + return {} + result: dict[str, list[str]] = {} + for line_number, raw_line in enumerate( + path.read_text(encoding="utf-8").splitlines(), start=1 + ): + if not raw_line.strip(): + continue + try: + record = json.loads(raw_line) + except json.JSONDecodeError as exc: + raise ValueError( + f"invalid CHAIN_EVENT_LOG JSON on line {line_number}" + ) from exc + if not isinstance(record, dict): + raise ValueError(f"CHAIN_EVENT_LOG line {line_number} is not a JSON object") + case_id = record.get("case_id") + event_types = record.get("event_types") + if not isinstance(case_id, str) or not isinstance(event_types, list): + raise ValueError( + f"CHAIN_EVENT_LOG line {line_number} needs case_id and event_types" + ) + if not all(isinstance(item, str) for item in event_types): + raise ValueError( + f"CHAIN_EVENT_LOG line {line_number} has non-string event_types" + ) + if case_id in result: + raise ValueError( + f"duplicate CHAIN_EVENT_LOG record for {case_id!r} on line {line_number}" + ) + result[case_id] = cast(list[str], event_types) + return result + + +def _measure_determinism( + *, + repo_root: Path, + out_dir: Path, + chain_cases: list[str], + pytest_args: list[str], +) -> list[DeterminismRun] | None: + runs: list[DeterminismRun] = [] + try: + for seed in range(1, 21): + ordered_cases = list(chain_cases) + random.Random(seed).shuffle(ordered_cases) + event_log = out_dir / f"chain-events-seed-{seed}.jsonl" + event_log.unlink(missing_ok=True) + env = os.environ.copy() + env["PYTHONHASHSEED"] = str(seed) + env["CHAIN_EVENT_LOG"] = str(event_log) + completed = _run_logged( + [sys.executable, "-m", "pytest", *pytest_args, *ordered_cases], + cwd=repo_root, + log_path=out_dir / f"determinism-seed-{seed}.log", + env=env, + ) + runs.append( + DeterminismRun( + seed=seed, + passed=completed.returncode == 0, + event_lists=_event_lists_from_jsonl(event_log), + ) + ) + except (OSError, ValueError): + return None + return runs + + +def _measure_junit( + *, + repo_root: Path, + out_dir: Path, + label: str, + cases: list[str], + pytest_args: list[str], +) -> dict[str, float] | None: + junit_path = out_dir / f"junit-{label}.xml" + try: + completed = _run_logged( + [ + sys.executable, + "-m", + "pytest", + *pytest_args, + *cases, + f"--junitxml={junit_path}", + ], + cwd=repo_root, + log_path=out_dir / f"junit-{label}.log", + ) + if completed.returncode != 0: + return None + return junit_case_seconds(junit_path) + except (OSError, ValueError, ET.ParseError): + return None + + +def _git_sha(repo_root: Path) -> str: + completed = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repo_root, + text=True, + capture_output=True, + check=False, + ) + sha = completed.stdout.strip() + if completed.returncode != 0 or re.fullmatch(r"[0-9a-fA-F]{40}", sha) is None: + raise ValueError("could not resolve a 40-hex git HEAD for pinned_sha") + return sha + + +def _walker_values(path: Path | None) -> list[str] | None: + return None if path is None else _read_lines(path) + + +def _measure(args: argparse.Namespace) -> int: + repo_root = Path(__file__).resolve().parents[2] + try: + if args.flow == "delegation": + config = _load_delegation_config(repo_root) + handlers = _string_list(config, "handlers", "handler_modules") + deleted_cases = _string_list(config, "deleted", "deleted_cases") + chain_cases = _string_list(config, "chain", "chain_cases") + walker_t_value = config.get("walker_error_paths_asserted_by_T") + walker_c_value = config.get("walker_error_paths_asserted_by_C") + walker_t = ( + cast(list[str], walker_t_value) + if isinstance(walker_t_value, list) + and all(isinstance(item, str) for item in walker_t_value) + else None + ) + walker_c = ( + cast(list[str], walker_c_value) + if isinstance(walker_c_value, list) + and all(isinstance(item, str) for item in walker_c_value) + else None + ) + else: + if not args.handlers or args.deleted is None or args.chain is None: + raise ValueError( + "measure requires --handlers, --deleted, and --chain " + "unless --flow delegation is used" + ) + handlers = cast(list[str], args.handlers) + deleted_cases = _read_lines(args.deleted) + chain_cases = _read_lines(args.chain) + walker_t = _walker_values(args.walker_t) + walker_c = _walker_values(args.walker_c) + pytest_args = shlex.split(args.pytest_args) + out_dir = cast(Path, args.out_dir).resolve() + out_dir.mkdir(parents=True, exist_ok=True) + pinned_sha = _git_sha(repo_root) + except (OSError, ValueError, TypeError) as exc: + print(f"chain replacement parity: {exc}", file=sys.stderr) + return 1 + + mutmut = _mutmut_executable() + if mutmut is None: + print("mutmut not installed: uv sync --group parity", file=sys.stderr) + return 1 + + # The comparison suite is T plus C plus any --baseline path. With no + # baseline it is exactly T plus C, which is STRICTER than the plan's + # full-suite comparison (C alone must keep every kill and every covered + # arc T and C had together), never looser. + baseline = cast(list[str], args.baseline or []) + full_selection = [*baseline, *deleted_cases, *chain_cases] + coverage_before = _measure_coverage( + repo_root=repo_root, + out_dir=out_dir, + label="before", + selection=full_selection, + deselect=[], + pytest_args=pytest_args, + ) + coverage_after = _measure_coverage( + repo_root=repo_root, + out_dir=out_dir, + label="after", + selection=full_selection, + deselect=deleted_cases, + pytest_args=pytest_args, + ) + + with tempfile.TemporaryDirectory(prefix="chain-parity-mutmut-") as scratch: + scratch_parent = Path(scratch) + mutation_before = _measure_mutation( + mutmut=mutmut, + repo_root=repo_root, + scratch_parent=scratch_parent, + out_dir=out_dir, + label="before", + handlers=handlers, + pytest_args=pytest_args, + selection=full_selection, + ) + mutation_after = _measure_mutation( + mutmut=mutmut, + repo_root=repo_root, + scratch_parent=scratch_parent, + out_dir=out_dir, + label="after", + handlers=handlers, + pytest_args=pytest_args, + selection=[ + *full_selection, + *(f"--deselect={case_id}" for case_id in deleted_cases), + ], + ) + + determinism_runs = _measure_determinism( + repo_root=repo_root, + out_dir=out_dir, + chain_cases=chain_cases, + pytest_args=pytest_args, + ) + case_seconds_t = _measure_junit( + repo_root=repo_root, + out_dir=out_dir, + label="deleted", + cases=deleted_cases, + pytest_args=pytest_args, + ) + case_seconds_c = _measure_junit( + repo_root=repo_root, + out_dir=out_dir, + label="chain", + cases=chain_cases, + pytest_args=pytest_args, + ) + + try: + inputs = ParityInputs( + flow=args.flow, + pinned_sha=pinned_sha, + handler_modules=tuple(handlers), + deleted_cases=tuple(deleted_cases), + chain_cases=tuple(chain_cases), + mutation_before=mutation_before, + mutation_after=mutation_after, + mutation_killers=None, + coverage_before=coverage_before, + coverage_after=coverage_after, + walker_error_paths_asserted_by_T=walker_t, + walker_error_paths_asserted_by_C=walker_c, + determinism_runs=determinism_runs, + case_seconds_T=case_seconds_t, + case_seconds_C=case_seconds_c, + ) + _write_model(out_dir / "inputs.json", inputs) + receipt = evaluate(inputs) + _write_model(out_dir / "receipt.json", receipt) + except (OSError, ValueError, TypeError, ValidationError) as exc: + print(f"chain replacement parity: {exc}", file=sys.stderr) + return 1 + return 0 if receipt.verdict == "PASS" else 1 + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + evaluate_parser = subparsers.add_parser( + "evaluate", help="evaluate a planted or lab-collected inputs JSON" + ) + evaluate_parser.add_argument("--inputs", type=Path, required=True) + evaluate_parser.add_argument("--out", type=Path, required=True) + + measure_parser = subparsers.add_parser( + "measure", help="collect lab-only measurements and evaluate them" + ) + measure_parser.add_argument("--flow", required=True) + measure_parser.add_argument( + "--handlers", + action="append", + help="repo-relative handler .py path; repeat for multiple handlers", + ) + measure_parser.add_argument( + "--deleted", type=Path, help="file of deleted pytest node ids, one per line" + ) + measure_parser.add_argument( + "--chain", type=Path, help="file of chain pytest node ids, one per line" + ) + measure_parser.add_argument( + "--pytest-args", default="", help="additional pytest arguments as one string" + ) + measure_parser.add_argument("--out-dir", type=Path, required=True) + measure_parser.add_argument( + "--baseline", + action="append", + help=( + "extra pytest path or node id run in BOTH the before and after " + "suites (repeatable); default none, which compares T+C against C" + ), + ) + measure_parser.add_argument( + "--walker-t", + type=Path, + help="optional file of walker path ids asserted by deleted tests", + ) + measure_parser.add_argument( + "--walker-c", + type=Path, + help="optional file of walker path ids asserted by chain tests", + ) + return parser + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + """Parse CLI arguments without running either layer.""" + + return _parser().parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + """CLI entry point; all errors and every non-PASS verdict return one.""" + + args = parse_args(argv) + if args.command == "evaluate": + return _evaluate_files(args.inputs, args.out) + if args.command == "measure": + return _measure(args) + print( + f"chain replacement parity: unknown command {args.command!r}", file=sys.stderr + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) + + +__all__ = [ + "CheckResult", + "DeterminismRun", + "FileCoverage", + "ParityInputs", + "ParityReceipt", + "coverage_from_json", + "evaluate", + "junit_case_seconds", + "main", + "parse_args", +] diff --git a/tests/unit/scripts/ci/test_chain_replacement_parity.py b/tests/unit/scripts/ci/test_chain_replacement_parity.py new file mode 100644 index 0000000000..333779b73c --- /dev/null +++ b/tests/unit/scripts/ci/test_chain_replacement_parity.py @@ -0,0 +1,313 @@ +# SPDX-FileCopyrightText: 2026 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +"""Unit tests for the chain-replacement coverage-parity receipt.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from scripts.ci.chain_replacement_parity import ( + DeterminismRun, + FileCoverage, + ParityInputs, + coverage_from_json, + evaluate, + junit_case_seconds, + main, + parse_args, +) + +pytestmark = pytest.mark.unit + +_HANDLER = "src/omnimarket/example_handler.py" +_T_CASE = "tests/unit/test_old.py::test_old" +_C_CASE = "tests/chains/test_chain.py::test_chain" +_SHA = "a" * 40 + + +def _good_runs() -> list[DeterminismRun]: + return [ + DeterminismRun( + seed=seed, + passed=True, + event_lists={_C_CASE: ["Started", "Completed"]}, + ) + for seed in range(1, 21) + ] + + +def _input_kwargs() -> dict[str, Any]: + coverage = { + _HANDLER: FileCoverage( + executed_lines=[10, 11, 14], + executed_branches=[(10, 11), (10, 14)], + ) + } + return { + "flow": "delegation", + "pinned_sha": _SHA, + "handler_modules": (_HANDLER,), + "deleted_cases": (_T_CASE,), + "chain_cases": (_C_CASE,), + "mutation_before": {"m1": "killed", "m2": "survived"}, + "mutation_after": {"m1": "killed", "m2": "survived"}, + "mutation_killers": None, + "coverage_before": coverage, + "coverage_after": coverage, + "walker_error_paths_asserted_by_T": ["retry-exhausted"], + "walker_error_paths_asserted_by_C": ["retry-exhausted", "timeout"], + "determinism_runs": _good_runs(), + "case_seconds_T": {_T_CASE: 2.0}, + "case_seconds_C": {_C_CASE: 1.0}, + } + + +def _inputs(**updates: Any) -> ParityInputs: + values = _input_kwargs() + values.update(updates) + return ParityInputs.model_validate(values) + + +def _write_inputs(path: Path, inputs: ParityInputs) -> None: + path.write_text( + json.dumps(inputs.model_dump(mode="json"), sort_keys=True), + encoding="utf-8", + ) + + +def test_no_op_deletion_passes_and_cli_exits_zero(tmp_path: Path) -> None: + inputs = _inputs() + receipt = evaluate(inputs) + + assert receipt.verdict == "PASS" + assert {check.status for check in receipt.checks.values()} == {"PASS"} + + inputs_path = tmp_path / "inputs.json" + receipt_path = tmp_path / "receipt.json" + _write_inputs(inputs_path, inputs) + assert ( + main( + [ + "evaluate", + "--inputs", + str(inputs_path), + "--out", + str(receipt_path), + ] + ) + == 0 + ) + persisted = json.loads(receipt_path.read_text(encoding="utf-8")) + assert persisted["verdict"] == "PASS" + assert persisted["schema_version"] == 1 + + +def test_lost_branch_fails_p2_and_cli(tmp_path: Path) -> None: + after = { + _HANDLER: FileCoverage( + executed_lines=[10, 11, 14], + executed_branches=[(10, 11)], + ) + } + inputs = _inputs(coverage_after=after) + receipt = evaluate(inputs) + + assert receipt.verdict == "FAIL" + assert receipt.checks["P2"].status == "FAIL" + assert receipt.checks["P2"].detail["lost_arcs"] == {_HANDLER: [[10, 14]]} + + inputs_path = tmp_path / "inputs.json" + receipt_path = tmp_path / "receipt.json" + _write_inputs(inputs_path, inputs) + assert ( + main(["evaluate", "--inputs", str(inputs_path), "--out", str(receipt_path)]) + == 1 + ) + + +def test_killed_mutant_that_survives_fails_p1() -> None: + receipt = evaluate( + _inputs( + mutation_after={"m1": "survived", "m2": "survived"}, + mutation_killers={"m1": [_T_CASE]}, + ) + ) + + assert receipt.checks["P1"].status == "FAIL" + assert receipt.checks["P1"].detail["escaped_mutants"] == ["m1"] + assert receipt.checks["P1"].detail["only_T_killed"] == {"m1": "unknown"} + + +def test_killed_before_mutant_absent_after_fails_p1() -> None: + receipt = evaluate(_inputs(mutation_after={"m2": "survived"})) + + assert receipt.checks["P1"].status == "FAIL" + assert receipt.checks["P1"].detail["escaped_mutants"] == ["m1"] + + +def test_walker_error_path_lost_fails_p3() -> None: + receipt = evaluate(_inputs(walker_error_paths_asserted_by_C=[])) + + assert receipt.checks["P3"].status == "FAIL" + assert receipt.checks["P3"].detail["missing_error_paths"] == ["retry-exhausted"] + + +def test_nineteen_determinism_runs_fail_p4() -> None: + receipt = evaluate(_inputs(determinism_runs=_good_runs()[:19])) + + assert receipt.checks["P4"].status == "FAIL" + assert receipt.checks["P4"].detail["run_count"] == 19 + + +def test_changed_event_list_fails_p4() -> None: + runs = _good_runs() + runs[-1] = runs[-1].model_copy( + update={"event_lists": {_C_CASE: ["Started", "Failed"]}} + ) + receipt = evaluate(_inputs(determinism_runs=runs)) + + assert receipt.checks["P4"].status == "FAIL" + assert _C_CASE in receipt.checks["P4"].detail["non_deterministic_cases"] + + +def test_duplicate_determinism_seed_fails_p4() -> None: + runs = _good_runs() + runs[-1] = runs[-1].model_copy(update={"seed": runs[0].seed}) + receipt = evaluate(_inputs(determinism_runs=runs)) + + assert receipt.checks["P4"].status == "FAIL" + assert receipt.checks["P4"].detail["distinct_seed_count"] == 19 + + +def test_chain_cost_slower_fails_p5() -> None: + receipt = evaluate(_inputs(case_seconds_C={_C_CASE: 2.1})) + + assert receipt.checks["P5"].status == "FAIL" + assert receipt.checks["P5"].detail["chain_seconds"] == 2.1 + + +def test_chain_case_without_time_is_missing_p5() -> None: + receipt = evaluate(_inputs(case_seconds_C={})) + + assert receipt.checks["P5"].status == "MISSING" + assert receipt.verdict == "FAIL" + assert receipt.checks["P5"].detail["missing_chain_cases"] == [_C_CASE] + + +@pytest.mark.parametrize( + ("field", "check_id"), + [ + ("mutation_before", "P1"), + ("mutation_after", "P1"), + ("coverage_before", "P2"), + ("coverage_after", "P2"), + ("walker_error_paths_asserted_by_T", "P3"), + ("walker_error_paths_asserted_by_C", "P3"), + ("determinism_runs", "P4"), + ("case_seconds_T", "P5"), + ("case_seconds_C", "P5"), + ], +) +def test_missing_input_fails_closed( + field: str, + check_id: str, + tmp_path: Path, +) -> None: + receipt = evaluate(_inputs(**{field: None})) + assert receipt.checks[check_id].status == "MISSING" + assert receipt.verdict == "FAIL" + + inputs_path = tmp_path / f"{field}.json" + receipt_path = tmp_path / f"{field}.receipt.json" + _write_inputs(inputs_path, _inputs(**{field: None})) + assert ( + main(["evaluate", "--inputs", str(inputs_path), "--out", str(receipt_path)]) + == 1 + ) + + +def test_missing_and_malformed_input_files_exit_one(tmp_path: Path) -> None: + out = tmp_path / "receipt.json" + assert ( + main(["evaluate", "--inputs", str(tmp_path / "absent.json"), "--out", str(out)]) + == 1 + ) + + malformed = tmp_path / "malformed.json" + malformed.write_text("{not-json", encoding="utf-8") + assert main(["evaluate", "--inputs", str(malformed), "--out", str(out)]) == 1 + + +def test_coverage_from_json(tmp_path: Path) -> None: + report = tmp_path / "coverage.json" + report.write_text( + json.dumps( + { + "meta": {"branch_coverage": True}, + "files": { + _HANDLER: { + "executed_lines": [10, 11], + "executed_branches": [[10, 11], [10, 14]], + } + }, + } + ), + encoding="utf-8", + ) + + assert coverage_from_json(report) == { + _HANDLER: FileCoverage( + executed_lines=[10, 11], + executed_branches=[(10, 11), (10, 14)], + ) + } + + +def test_junit_case_seconds_maps_classname_to_pytest_node_id(tmp_path: Path) -> None: + report = tmp_path / "junit.xml" + report.write_text( + """ + + + +""", + encoding="utf-8", + ) + + assert junit_case_seconds(report) == { + "tests/unit/scripts/ci/test_sample.py::test_fast": 0.125 + } + + +def test_measure_arguments_parse_without_running_measurements(tmp_path: Path) -> None: + args = parse_args( + [ + "measure", + "--flow", + "sample", + "--handlers", + _HANDLER, + "--deleted", + str(tmp_path / "deleted.txt"), + "--chain", + str(tmp_path / "chain.txt"), + "--pytest-args=-q -x", + "--out-dir", + str(tmp_path / "out"), + ] + ) + + assert args.command == "measure" + assert args.handlers == [_HANDLER] + assert args.pytest_args == "-q -x" + + +def test_zero_mutants_is_a_missing_measurement_not_a_pass() -> None: + """An empty mutation run over H must never pass P1 vacuously.""" + receipt = evaluate(_inputs(mutation_before={}, mutation_after={})) + assert receipt.checks["P1"].status == "MISSING" + assert receipt.verdict == "FAIL" diff --git a/uv.lock b/uv.lock index 03e08982fe..e43047884e 100644 --- a/uv.lock +++ b/uv.lock @@ -2155,6 +2155,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "libcst" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml", marker = "python_full_version != '3.13.*'" }, + { name = "pyyaml-ft", marker = "python_full_version == '3.13.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/c0/098e5c91ff1537f00c85a6438b6cb1863d17144680cc91f47c87f104a200/libcst-1.9.0.tar.gz", hash = "sha256:087b58a9afe076bb08e2d726478e1f16cb928d67ffa9092817e033c335de522a", size = 914739, upload-time = "2026-07-29T21:28:43.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/bb/d22c37c33dfe18084634f5ef89f8f0749ffe7b6e0ad312722aafd86bbbdb/libcst-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cd1a3500c41784075c4946a995d5ad89f68fa0d226b63ff3c4d78f6ea6dd23e5", size = 2043159, upload-time = "2026-07-29T19:24:49.013Z" }, + { url = "https://files.pythonhosted.org/packages/10/b8/2dedef84d72e7271119217503b69ed6dc5d0b2077685e163caae669d9c70/libcst-1.9.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:611cebd3bbc2014576f4dcc7b845b3c594c96ddc287a3db9b78f22eff156a7d3", size = 2203245, upload-time = "2026-07-29T19:24:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/e8/90/e02ac2dad647423f947bb11f8322bfdba8ccfdd380e6c7b695add2d1acd4/libcst-1.9.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:8d731abe1307720ea1a52d447555e8443a6d130e0e520243c0634a58f6edbc9d", size = 2255388, upload-time = "2026-07-29T19:24:52.21Z" }, + { url = "https://files.pythonhosted.org/packages/13/5f/6089a51518cfd2ff40950eb26bcc36951d7b6d4f4213568aa0290265aff7/libcst-1.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8bc5351d92ca6ac1cc32e097700e1161ad1ceaa4d9b2cca5abadb1e94576b325", size = 2268982, upload-time = "2026-07-29T19:24:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/ed/78/26881ec466fb70cbc129dca26ccb5a52a0061face2c5822e4b61f00f9699/libcst-1.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03165a264653bb77f6a11b412ae09c08bdb0c25864f3b8d42b816ac64b9d4b9e", size = 2378174, upload-time = "2026-07-29T19:24:55.175Z" }, + { url = "https://files.pythonhosted.org/packages/e1/7a/a4dba5f11faf12a12ffba06d18019987851aab33b590242a602ffd4fb1bd/libcst-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:b755ed4a4bc2faee849b54820023137d60d4c199e0a0822f0ff0c1bc49e49b48", size = 2103462, upload-time = "2026-07-29T19:24:56.966Z" }, + { url = "https://files.pythonhosted.org/packages/f5/13/57cb129093e0d6744b3c914ba6cbbdf51ed1b2c823882936c553a3a0cf1b/libcst-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e50576bad7d56459d9792cd0b0dfe5469dad13646e9a9c0a8b1ba20b269f332", size = 1979825, upload-time = "2026-07-29T19:24:58.403Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b1/befc0544283bb3923a928accf79ed685e5a725524bdb3491826670affc07/libcst-1.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b8b9df30f524317b097dc53065b25dda33d6a4cc3c7c8bf4fc83ca7559c58cc0", size = 2043754, upload-time = "2026-07-29T19:24:59.885Z" }, + { url = "https://files.pythonhosted.org/packages/45/50/fef7c172a8457c95894edf5fb04805024899cdfea41fa01b0636587b79e1/libcst-1.9.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e465a7bc9c2b9533eb9e06d2391f8819f811b5112919d4064c9fb8565aaafa08", size = 2203548, upload-time = "2026-07-29T19:25:01.323Z" }, + { url = "https://files.pythonhosted.org/packages/18/ff/764cd2be1fd99d774fc44039c319dc0ed1d9d9afeaa02759a05121cccd4b/libcst-1.9.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:8504b422c95676a8c27b517e1ac01413ece91bf356865c587ca9bdcd5708a2f7", size = 2255274, upload-time = "2026-07-29T19:25:02.764Z" }, + { url = "https://files.pythonhosted.org/packages/34/a7/474748a27a02fa83e3556b260d5f5236ca48164fa7beffecf3b2cad24dca/libcst-1.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bcb9f9d4fcfe2ec7a40d2c26e03a538d5d9dc189c38551eb3f94ab661afee7c0", size = 2269126, upload-time = "2026-07-29T19:25:04.158Z" }, + { url = "https://files.pythonhosted.org/packages/8a/b7/655e45363b8cf87b91e41e060b21c89e5316c7ef36eb14a1a498b27bb71e/libcst-1.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8d671c39a431c309476099b8ec811e412503ec0f4465f6fc907cb51c70e8e6b", size = 2378602, upload-time = "2026-07-29T19:25:06.424Z" }, + { url = "https://files.pythonhosted.org/packages/db/13/6da63f0902ece43bf9d737017251ad7ad06edd9d3c4e1856450403cb4473/libcst-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:4d382fba04077eb556a1ea4295a4482e192aa93639c5a07ba0885841965ea0c0", size = 2103486, upload-time = "2026-07-29T19:25:07.979Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ff/dccb1a55e38b4e47256a97242d61d2bef5366c744faf88fb087d4fa0f995/libcst-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:a621e261990148c1cfbe26c1798bd2375c131cf358a44a7a4659fc41c1a336e1", size = 1979907, upload-time = "2026-07-29T19:25:09.532Z" }, + { url = "https://files.pythonhosted.org/packages/65/2a/4943c71d90975bc59034a057dea346b365c276f308c1d31f5cf2bd85492d/libcst-1.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:eccf4c57d273cdd3fe1c67b72cf9bb1bbd4547aa011824e96ccf5b7136057aa4", size = 2044529, upload-time = "2026-07-29T19:25:11.04Z" }, + { url = "https://files.pythonhosted.org/packages/be/ed/1168b98c2a0f338be3a44753647baab051feaf6167cc887bf4447f8fd920/libcst-1.9.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:32395244edfe6538e0ea2bf82051d60103d3f54861274805c4fb3745efb70a85", size = 2203519, upload-time = "2026-07-29T19:25:12.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/7d/2eaa697a80f899bcf2245680bbfcc1e478eb3b3328c21d4b2880bdf00dac/libcst-1.9.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:444e84c76cd035cd2fe136838c1a524d34b08216521f6f5093df8a6f6cfa5799", size = 2255879, upload-time = "2026-07-29T19:25:14.137Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/793b9fd96dd52d202f5f2b88d7e54f05ebed767d1bb3b32395a1817bf6ab/libcst-1.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb5d0946f2b4c6711b5d69fe4f833b364f9e7a1a2b08f88b98619dc18975099a", size = 2269807, upload-time = "2026-07-29T19:25:15.647Z" }, + { url = "https://files.pythonhosted.org/packages/a3/f4/1bc7aaea03971c45e8a885fed9fc1c73176dbbd00b0296a3cde9529bafd6/libcst-1.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:45808c03528b3ad40b14095348a918e08d98c47b4a125d631cb78e817c0a5b16", size = 2378171, upload-time = "2026-07-29T19:25:17.289Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f7/bc49e367d2bc8817213594dd52cf6b2da2dbbda90b5382d60653999274ac/libcst-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:568288cdbfe3b4ca3ae4852cb0a439ff053dd54c841bc4995bf2b71238b5de40", size = 2177847, upload-time = "2026-07-29T19:25:19.35Z" }, + { url = "https://files.pythonhosted.org/packages/87/80/4d81577a22e6d535d1a3409f3a1c6903e09036f0e17fc47f92169dbc3501/libcst-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:107593af46945593e7825821793393262bc4fa1d3ea24c3ed487b61269bbbdf8", size = 2058134, upload-time = "2026-07-29T19:25:20.769Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7f/c3f3a0e7a1a2adaa76e815e82a7814d6bfe7d49432276bba652248c68d0b/libcst-1.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f6248cb07444ab9a6733a855737a9febed8b9adca51347019348b09a3ac7dfe9", size = 2036102, upload-time = "2026-07-29T19:25:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/4e/af/2f5543255b2c7d749b966adeccc6bfb1652cf8b425afb913d0f3f025a865/libcst-1.9.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:496c24e0d3240bc7da45dae543aff3f5b6509978c39262d6b84ed2fb999dded2", size = 2194806, upload-time = "2026-07-29T19:25:24.017Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5b/03f4cddce426d005e208b39ea7b2d456e667cdee0f1891360f0fc8430f20/libcst-1.9.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ea490fa8540503db5f321f0268becab46eb50f710e8cec8041e241fd66f6874f", size = 2246762, upload-time = "2026-07-29T19:25:25.365Z" }, + { url = "https://files.pythonhosted.org/packages/d8/31/9d5fe1e43dc3dbcc74f70f3e0e73fcdd8d84effc1059d8b45974f0d0d2eb/libcst-1.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:50ab94bb2524b419056d4003032b8c67102ac800f8d85b3c4260a01746d94dbb", size = 2259633, upload-time = "2026-07-29T19:25:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2b/6752b28d88c3a19b3bc0b9e1838443b6760d5c862b4f4b37955402659e24/libcst-1.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a2faaf92500d0226358125630f5aab4758e8aad3f2d70a10892ec3c700781a54", size = 2368043, upload-time = "2026-07-29T19:25:28.344Z" }, + { url = "https://files.pythonhosted.org/packages/e2/94/775825b2637f8ab05694b6a4b3802ae6783b4e799f9b58d2400c7e2d4369/libcst-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0c7b548512db25af9c2997a95fa731bd6b6928ecbad6c0915d7482d8bb42d34f", size = 2176432, upload-time = "2026-07-29T19:25:29.921Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3d/88ad67427c6fd9db929e087912b0a540e5140e5cb77e7ca4170edaac8531/libcst-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:497d5329345f1f5df84e41b0bbd00204b64a2fd30dfe3cfaeaebca633a31e877", size = 2052931, upload-time = "2026-07-29T19:25:31.397Z" }, + { url = "https://files.pythonhosted.org/packages/39/e4/ad790b043a38b10cea13166c8dd716654ad8b227c20f12eebf6326191cd9/libcst-1.9.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:a5068bf6114f6f4d79af7a6c80a28d1deb50441150ea1db13b5458ab34bec159", size = 2043987, upload-time = "2026-08-11T05:54:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/15/6b/d3cd8275cc54ffc9817ade91442b3e6e9edd1a4518bd4e6dda13e3152dbe/libcst-1.9.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:7acfd18adcdd32dcf41ce676cf121002afcbe9ad2c72dc0c18d78465beedc228", size = 2204003, upload-time = "2026-08-11T05:54:04.031Z" }, + { url = "https://files.pythonhosted.org/packages/da/62/87eddccb11d5d221d50ac4fff4b6e9b8d48c547028d01f7d0fbc5c8a1d75/libcst-1.9.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:86361e2b426bd1b403e52375703c8b764e226e571adcb30442510710681edbf0", size = 2256053, upload-time = "2026-08-11T05:54:06.228Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/a44126aeb9fca8b4e342e0cc803ea00ca8f6cd5712619a7897b3eba13797/libcst-1.9.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8c14abe844bec8b021111b3985474e49f5af799c020e8e40dedcac87c97a40c5", size = 2270576, upload-time = "2026-08-11T05:54:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/bb/51/3af3dd44b117d66486e0ed61e43a16b7fc8cc5c372e1dd4ca0e70b888d46/libcst-1.9.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:8930971d2299b7006bb5d10c10038f44efb6da89d5bca2823595e31a9cdb96af", size = 2378573, upload-time = "2026-08-11T05:54:10.093Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e8/e545087d298dbf6494e84a8092f0f5dbd62eedacabd4ecd6b364367b4804/libcst-1.9.0-cp315-cp315-win_amd64.whl", hash = "sha256:5891ce9cff815077614f509e3a23888c5ddb8081d6188e8ba1172c0ccc369022", size = 2177937, upload-time = "2026-08-11T05:54:12.168Z" }, + { url = "https://files.pythonhosted.org/packages/84/17/93a00a8e03494a84db102dd0e3d35b8876b1084ca2c194b331a168d86eb1/libcst-1.9.0-cp315-cp315-win_arm64.whl", hash = "sha256:1260b5d070a3324447a53a00387225a55472a62077ce01922d30a2a78cc4b138", size = 2058633, upload-time = "2026-08-11T05:54:13.817Z" }, + { url = "https://files.pythonhosted.org/packages/d7/00/c3751b12eb81822ea0b6131d374a98bc6c024c4b9ec5f6ea8f53dc23e967/libcst-1.9.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:02ee2dbdb5c218116f16a021350fc267a14be205b50d81c33f5d73857ab6fbf7", size = 2036178, upload-time = "2026-08-11T05:54:15.545Z" }, + { url = "https://files.pythonhosted.org/packages/2f/12/48ff486eb5adc593f4818569e8896b74b672414ab4722a74b17e4c4cf31e/libcst-1.9.0-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:3e5b684191a0462b2d40a73261ea7f4da6a49e7a030b7e0fceca46bebb51dfe5", size = 2195496, upload-time = "2026-08-11T05:54:17.625Z" }, + { url = "https://files.pythonhosted.org/packages/51/18/b13a4669864d41a4801fed7b86ede1049dc9a1e1dde250a7ee1f9c3b1285/libcst-1.9.0-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:2b150c4f298fe54eb0fe73abf71f57db74d070796140a8c206c9615b6861f01e", size = 2245769, upload-time = "2026-08-11T05:54:19.343Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c7/cf2d46744825e84afbd7228fdeeea8d23cf6c4b2074253c27efb7705c653/libcst-1.9.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:50b913e37187f00a6fb88962009364e1cf1b1284aa1762304f34b52b972f2218", size = 2260576, upload-time = "2026-08-11T05:54:21.444Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/5433d3d62250b2e4325938db101766bcab2a006819684713bcccb6259a88/libcst-1.9.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:9f92c75283030fd58fb7d6e560168a00381e0e3368ec8a026a3ec8a828a05f93", size = 2368709, upload-time = "2026-08-11T05:54:23.276Z" }, + { url = "https://files.pythonhosted.org/packages/87/39/9f0e1690727623f99489895db0e42048a3e1e8cea0627517c593e437fd83/libcst-1.9.0-cp315-cp315t-win_amd64.whl", hash = "sha256:4adf97bb1aff8039b0bd4991e2f838be6e4fad552821b26b71a5d1a653c2582f", size = 2177866, upload-time = "2026-08-11T05:54:25.144Z" }, + { url = "https://files.pythonhosted.org/packages/cd/79/9dc7811883e67ea771057e8937bc536eb3e77f3635fc5a93cd85b6fe1ea8/libcst-1.9.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8f0dd08a5773d7051e105250b2a2c73d8065ea9b2d68e7e1bdc5244660e75f93", size = 2052908, upload-time = "2026-08-11T05:54:26.835Z" }, +] + [[package]] name = "librt" version = "0.15.0" @@ -2630,6 +2684,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/ee/be4e1a4b7a2b27f4fb6936510d4bebcb41b0562c946930ad26916e069cf9/multidict-6.8.0-py3-none-any.whl", hash = "sha256:75daa15ca16d6285eb2e104b2f05ee6f8d9836c68da3ce5c85f615a0450eed0e", size = 16297, upload-time = "2026-09-09T13:57:56.106Z" }, ] +[[package]] +name = "mutmut" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "coverage" }, + { name = "libcst" }, + { name = "pytest" }, + { name = "setproctitle" }, + { name = "textual" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/30/b334a22184026d92a7549c8a76ad1d397ea65a6f8534ef486556ab861c70/mutmut-3.8.0.tar.gz", hash = "sha256:bc670276518f893ab356f1d28826c49b7e606e7cf89c300e3888c0b52fc1d2ed", size = 83631, upload-time = "2026-09-12T16:53:58.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/2a/a9904a45804c1de50c0c3baad1512b78d5b66d2befd841104b8a62bf0f35/mutmut-3.8.0-py3-none-any.whl", hash = "sha256:4c0abcef6b8d1116f2adfaad19df12e1c621e4a402d4367ef15bf8cba497b91d", size = 81469, upload-time = "2026-09-12T16:53:56.906Z" }, +] + [[package]] name = "mypy" version = "2.3.1" @@ -3129,6 +3200,9 @@ dev = [ { name = "types-jsonschema" }, { name = "types-pyyaml" }, ] +parity = [ + { name = "mutmut" }, +] [package.metadata] requires-dist = [ @@ -3181,6 +3255,7 @@ dev = [ { name = "types-jsonschema", specifier = ">=4.25.1.20251222" }, { name = "types-pyyaml", specifier = ">=6.0.12.20250822" }, ] +parity = [{ name = "mutmut", specifier = ">=3.3,<4" }] [[package]] name = "omninode-memory" @@ -4226,6 +4301,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "pyyaml-ft" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/eb/5a0d575de784f9a1f94e2b1288c6886f13f34185e13117ed530f32b6f8a8/pyyaml_ft-8.0.0.tar.gz", hash = "sha256:0c947dce03954c7b5d38869ed4878b2e6ff1d44b08a0d84dc83fdad205ae39ab", size = 141057, upload-time = "2025-06-10T15:32:15.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/ba/a067369fe61a2e57fb38732562927d5bae088c73cb9bb5438736a9555b29/pyyaml_ft-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8c1306282bc958bfda31237f900eb52c9bedf9b93a11f82e1aab004c9a5657a6", size = 187027, upload-time = "2025-06-10T15:31:48.722Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c5/a3d2020ce5ccfc6aede0d45bcb870298652ac0cf199f67714d250e0cdf39/pyyaml_ft-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30c5f1751625786c19de751e3130fc345ebcba6a86f6bddd6e1285342f4bbb69", size = 176146, upload-time = "2025-06-10T15:31:50.584Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bb/23a9739291086ca0d3189eac7cd92b4d00e9fdc77d722ab610c35f9a82ba/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fa992481155ddda2e303fcc74c79c05eddcdbc907b888d3d9ce3ff3e2adcfb0", size = 746792, upload-time = "2025-06-10T15:31:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c2/e8825f4ff725b7e560d62a3609e31d735318068e1079539ebfde397ea03e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cec6c92b4207004b62dfad1f0be321c9f04725e0f271c16247d8b39c3bf3ea42", size = 786772, upload-time = "2025-06-10T15:31:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/35/be/58a4dcae8854f2fdca9b28d9495298fd5571a50d8430b1c3033ec95d2d0e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06237267dbcab70d4c0e9436d8f719f04a51123f0ca2694c00dd4b68c338e40b", size = 778723, upload-time = "2025-06-10T15:31:56.093Z" }, + { url = "https://files.pythonhosted.org/packages/86/ed/fed0da92b5d5d7340a082e3802d84c6dc9d5fa142954404c41a544c1cb92/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a7f332bc565817644cdb38ffe4739e44c3e18c55793f75dddb87630f03fc254", size = 758478, upload-time = "2025-06-10T15:31:58.314Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/ac02afe286275980ecb2dcdc0156617389b7e0c0a3fcdedf155c67be2b80/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7d10175a746be65f6feb86224df5d6bc5c049ebf52b89a88cf1cd78af5a367a8", size = 799159, upload-time = "2025-06-10T15:31:59.675Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ac/c492a9da2e39abdff4c3094ec54acac9747743f36428281fb186a03fab76/pyyaml_ft-8.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:58e1015098cf8d8aec82f360789c16283b88ca670fe4275ef6c48c5e30b22a96", size = 158779, upload-time = "2025-06-10T15:32:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9b/41998df3298960d7c67653669f37710fa2d568a5fc933ea24a6df60acaf6/pyyaml_ft-8.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5f3e2ceb790d50602b2fd4ec37abbd760a8c778e46354df647e7c5a4ebb", size = 191331, upload-time = "2025-06-10T15:32:02.602Z" }, + { url = "https://files.pythonhosted.org/packages/0f/16/2710c252ee04cbd74d9562ebba709e5a284faeb8ada88fcda548c9191b47/pyyaml_ft-8.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d445bf6ea16bb93c37b42fdacfb2f94c8e92a79ba9e12768c96ecde867046d1", size = 182879, upload-time = "2025-06-10T15:32:04.466Z" }, + { url = "https://files.pythonhosted.org/packages/9a/40/ae8163519d937fa7bfa457b6f78439cc6831a7c2b170e4f612f7eda71815/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c56bb46b4fda34cbb92a9446a841da3982cdde6ea13de3fbd80db7eeeab8b49", size = 811277, upload-time = "2025-06-10T15:32:06.214Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/28d82dbff7f87b96f0eeac79b7d972a96b4980c1e445eb6a857ba91eda00/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dab0abb46eb1780da486f022dce034b952c8ae40753627b27a626d803926483b", size = 831650, upload-time = "2025-06-10T15:32:08.076Z" }, + { url = "https://files.pythonhosted.org/packages/e8/df/161c4566facac7d75a9e182295c223060373d4116dead9cc53a265de60b9/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd48d639cab5ca50ad957b6dd632c7dd3ac02a1abe0e8196a3c24a52f5db3f7a", size = 815755, upload-time = "2025-06-10T15:32:09.435Z" }, + { url = "https://files.pythonhosted.org/packages/05/10/f42c48fa5153204f42eaa945e8d1fd7c10d6296841dcb2447bf7da1be5c4/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:052561b89d5b2a8e1289f326d060e794c21fa068aa11255fe71d65baf18a632e", size = 810403, upload-time = "2025-06-10T15:32:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/e369064aa51009eb9245399fd8ad2c562bd0bcd392a00be44b2a824ded7c/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3bb4b927929b0cb162fb1605392a321e3333e48ce616cdcfa04a839271373255", size = 835581, upload-time = "2025-06-10T15:32:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/c0/28/26534bed77109632a956977f60d8519049f545abc39215d086e33a61f1f2/pyyaml_ft-8.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:de04cfe9439565e32f178106c51dd6ca61afaa2907d143835d501d84703d3793", size = 171579, upload-time = "2025-06-10T15:32:14.34Z" }, +] + [[package]] name = "qdrant-client" version = "1.19.0" @@ -4845,6 +4944,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/fc/c71335a5046d46bf3b761a8c7fa092b40a7721429a5613538f0a28ae80a6/securesystemslib-1.5.1-py3-none-any.whl", hash = "sha256:ada8bdf817da29ece4ba91654f6a162ce7cfbadbc3ae3f840f7313f9d22675de", size = 876390, upload-time = "2026-09-04T06:55:30.393Z" }, ] +[[package]] +name = "setproctitle" +version = "1.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/48/49393a96a2eef1ab418b17475fb92b8fcfad83d099e678751b05472e69de/setproctitle-1.3.7.tar.gz", hash = "sha256:bc2bc917691c1537d5b9bca1468437176809c7e11e5694ca79a9ca12345dcb9e", size = 27002, upload-time = "2025-09-05T12:51:25.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/f0/2dc88e842077719d7384d86cc47403e5102810492b33680e7dadcee64cd8/setproctitle-1.3.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2dc99aec591ab6126e636b11035a70991bc1ab7a261da428491a40b84376654e", size = 18049, upload-time = "2025-09-05T12:49:36.241Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b4/50940504466689cda65680c9e9a1e518e5750c10490639fa687489ac7013/setproctitle-1.3.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdd8aa571b7aa39840fdbea620e308a19691ff595c3a10231e9ee830339dd798", size = 13079, upload-time = "2025-09-05T12:49:38.088Z" }, + { url = "https://files.pythonhosted.org/packages/d0/99/71630546b9395b095f4082be41165d1078204d1696c2d9baade3de3202d0/setproctitle-1.3.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2906b6c7959cdb75f46159bf0acd8cc9906cf1361c9e1ded0d065fe8f9039629", size = 32932, upload-time = "2025-09-05T12:49:39.271Z" }, + { url = "https://files.pythonhosted.org/packages/50/22/cee06af4ffcfb0e8aba047bd44f5262e644199ae7527ae2c1f672b86495c/setproctitle-1.3.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6915964a6dda07920a1159321dcd6d94fc7fc526f815ca08a8063aeca3c204f1", size = 33736, upload-time = "2025-09-05T12:49:40.565Z" }, + { url = "https://files.pythonhosted.org/packages/5c/00/a5949a8bb06ef5e7df214fc393bb2fb6aedf0479b17214e57750dfdd0f24/setproctitle-1.3.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cff72899861c765bd4021d1ff1c68d60edc129711a2fdba77f9cb69ef726a8b6", size = 35605, upload-time = "2025-09-05T12:49:42.362Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3a/50caca532a9343828e3bf5778c7a84d6c737a249b1796d50dd680290594d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7cb05bd446687ff816a3aaaf831047fc4c364feff7ada94a66024f1367b448c", size = 33143, upload-time = "2025-09-05T12:49:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ca/14/b843a251296ce55e2e17c017d6b9f11ce0d3d070e9265de4ecad948b913d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3a57b9a00de8cae7e2a1f7b9f0c2ac7b69372159e16a7708aa2f38f9e5cc987a", size = 34434, upload-time = "2025-09-05T12:49:45.31Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b7/06145c238c0a6d2c4bc881f8be230bb9f36d2bf51aff7bddcb796d5eed67/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d8828b356114f6b308b04afe398ed93803d7fca4a955dd3abe84430e28d33739", size = 32795, upload-time = "2025-09-05T12:49:46.419Z" }, + { url = "https://files.pythonhosted.org/packages/ef/dc/ef76a81fac9bf27b84ed23df19c1f67391a753eed6e3c2254ebcb5133f56/setproctitle-1.3.7-cp312-cp312-win32.whl", hash = "sha256:b0304f905efc845829ac2bc791ddebb976db2885f6171f4a3de678d7ee3f7c9f", size = 12552, upload-time = "2025-09-05T12:49:47.635Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5b/a9fe517912cd6e28cf43a212b80cb679ff179a91b623138a99796d7d18a0/setproctitle-1.3.7-cp312-cp312-win_amd64.whl", hash = "sha256:9888ceb4faea3116cf02a920ff00bfbc8cc899743e4b4ac914b03625bdc3c300", size = 13247, upload-time = "2025-09-05T12:49:49.16Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2f/fcedcade3b307a391b6e17c774c6261a7166aed641aee00ed2aad96c63ce/setproctitle-1.3.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3736b2a423146b5e62230502e47e08e68282ff3b69bcfe08a322bee73407922", size = 18047, upload-time = "2025-09-05T12:49:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/23/ae/afc141ca9631350d0a80b8f287aac79a76f26b6af28fd8bf92dae70dc2c5/setproctitle-1.3.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3384e682b158d569e85a51cfbde2afd1ab57ecf93ea6651fe198d0ba451196ee", size = 13073, upload-time = "2025-09-05T12:49:51.46Z" }, + { url = "https://files.pythonhosted.org/packages/87/ed/0a4f00315bc02510395b95eec3d4aa77c07192ee79f0baae77ea7b9603d8/setproctitle-1.3.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0564a936ea687cd24dffcea35903e2a20962aa6ac20e61dd3a207652401492dd", size = 33284, upload-time = "2025-09-05T12:49:52.741Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e4/adf3c4c0a2173cb7920dc9df710bcc67e9bcdbf377e243b7a962dc31a51a/setproctitle-1.3.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5d1cb3f81531f0eb40e13246b679a1bdb58762b170303463cb06ecc296f26d0", size = 34104, upload-time = "2025-09-05T12:49:54.416Z" }, + { url = "https://files.pythonhosted.org/packages/52/4f/6daf66394152756664257180439d37047aa9a1cfaa5e4f5ed35e93d1dc06/setproctitle-1.3.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a7d159e7345f343b44330cbba9194169b8590cb13dae940da47aa36a72aa9929", size = 35982, upload-time = "2025-09-05T12:49:56.295Z" }, + { url = "https://files.pythonhosted.org/packages/1b/62/f2c0595403cf915db031f346b0e3b2c0096050e90e0be658a64f44f4278a/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0b5074649797fd07c72ca1f6bff0406f4a42e1194faac03ecaab765ce605866f", size = 33150, upload-time = "2025-09-05T12:49:58.025Z" }, + { url = "https://files.pythonhosted.org/packages/a0/29/10dd41cde849fb2f9b626c846b7ea30c99c81a18a5037a45cc4ba33c19a7/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61e96febced3f61b766115381d97a21a6265a0f29188a791f6df7ed777aef698", size = 34463, upload-time = "2025-09-05T12:49:59.424Z" }, + { url = "https://files.pythonhosted.org/packages/71/3c/cedd8eccfaf15fb73a2c20525b68c9477518917c9437737fa0fda91e378f/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:047138279f9463f06b858e579cc79580fbf7a04554d24e6bddf8fe5dddbe3d4c", size = 32848, upload-time = "2025-09-05T12:50:01.107Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3e/0a0e27d1c9926fecccfd1f91796c244416c70bf6bca448d988638faea81d/setproctitle-1.3.7-cp313-cp313-win32.whl", hash = "sha256:7f47accafac7fe6535ba8ba9efd59df9d84a6214565108d0ebb1199119c9cbbd", size = 12544, upload-time = "2025-09-05T12:50:15.81Z" }, + { url = "https://files.pythonhosted.org/packages/36/1b/6bf4cb7acbbd5c846ede1c3f4d6b4ee52744d402e43546826da065ff2ab7/setproctitle-1.3.7-cp313-cp313-win_amd64.whl", hash = "sha256:fe5ca35aeec6dc50cabab9bf2d12fbc9067eede7ff4fe92b8f5b99d92e21263f", size = 13235, upload-time = "2025-09-05T12:50:16.89Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a4/d588d3497d4714750e3eaf269e9e8985449203d82b16b933c39bd3fc52a1/setproctitle-1.3.7-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:10e92915c4b3086b1586933a36faf4f92f903c5554f3c34102d18c7d3f5378e9", size = 18058, upload-time = "2025-09-05T12:50:02.501Z" }, + { url = "https://files.pythonhosted.org/packages/05/77/7637f7682322a7244e07c373881c7e982567e2cb1dd2f31bd31481e45500/setproctitle-1.3.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:de879e9c2eab637f34b1a14c4da1e030c12658cdc69ee1b3e5be81b380163ce5", size = 13072, upload-time = "2025-09-05T12:50:03.601Z" }, + { url = "https://files.pythonhosted.org/packages/52/09/f366eca0973cfbac1470068d1313fa3fe3de4a594683385204ec7f1c4101/setproctitle-1.3.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c18246d88e227a5b16248687514f95642505000442165f4b7db354d39d0e4c29", size = 34490, upload-time = "2025-09-05T12:50:04.948Z" }, + { url = "https://files.pythonhosted.org/packages/71/36/611fc2ed149fdea17c3677e1d0df30d8186eef9562acc248682b91312706/setproctitle-1.3.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7081f193dab22df2c36f9fc6d113f3793f83c27891af8fe30c64d89d9a37e152", size = 35267, upload-time = "2025-09-05T12:50:06.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/a4/64e77d0671446bd5a5554387b69e1efd915274686844bea733714c828813/setproctitle-1.3.7-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cc9b901ce129350637426a89cfd650066a4adc6899e47822e2478a74023ff7c", size = 37376, upload-time = "2025-09-05T12:50:07.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/ad9c664fe524fb4a4b2d3663661a5c63453ce851736171e454fa2cdec35c/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:80e177eff2d1ec172188d0d7fd9694f8e43d3aab76a6f5f929bee7bf7894e98b", size = 33963, upload-time = "2025-09-05T12:50:09.056Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a36de7caf2d90c4c28678da1466b47495cbbad43badb4e982d8db8167ed4/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:23e520776c445478a67ee71b2a3c1ffdafbe1f9f677239e03d7e2cc635954e18", size = 35550, upload-time = "2025-09-05T12:50:10.791Z" }, + { url = "https://files.pythonhosted.org/packages/dd/68/17e8aea0ed5ebc17fbf03ed2562bfab277c280e3625850c38d92a7b5fcd9/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5fa1953126a3b9bd47049d58c51b9dac72e78ed120459bd3aceb1bacee72357c", size = 33727, upload-time = "2025-09-05T12:50:12.032Z" }, + { url = "https://files.pythonhosted.org/packages/b2/33/90a3bf43fe3a2242b4618aa799c672270250b5780667898f30663fd94993/setproctitle-1.3.7-cp313-cp313t-win32.whl", hash = "sha256:4a5e212bf438a4dbeece763f4962ad472c6008ff6702e230b4f16a037e2f6f29", size = 12549, upload-time = "2025-09-05T12:50:13.074Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0e/50d1f07f3032e1f23d814ad6462bc0a138f369967c72494286b8a5228e40/setproctitle-1.3.7-cp313-cp313t-win_amd64.whl", hash = "sha256:cf2727b733e90b4f874bac53e3092aa0413fe1ea6d4f153f01207e6ce65034d9", size = 13243, upload-time = "2025-09-05T12:50:14.146Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/43ac3a98414f91d1b86a276bc2f799ad0b4b010e08497a95750d5bc42803/setproctitle-1.3.7-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:80c36c6a87ff72eabf621d0c79b66f3bdd0ecc79e873c1e9f0651ee8bf215c63", size = 18052, upload-time = "2025-09-05T12:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2c/dc258600a25e1a1f04948073826bebc55e18dbd99dc65a576277a82146fa/setproctitle-1.3.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b53602371a52b91c80aaf578b5ada29d311d12b8a69c0c17fbc35b76a1fd4f2e", size = 13071, upload-time = "2025-09-05T12:50:19.061Z" }, + { url = "https://files.pythonhosted.org/packages/ab/26/8e3bb082992f19823d831f3d62a89409deb6092e72fc6940962983ffc94f/setproctitle-1.3.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fcb966a6c57cf07cc9448321a08f3be6b11b7635be502669bc1d8745115d7e7f", size = 33180, upload-time = "2025-09-05T12:50:20.395Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/ae692a20276d1159dd0cf77b0bcf92cbb954b965655eb4a69672099bb214/setproctitle-1.3.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46178672599b940368d769474fe13ecef1b587d58bb438ea72b9987f74c56ea5", size = 34043, upload-time = "2025-09-05T12:50:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/34/b2/6a092076324dd4dac1a6d38482bedebbff5cf34ef29f58585ec76e47bc9d/setproctitle-1.3.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f9e9e3ff135cbcc3edd2f4cf29b139f4aca040d931573102742db70ff428c17", size = 35892, upload-time = "2025-09-05T12:50:23.937Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1a/8836b9f28cee32859ac36c3df85aa03e1ff4598d23ea17ca2e96b5845a8f/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14c7eba8d90c93b0e79c01f0bd92a37b61983c27d6d7d5a3b5defd599113d60e", size = 32898, upload-time = "2025-09-05T12:50:25.617Z" }, + { url = "https://files.pythonhosted.org/packages/ef/22/8fabdc24baf42defb599714799d8445fe3ae987ec425a26ec8e80ea38f8e/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e64e98077fb30b6cf98073d6c439cd91deb8ebbf8fc62d9dbf52bd38b0c6ac0", size = 34308, upload-time = "2025-09-05T12:50:26.827Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/b9bee9de6c8cdcb3b3a6cb0b3e773afdb86bbbc1665a3bfa424a4294fda2/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b91387cc0f02a00ac95dcd93f066242d3cca10ff9e6153de7ee07069c6f0f7c8", size = 32536, upload-time = "2025-09-05T12:50:28.5Z" }, + { url = "https://files.pythonhosted.org/packages/37/0c/75e5f2685a5e3eda0b39a8b158d6d8895d6daf3ba86dec9e3ba021510272/setproctitle-1.3.7-cp314-cp314-win32.whl", hash = "sha256:52b054a61c99d1b72fba58b7f5486e04b20fefc6961cd76722b424c187f362ed", size = 12731, upload-time = "2025-09-05T12:50:43.955Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/acddbce90d1361e1786e1fb421bc25baeb0c22ef244ee5d0176511769ec8/setproctitle-1.3.7-cp314-cp314-win_amd64.whl", hash = "sha256:5818e4080ac04da1851b3ec71e8a0f64e3748bf9849045180566d8b736702416", size = 13464, upload-time = "2025-09-05T12:50:45.057Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/20886c8ff2e6d85e3cabadab6aab9bb90acaf1a5cfcb04d633f8d61b2626/setproctitle-1.3.7-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6fc87caf9e323ac426910306c3e5d3205cd9f8dcac06d233fcafe9337f0928a3", size = 18062, upload-time = "2025-09-05T12:50:29.78Z" }, + { url = "https://files.pythonhosted.org/packages/9a/60/26dfc5f198715f1343b95c2f7a1c16ae9ffa45bd89ffd45a60ed258d24ea/setproctitle-1.3.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6134c63853d87a4897ba7d5cc0e16abfa687f6c66fc09f262bb70d67718f2309", size = 13075, upload-time = "2025-09-05T12:50:31.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/9c/980b01f50d51345dd513047e3ba9e96468134b9181319093e61db1c47188/setproctitle-1.3.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1403d2abfd32790b6369916e2313dffbe87d6b11dca5bbd898981bcde48e7a2b", size = 34744, upload-time = "2025-09-05T12:50:32.777Z" }, + { url = "https://files.pythonhosted.org/packages/86/b4/82cd0c86e6d1c4538e1a7eb908c7517721513b801dff4ba3f98ef816a240/setproctitle-1.3.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7c5bfe4228ea22373e3025965d1a4116097e555ee3436044f5c954a5e63ac45", size = 35589, upload-time = "2025-09-05T12:50:34.13Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4f/9f6b2a7417fd45673037554021c888b31247f7594ff4bd2239918c5cd6d0/setproctitle-1.3.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:585edf25e54e21a94ccb0fe81ad32b9196b69ebc4fc25f81da81fb8a50cca9e4", size = 37698, upload-time = "2025-09-05T12:50:35.524Z" }, + { url = "https://files.pythonhosted.org/packages/20/92/927b7d4744aac214d149c892cb5fa6dc6f49cfa040cb2b0a844acd63dcaf/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:96c38cdeef9036eb2724c2210e8d0b93224e709af68c435d46a4733a3675fee1", size = 34201, upload-time = "2025-09-05T12:50:36.697Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0c/fd4901db5ba4b9d9013e62f61d9c18d52290497f956745cd3e91b0d80f90/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:45e3ef48350abb49cf937d0a8ba15e42cee1e5ae13ca41a77c66d1abc27a5070", size = 35801, upload-time = "2025-09-05T12:50:38.314Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e3/54b496ac724e60e61cc3447f02690105901ca6d90da0377dffe49ff99fc7/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1fae595d032b30dab4d659bece20debd202229fce12b55abab978b7f30783d73", size = 33958, upload-time = "2025-09-05T12:50:39.841Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/c84bb045ebf8c6fdc7f7532319e86f8380d14bbd3084e6348df56bdfe6fd/setproctitle-1.3.7-cp314-cp314t-win32.whl", hash = "sha256:02432f26f5d1329ab22279ff863c83589894977063f59e6c4b4845804a08f8c2", size = 12745, upload-time = "2025-09-05T12:50:41.377Z" }, + { url = "https://files.pythonhosted.org/packages/08/b6/3a5a4f9952972791a9114ac01dfc123f0df79903577a3e0a7a404a695586/setproctitle-1.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:cbc388e3d86da1f766d8fc2e12682e446064c01cea9f88a88647cfe7c011de6a", size = 13469, upload-time = "2025-09-05T12:50:42.67Z" }, +] + [[package]] name = "setuptools" version = "84.0.0" From 11283f4e5cc93127b9c33b0cb4d5d751c949aeec Mon Sep 17 00:00:00 2001 From: jonahgabriel Date: Sat, 26 Sep 2026 07:49:07 -0400 Subject: [PATCH 2/7] ci(OMN-19711): mutmut copies the whole src tree; junit ids keep test classes Two defects found by the first h201 measure run over the four delegation handlers (verdict FAIL, exit 1, as it should on the planted pair): - naming the handler files in mutmut's paths_to_mutate copied only those files into mutants/, so mutants/src/omnimarket was a namespace portion that lost to the editable install; the tests imported the original code and mutmut stopped with no test case for any mutant (P1 MISSING). The scratch config now sets source_paths to src, only_mutate to H, and turns off git change detection in the .git-less scratch copy. - junit classname tests.x.test_y.TestZ was mapped to tests/x/test_y/TestZ.py, so all 21 class-based deleted cases had no time (P5 MISSING). The mapping now resolves the longest prefix naming a .py file and keeps the classes. --- scripts/ci/chain_replacement_parity.py | 52 +++++++++++++------ .../ci/test_chain_replacement_parity.py | 24 +++++++++ 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/scripts/ci/chain_replacement_parity.py b/scripts/ci/chain_replacement_parity.py index becb75fe87..42c5f058e7 100644 --- a/scripts/ci/chain_replacement_parity.py +++ b/scripts/ci/chain_replacement_parity.py @@ -19,10 +19,10 @@ "event_types": ["Started", "Completed"]} A chain case missing from a run's log is a missing P4 measurement. JUnit -``classname`` values are mapped to pytest ids by replacing dots with slashes, -appending ``.py``, and then appending ``::``. For example, -``tests.unit.test_x`` plus ``test_y`` becomes -``tests/unit/test_x.py::test_y``. +``classname`` values are mapped to pytest ids by resolving the longest dotted +prefix that names a ``.py`` file under the repository; the remaining parts are +test classes. ``tests.unit.test_x.TestY`` plus ``test_z`` becomes +``tests/unit/test_x.py::TestY::test_z``. The mutmut collector requires mutmut 3 from the ``parity`` dependency group. It copies the repository to scratch directories and appends a temporary @@ -427,14 +427,20 @@ def coverage_from_json(path: str | Path) -> dict[str, FileCoverage]: return result -def junit_case_seconds(path: str | Path) -> dict[str, float]: - """Read JUnit times using ``classname-as-path.py::name`` node ids. +def junit_case_seconds( + path: str | Path, *, repo_root: str | Path | None = None +) -> dict[str, float]: + """Read JUnit times as pytest node ids. - The mapping is intentionally exact and documented in the module docstring: - dots in ``classname`` become slashes, ``.py`` is appended, and ``name`` is - appended after ``::``. Chain/deleted input lists must use that same form. + pytest writes ``classname`` as the dotted module path followed by any + enclosing test classes, e.g. ``tests.unit.test_x.TestY`` for + ``tests/unit/test_x.py::TestY::test_z``. With ``repo_root`` the longest + dotted prefix that names an existing ``.py`` file is the module and the + rest are classes. Without it, every dot becomes a slash (module-level + tests only). Chain and deleted input lists must use pytest's node-id form. """ + root_dir = None if repo_root is None else Path(repo_root) root = ET.parse(path).getroot() result: dict[str, float] = {} for case in root.iter("testcase"): @@ -443,10 +449,18 @@ def junit_case_seconds(path: str | Path) -> dict[str, float]: time = case.attrib.get("time") if classname is None or name is None or time is None: raise ValueError("every JUnit testcase needs classname, name, and time") - classname_path = classname.replace(".", "/") - if not classname_path.endswith(".py"): - classname_path += ".py" - result[f"{classname_path}::{name}"] = float(time) + parts = classname.split(".") + module_parts, class_parts = parts, [] + if root_dir is not None: + for split in range(len(parts), 0, -1): + candidate = Path(*parts[:split]).with_suffix(".py") + if (root_dir / candidate).is_file(): + module_parts, class_parts = parts[:split], parts[split:] + break + node_id = "/".join(module_parts) + ".py" + for class_name in class_parts: + node_id += f"::{class_name}" + result[f"{node_id}::{name}"] = float(time) return result @@ -610,9 +624,17 @@ def _append_mutmut_config( original = pyproject.read_text(encoding="utf-8") if "[tool.mutmut]" in original: raise ValueError("scratch pyproject already contains [tool.mutmut]") + # source_paths is the whole src/ tree so mutmut copies every package + # __init__.py into mutants/; naming only the handler files there leaves + # mutants/src/omnimarket a namespace portion that loses to the editable + # install, the tests import the original code, and mutmut stops with "no + # test case for any mutant" (measured on h201, 2026-09-26). only_mutate + # restricts mutation to H. The scratch copy has no .git. config = ( "\n[tool.mutmut]\n" - f"paths_to_mutate = {_toml_array(handlers)}\n" + 'source_paths = ["src"]\n' + f"only_mutate = {_toml_array(handlers)}\n" + "use_git_change_detection = false\n" f"pytest_add_cli_args = {_toml_array(pytest_args)}\n" "pytest_add_cli_args_test_selection = " f"{_toml_array(selection)}\n" @@ -773,7 +795,7 @@ def _measure_junit( ) if completed.returncode != 0: return None - return junit_case_seconds(junit_path) + return junit_case_seconds(junit_path, repo_root=repo_root) except (OSError, ValueError, ET.ParseError): return None diff --git a/tests/unit/scripts/ci/test_chain_replacement_parity.py b/tests/unit/scripts/ci/test_chain_replacement_parity.py index 333779b73c..966664264e 100644 --- a/tests/unit/scripts/ci/test_chain_replacement_parity.py +++ b/tests/unit/scripts/ci/test_chain_replacement_parity.py @@ -311,3 +311,27 @@ def test_zero_mutants_is_a_missing_measurement_not_a_pass() -> None: receipt = evaluate(_inputs(mutation_before={}, mutation_after={})) assert receipt.checks["P1"].status == "MISSING" assert receipt.verdict == "FAIL" + + +def test_junit_case_seconds_resolves_test_classes_against_the_repo( + tmp_path: Path, +) -> None: + """A class-based case keeps its class as a node-id segment (h201 finding).""" + module = tmp_path / "tests" / "unit" / "test_sample.py" + module.parent.mkdir(parents=True) + module.write_text("", encoding="utf-8") + report = tmp_path / "junit.xml" + report.write_text( + """ + + + + +""", + encoding="utf-8", + ) + + assert junit_case_seconds(report, repo_root=tmp_path) == { + "tests/unit/test_sample.py::TestOuter::test_a": 0.5, + "tests/unit/test_sample.py::test_b": 0.25, + } From 54a55e65af6dd38d7327ec2247ad90f6a6b9bf07 Mon Sep 17 00:00:00 2001 From: jonahgabriel Date: Sat, 26 Sep 2026 08:02:46 -0400 Subject: [PATCH 3/7] ci(OMN-19711): bound mutmut workers on the shared lab host measure passes --max-children to mutmut run, default 8. The first h201 run used every core and took the host's load from about 10 to 23. --- scripts/ci/chain_replacement_parity.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/ci/chain_replacement_parity.py b/scripts/ci/chain_replacement_parity.py index 42c5f058e7..54505486b4 100644 --- a/scripts/ci/chain_replacement_parity.py +++ b/scripts/ci/chain_replacement_parity.py @@ -672,6 +672,7 @@ def _measure_mutation( handlers: list[str], pytest_args: list[str], selection: list[str], + max_children: int, ) -> dict[str, MutmutStatus] | None: scratch_repo = scratch_parent / f"repo-{label}" try: @@ -683,7 +684,7 @@ def _measure_mutation( selection=selection, ) run = _run_logged( - [mutmut, "run"], + [mutmut, "run", "--max-children", str(max_children)], cwd=scratch_repo, log_path=out_dir / f"mutmut-{label}.log", ) @@ -891,6 +892,7 @@ def _measure(args: argparse.Namespace) -> int: scratch_parent = Path(scratch) mutation_before = _measure_mutation( mutmut=mutmut, + max_children=cast(int, args.max_children), repo_root=repo_root, scratch_parent=scratch_parent, out_dir=out_dir, @@ -901,6 +903,7 @@ def _measure(args: argparse.Namespace) -> int: ) mutation_after = _measure_mutation( mutmut=mutmut, + max_children=cast(int, args.max_children), repo_root=repo_root, scratch_parent=scratch_parent, out_dir=out_dir, @@ -990,6 +993,15 @@ def _parser() -> argparse.ArgumentParser: "--pytest-args", default="", help="additional pytest arguments as one string" ) measure_parser.add_argument("--out-dir", type=Path, required=True) + measure_parser.add_argument( + "--max-children", + type=int, + default=8, + help=( + "mutmut worker processes (default 8), bounded so a lab run does " + "not take every core of a shared host" + ), + ) measure_parser.add_argument( "--baseline", action="append", From af456116be69089b5eb84e2e0ed5d7af4801badc Mon Sep 17 00:00:00 2001 From: jonahgabriel Date: Sat, 26 Sep 2026 08:39:31 -0400 Subject: [PATCH 4/7] ci(OMN-19711): copy every top-level tree into mutmut's mutants dir The pilot run with the whole delegation flow as baseline stopped at 'failed to collect stats': tests/test_delegation_env_scanner.py imports a module from scripts/ through a sys.path insert, and mutants/ held only src and tests. The scratch config now lists every other top-level entry in also_copy. --- scripts/ci/chain_replacement_parity.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scripts/ci/chain_replacement_parity.py b/scripts/ci/chain_replacement_parity.py index 54505486b4..9fa5683054 100644 --- a/scripts/ci/chain_replacement_parity.py +++ b/scripts/ci/chain_replacement_parity.py @@ -630,9 +630,20 @@ def _append_mutmut_config( # install, the tests import the original code, and mutmut stops with "no # test case for any mutant" (measured on h201, 2026-09-26). only_mutate # restricts mutation to H. The scratch copy has no .git. + # mutmut copies source_paths and tests into mutants/ and runs pytest there. + # Tests that import helpers from other top-level trees (scripts/ via a + # sys.path insert, config/, validation/) fail to collect in mutants/ unless + # those trees are copied too, and mutmut then stops at "failed to collect + # stats" (measured on h201, 2026-09-26). Copy every other top-level entry. + also_copy = sorted( + entry.name + for entry in scratch_repo.iterdir() + if entry.name not in {"src", "tests", "mutants", ".git", ".venv"} + ) config = ( "\n[tool.mutmut]\n" 'source_paths = ["src"]\n' + f"also_copy = {_toml_array(also_copy)}\n" f"only_mutate = {_toml_array(handlers)}\n" "use_git_change_detection = false\n" f"pytest_add_cli_args = {_toml_array(pytest_args)}\n" From cb2e5568e4292bedc9a47de35dad061ad6887e3f Mon Sep 17 00:00:00 2001 From: jonahgabriel Date: Sat, 26 Sep 2026 09:02:28 -0400 Subject: [PATCH 5/7] ci(OMN-19711): set aside tests mutmut cannot run, refuse deleting them mutmut rewrites every function of H into a trampoline, so a test that reads a handler's source text fails in mutants/ before any mutant is applied and mutmut stops at 'failed to collect stats' (the pilot run on h201 hit test_every_terminal_emission_inputs_names_both_fields). measure now deselects the tests a stats run reports FAILED, retries at most ten times, uses the same set on both sides, and records it as mutation_unmeasurable_tests. A deletion that removes one of those tests is P1 MISSING: its kills were never measured. --- scripts/ci/chain_replacement_parity.py | 119 ++++++++++++++---- .../ci/test_chain_replacement_parity.py | 31 +++++ 2 files changed, 125 insertions(+), 25 deletions(-) diff --git a/scripts/ci/chain_replacement_parity.py b/scripts/ci/chain_replacement_parity.py index 9fa5683054..86197ac2ee 100644 --- a/scripts/ci/chain_replacement_parity.py +++ b/scripts/ci/chain_replacement_parity.py @@ -110,6 +110,7 @@ class ParityInputs(BaseModel): mutation_before: dict[str, MutmutStatus] | None mutation_after: dict[str, MutmutStatus] | None mutation_killers: dict[str, list[str]] | None = None + mutation_unmeasurable_tests: list[str] = Field(default_factory=list) coverage_before: dict[str, FileCoverage] | None coverage_after: dict[str, FileCoverage] | None walker_error_paths_asserted_by_T: list[str] | None # noqa: N815 - specified schema @@ -181,6 +182,18 @@ def _p1_mutation(inputs: ParityInputs) -> CheckResult: before = cast(dict[str, MutmutStatus], inputs.mutation_before) after = cast(dict[str, MutmutStatus], inputs.mutation_after) + unmeasured_deletions = sorted( + set(inputs.mutation_unmeasurable_tests) & set(inputs.deleted_cases) + ) + if unmeasured_deletions: + # A deleted test that mutation could not run has kills nobody measured. + return CheckResult( + status="MISSING", + detail={ + "missing": ["mutation kills of deleted tests mutmut could not run"], + "unmeasurable_deleted_tests": unmeasured_deletions, + }, + ) if not before: # Zero mutants over H is not a measurement: mutmut found nothing to # mutate or the run collected nothing. It never passes vacuously. @@ -673,6 +686,22 @@ def _parse_mutmut_results(output: str) -> dict[str, MutmutStatus]: return parsed +_STATS_FAILED_MARKER = "failed to collect stats" +_FAILED_TEST_LINE = re.compile(r"^FAILED (?P\S+?)(?: - .*)?$") +_MAX_STATS_RETRIES = 10 + + +def _failed_test_ids(log_text: str) -> list[str]: + """Return the pytest node ids a mutmut stats run reported as FAILED.""" + return sorted( + { + match.group("id") + for line in log_text.splitlines() + if (match := _FAILED_TEST_LINE.match(line.strip())) is not None + } + ) + + def _measure_mutation( *, mutmut: str, @@ -684,33 +713,53 @@ def _measure_mutation( pytest_args: list[str], selection: list[str], max_children: int, + unmeasurable: set[str], ) -> dict[str, MutmutStatus] | None: - scratch_repo = scratch_parent / f"repo-{label}" - try: - _copy_for_mutmut(repo_root, scratch_repo) - _append_mutmut_config( - scratch_repo, - handlers=handlers, - pytest_args=pytest_args, - selection=selection, - ) - run = _run_logged( - [mutmut, "run", "--max-children", str(max_children)], - cwd=scratch_repo, - log_path=out_dir / f"mutmut-{label}.log", - ) - if run.returncode != 0: - return None - results = _run_logged( - [mutmut, "results", "--all", "true"], - cwd=scratch_repo, - log_path=out_dir / f"mutmut-{label}-results.log", - ) - if results.returncode != 0: + """Run mutmut over H; tests that fail on UNMUTATED mutmut source are set aside. + + mutmut rewrites every function of H into a trampoline, so a test that reads + a handler's source text (an AST scan, a line-number assertion) fails in + mutants/ before any mutant is applied, and mutmut refuses to start. Such a + test cannot be measured by mutation at all. It is deselected, retried at + most ``_MAX_STATS_RETRIES`` times, and added to ``unmeasurable`` so the + evaluator can refuse a deletion that would remove one (P1 MISSING). + """ + for attempt in range(_MAX_STATS_RETRIES + 1): + scratch_repo = scratch_parent / f"repo-{label}-{attempt}" + try: + _copy_for_mutmut(repo_root, scratch_repo) + _append_mutmut_config( + scratch_repo, + handlers=handlers, + pytest_args=pytest_args, + selection=[ + *selection, + *(f"--deselect={case_id}" for case_id in sorted(unmeasurable)), + ], + ) + run = _run_logged( + [mutmut, "run", "--max-children", str(max_children)], + cwd=scratch_repo, + log_path=out_dir / f"mutmut-{label}.log", + ) + if run.returncode != 0: + failed = set(_failed_test_ids(run.stdout)) - unmeasurable + if _STATS_FAILED_MARKER in run.stdout and failed: + unmeasurable.update(failed) + shutil.rmtree(scratch_repo, ignore_errors=True) + continue + return None + results = _run_logged( + [mutmut, "results", "--all", "true"], + cwd=scratch_repo, + log_path=out_dir / f"mutmut-{label}-results.log", + ) + if results.returncode != 0: + return None + return _parse_mutmut_results(results.stdout) + except (OSError, ValueError): return None - return _parse_mutmut_results(results.stdout) - except (OSError, ValueError): - return None + return None def _event_lists_from_jsonl(path: Path) -> dict[str, list[str]]: @@ -899,6 +948,7 @@ def _measure(args: argparse.Namespace) -> int: pytest_args=pytest_args, ) + unmeasurable: set[str] = set() with tempfile.TemporaryDirectory(prefix="chain-parity-mutmut-") as scratch: scratch_parent = Path(scratch) mutation_before = _measure_mutation( @@ -911,7 +961,9 @@ def _measure(args: argparse.Namespace) -> int: handlers=handlers, pytest_args=pytest_args, selection=full_selection, + unmeasurable=unmeasurable, ) + set_aside_by_before = len(unmeasurable) mutation_after = _measure_mutation( mutmut=mutmut, max_children=cast(int, args.max_children), @@ -925,7 +977,23 @@ def _measure(args: argparse.Namespace) -> int: *full_selection, *(f"--deselect={case_id}" for case_id in deleted_cases), ], + unmeasurable=unmeasurable, ) + if len(unmeasurable) > set_aside_by_before and mutation_before is not None: + # A test set aside only by the AFTER run would make the two sides + # compare different suites; re-measure BEFORE with the final set. + mutation_before = _measure_mutation( + mutmut=mutmut, + max_children=cast(int, args.max_children), + repo_root=repo_root, + scratch_parent=scratch_parent, + out_dir=out_dir, + label="before-final", + handlers=handlers, + pytest_args=pytest_args, + selection=full_selection, + unmeasurable=set(unmeasurable), + ) determinism_runs = _measure_determinism( repo_root=repo_root, @@ -958,6 +1026,7 @@ def _measure(args: argparse.Namespace) -> int: mutation_before=mutation_before, mutation_after=mutation_after, mutation_killers=None, + mutation_unmeasurable_tests=sorted(unmeasurable), coverage_before=coverage_before, coverage_after=coverage_after, walker_error_paths_asserted_by_T=walker_t, diff --git a/tests/unit/scripts/ci/test_chain_replacement_parity.py b/tests/unit/scripts/ci/test_chain_replacement_parity.py index 966664264e..0d3ce3cd2a 100644 --- a/tests/unit/scripts/ci/test_chain_replacement_parity.py +++ b/tests/unit/scripts/ci/test_chain_replacement_parity.py @@ -335,3 +335,34 @@ def test_junit_case_seconds_resolves_test_classes_against_the_repo( "tests/unit/test_sample.py::TestOuter::test_a": 0.5, "tests/unit/test_sample.py::test_b": 0.25, } + + +def test_a_deleted_test_mutmut_could_not_run_makes_p1_missing() -> None: + """Kills of a source-reading test are unmeasured, so its deletion is refused.""" + deleted = _inputs().deleted_cases[0] + receipt = evaluate(_inputs(mutation_unmeasurable_tests=[deleted])) + assert receipt.checks["P1"].status == "MISSING" + assert receipt.checks["P1"].detail["unmeasurable_deleted_tests"] == [deleted] + assert receipt.verdict == "FAIL" + + +def test_an_unmeasurable_test_that_is_kept_does_not_block_p1() -> None: + receipt = evaluate( + _inputs(mutation_unmeasurable_tests=["tests/unit/test_kept.py::test_ast"]) + ) + assert receipt.checks["P1"].status == "PASS" + + +def test_failed_test_ids_reads_pytest_short_summary_lines() -> None: + from scripts.ci.chain_replacement_parity import _failed_test_ids + + log = ( + "noise\n" + "FAILED tests/unit/test_a.py::TestX::test_y - AssertionError: boom\n" + "FAILED tests/unit/test_b.py::test_z\n" + "ERROR tests/unit/test_c.py\n" + ) + assert _failed_test_ids(log) == [ + "tests/unit/test_a.py::TestX::test_y", + "tests/unit/test_b.py::test_z", + ] From cecd3c7e7d54732a4d9f3c3bb663204b97a44fe8 Mon Sep 17 00:00:00 2001 From: jonahgabriel Date: Sat, 26 Sep 2026 09:25:03 -0400 Subject: [PATCH 6/7] ci(OMN-19711): find every unmutated-source failure in one pass mutmut's stats run stops at the first failing test, so setting tests aside one per retry took about five minutes each on the pilot (four retries, one parametrized source-reading test per retry). After a stats failure the tool now runs the same selection once inside mutants/ with no mutant active (MUTANT_UNDER_TEST empty, so every trampoline calls the original function) and sets aside every test that fails there. --- scripts/ci/chain_replacement_parity.py | 40 +++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/scripts/ci/chain_replacement_parity.py b/scripts/ci/chain_replacement_parity.py index 86197ac2ee..53ff73d083 100644 --- a/scripts/ci/chain_replacement_parity.py +++ b/scripts/ci/chain_replacement_parity.py @@ -702,6 +702,28 @@ def _failed_test_ids(log_text: str) -> list[str]: ) +def _unmutated_failures( + scratch_repo: Path, + *, + out_dir: Path, + label: str, + pytest_args: list[str], + selection: list[str], +) -> set[str]: + mutants_dir = scratch_repo / "mutants" + if not mutants_dir.is_dir(): + return set() + env = os.environ.copy() + env["MUTANT_UNDER_TEST"] = "" + completed = _run_logged( + [sys.executable, "-m", "pytest", "-rf", *pytest_args, *selection], + cwd=mutants_dir, + log_path=out_dir / f"mutmut-unmutated-{label}.log", + env=env, + ) + return set(_failed_test_ids(completed.stdout)) + + def _measure_mutation( *, mutmut: str, @@ -743,7 +765,23 @@ def _measure_mutation( log_path=out_dir / f"mutmut-{label}.log", ) if run.returncode != 0: - failed = set(_failed_test_ids(run.stdout)) - unmeasurable + failed = set(_failed_test_ids(run.stdout)) + if _STATS_FAILED_MARKER in run.stdout: + # mutmut's stats run stops at the first failure. Run the + # same selection once in mutants/ with no mutant active + # (MUTANT_UNDER_TEST="" calls every original function) to + # find every test that fails on unmutated mutmut source. + failed |= _unmutated_failures( + scratch_repo, + out_dir=out_dir, + label=f"{label}-{attempt}", + pytest_args=pytest_args, + selection=[ + *selection, + *(f"--deselect={case_id}" for case_id in unmeasurable), + ], + ) + failed -= unmeasurable if _STATS_FAILED_MARKER in run.stdout and failed: unmeasurable.update(failed) shutil.rmtree(scratch_repo, ignore_errors=True) From a350f3d4e68b7274ff18828e3aac91ba0b8e8a7c Mon Sep 17 00:00:00 2001 From: jonahgabriel Date: Sat, 26 Sep 2026 09:55:40 -0400 Subject: [PATCH 7/7] ci(OMN-19711): keep mutmut's per-function test map in the out dir The stats file lives in a temporary scratch copy and was deleted with it. It records which tests cover each mutated function, which is the evidence needed to shrink a refused deletion set to an admissible one. --- scripts/ci/chain_replacement_parity.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/ci/chain_replacement_parity.py b/scripts/ci/chain_replacement_parity.py index 53ff73d083..868ac6f1ca 100644 --- a/scripts/ci/chain_replacement_parity.py +++ b/scripts/ci/chain_replacement_parity.py @@ -787,6 +787,11 @@ def _measure_mutation( shutil.rmtree(scratch_repo, ignore_errors=True) continue return None + stats = scratch_repo / "mutants" / "mutmut-stats.json" + if stats.is_file(): + # Which tests cover which mutated function: the evidence a lane + # needs to shrink a refused deletion set to an admissible one. + shutil.copyfile(stats, out_dir / f"mutmut-stats-{label}.json") results = _run_logged( [mutmut, "results", "--all", "true"], cwd=scratch_repo,