From 96c11ac7f6cc75e525f5af3fad799822b490b723 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 18:17:05 +0100 Subject: [PATCH 01/24] fix(ci): support declaration mutation evidence --- .ci/behavior-claims/README.md | 8 +- CONTRIBUTING.md | 7 +- backend/scripts/mutation_policy.py | 231 +++++++++++++++++++++++--- backend/tests/test_mutation_policy.py | 190 ++++++++++++++++++++- docs/operations_backend_testing.md | 13 +- scripts/behavior-claim.schema.json | 2 +- 6 files changed, 415 insertions(+), 36 deletions(-) diff --git a/.ci/behavior-claims/README.md b/.ci/behavior-claims/README.md index ce96a4c5..270e3950 100644 --- a/.ci/behavior-claims/README.md +++ b/.ci/behavior-claims/README.md @@ -7,7 +7,13 @@ is selected independently, and a claim cannot remove or replace one. The filename and `chunk_id` must match. Targets are repository-relative Python files under `backend/app/` or `backend/scripts/`; each target also names its qualified callables, exact owning pytest nodes, typed observable outcomes, and -any essential real boundaries. Unknown fields, unsafe paths, missing files, +any essential real boundaries. A target whose delta contains only added or +changed imports, docstrings, or inert module/class declarations uses an empty +callable list: its owning tests remain +mandatory, while no unrelated callable is invented for mutation. Mixed targets +still mutate every changed callable and run the declaration-owning tests. +Control flow, executable expressions, removed or renamed classes/callables, and +other unmapped logic continue to fail closed. Unknown fields, unsafe paths, missing files, duplicate entries, unowned changed targets, or stale chunk identifiers fail closed. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d21304ae..b0f697df 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,9 +75,12 @@ rerun affected checks; unchanged evidence does not need ceremonial repetition. Eligible Python changes under `backend/app/` or `backend/scripts/` require one schema-v1 claim at `.ci/behavior-claims/.json`. Start from [the copyable example](.ci/behavior-claims/example.behavior-claim.json) and name -the exact changed callable, its owning pytest node, the observable outcome, and +each exact changed callable, its owning pytest node, the observable outcome, and any essential PostgreSQL, MinIO, HTTP, lock, trigger, or concurrency boundary. -The [claim guide](.ci/behavior-claims/README.md) contains the closed rules. +Import or inert module/class declaration-only deltas use an empty callable list +but still require exact owning tests. Inspect both `declaration_targets` and +`mutation_targets` in the generated selection. The +[claim guide](.ci/behavior-claims/README.md) contains the closed rules. Run `cd backend && .venv/bin/python -m pytest -q ` before opening the PR. From the repository root, validate discovery against the PR diff --git a/backend/scripts/mutation_policy.py b/backend/scripts/mutation_policy.py index cdd64b66..9413ffa6 100644 --- a/backend/scripts/mutation_policy.py +++ b/backend/scripts/mutation_policy.py @@ -67,6 +67,25 @@ def _repository_root() -> Path: STRONG_CALIBRATION_FILTER = "scripts.mutation_policy.x__strong_calibration__mutmut_*" # workstream-mutation-capability:discover-v1 POLICY_CAPABILITY_MARKER = "workstream-mutation-capability:discover-v1" +_MODULE_DECLARATION_FACTORIES = frozenset({"frozenset"}) +_DECLARATION_FACTORY_IMPORTS = { + "dataclasses": frozenset({"dataclass"}), + "pydantic": frozenset({"Field"}), + "sqlalchemy": frozenset( + { + "CheckConstraint", + "DateTime", + "ForeignKey", + "ForeignKeyConstraint", + "Index", + "String", + "UniqueConstraint", + "Uuid", + "text", + } + ), + "sqlalchemy.orm": frozenset({"mapped_column", "relationship"}), +} RUNTIME_ENV_ALLOWLIST = { "HOME", "LANG", @@ -227,14 +246,87 @@ def _source_at(root: Path, revision: str, path: str) -> str: def _callable_spans( source: str, module: str -) -> tuple[list[tuple[int, int, str]], list[tuple[int, int]]]: - """Return qualified callable spans and module/class executable spans.""" +) -> tuple[ + list[tuple[int, int, str]], + list[tuple[int, int]], + list[tuple[int, int]], +]: + """Return callable, declaration, and unsupported executable spans.""" try: tree = ast.parse(source) except SyntaxError as exc: raise MutationPolicyError("invalid_target_syntax") from exc callables: list[tuple[int, int, str]] = [] + declarations: list[tuple[int, int]] = [] executable: list[tuple[int, int]] = [] + approved_factory_names: set[str] = set() + sqlalchemy_func_names: set[str] = set() + for statement in tree.body: + if not isinstance(statement, ast.ImportFrom) or statement.module is None: + continue + approved = _DECLARATION_FACTORY_IMPORTS.get(statement.module, frozenset()) + for item in statement.names: + local_name = item.asname or item.name + if item.name in approved: + approved_factory_names.add(local_name) + if statement.module in {"sqlalchemy", "sqlalchemy.sql"} and item.name == "func": + sqlalchemy_func_names.add(local_name) + shadowed_names: set[str] = set() + for statement in tree.body: + if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + shadowed_names.add(statement.name) + elif isinstance(statement, (ast.Assign, ast.AnnAssign)): + targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target] + shadowed_names.update(target.id for target in targets if isinstance(target, ast.Name)) + approved_factory_names.difference_update(shadowed_names) + sqlalchemy_func_names.difference_update(shadowed_names) + + def declaration_value(node: ast.expr | None, *, class_scope: bool) -> bool: + if node is None: + return True + if isinstance(node, (ast.Constant, ast.Name, ast.Attribute)): + return True + if isinstance(node, (ast.List, ast.Tuple, ast.Set)): + return all(declaration_value(item, class_scope=class_scope) for item in node.elts) + if isinstance(node, ast.Dict): + return all( + declaration_value(item, class_scope=class_scope) + for item in (*node.keys, *node.values) + if item is not None + ) + if isinstance(node, ast.UnaryOp): + return declaration_value(node.operand, class_scope=class_scope) + if isinstance(node, ast.Starred): + return declaration_value(node.value, class_scope=class_scope) + if isinstance(node, ast.Call): + name = ( + node.func.attr + if isinstance(node.func, ast.Attribute) + else getattr(node.func, "id", "") + ) + if ( + name == "split" + and not class_scope + and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Constant) + and isinstance(node.func.value.value, str) + ): + return not node.args and not node.keywords + approved_call = ( + name in approved_factory_names + or ( + isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id in sqlalchemy_func_names + and name == "now" + ) + or (not class_scope and name in _MODULE_DECLARATION_FACTORIES) + ) + return approved_call and all( + declaration_value(item, class_scope=class_scope) + for item in (*node.args, *(item.value for item in node.keywords)) + ) + return False def visit(nodes: list[ast.stmt], parents: tuple[str, ...] = ()) -> None: for node in nodes: @@ -246,52 +338,122 @@ def visit(nodes: list[ast.stmt], parents: tuple[str, ...] = ()) -> None: visit(node.body, (*parents, node.name)) elif isinstance(node, ast.ClassDef): start = min([node.lineno, *[item.lineno for item in node.decorator_list]]) - executable.append((start, node.lineno)) + decorators_valid = all( + (isinstance(item, ast.Name) and item.id in approved_factory_names) + or (isinstance(item, ast.Call) and declaration_value(item, class_scope=True)) + for item in node.decorator_list + ) + bases_valid = all( + declaration_value(item, class_scope=True) + for item in (*node.bases, *(item.value for item in node.keywords)) + ) + destination = declarations if decorators_valid and bases_valid else executable + destination.append((start, node.lineno)) visit(node.body, (*parents, node.name)) + elif isinstance(node, (ast.Import, ast.ImportFrom)): + declarations.append((node.lineno, end)) + elif isinstance(node, ast.Assign) and declaration_value( + node.value, class_scope=bool(parents) + ): + declarations.append((node.lineno, end)) + elif isinstance(node, ast.AnnAssign) and declaration_value( + node.value, class_scope=bool(parents) + ): + declarations.append((node.lineno, end)) + elif ( + isinstance(node, ast.Expr) + and isinstance(node.value, ast.Constant) + and isinstance(node.value.value, str) + ): + declarations.append((node.lineno, end)) else: executable.append((node.lineno, end)) visit(tree.body) - return callables, executable + return callables, declarations, executable -def _map_changed_lines(source: str, module: str, lines: set[int]) -> tuple[set[str], bool]: - callables, executable = _callable_spans(source, module) +def _map_changed_lines(source: str, module: str, lines: set[int]) -> tuple[set[str], bool, bool]: + callables, declarations, executable = _callable_spans(source, module) owners: set[str] = set() unmapped = False + declaration_changed = False for line in lines: matches = [item for item in callables if item[0] <= line <= item[1]] if matches: owners.add(min(matches, key=lambda item: item[1] - item[0])[2]) + elif any(start <= line <= end for start, end in declarations): + declaration_changed = True elif any(start <= line <= end for start, end in executable): unmapped = True - return owners, unmapped + return owners, declaration_changed, unmapped -def changed_callables( - root: Path, base_sha: str, head_sha: str, target: str, *, allow_unmapped: bool = False -) -> list[str]: - """Derive complete current callable ownership for executable target hunks.""" +def changed_target_ownership( + root: Path, base_sha: str, head_sha: str, target: str +) -> tuple[list[str], bool]: + """Return exact callable owners and whether declaration evidence is also required.""" module = target.removeprefix("backend/").removesuffix(".py").replace("/", ".") delta_base = _git(root, "merge-base", base_sha, head_sha) old_lines, new_lines = _diff_lines(root, delta_base, head_sha, target) current_source = _source_at(root, head_sha, target) - current, current_unmapped = _map_changed_lines(current_source, module, new_lines) + current, current_declarations, current_unmapped = _map_changed_lines( + current_source, module, new_lines + ) try: base_source = _source_at(root, delta_base, target) except MutationPolicyError: base_source = "" previous: set[str] = set() + previous_declarations = False previous_unmapped = False if base_source: - previous, previous_unmapped = _map_changed_lines(base_source, module, old_lines) - removed = previous - {item[2] for item in _callable_spans(current_source, module)[0]} - if (current_unmapped or previous_unmapped or removed) and not allow_unmapped: + previous, previous_declarations, previous_unmapped = _map_changed_lines( + base_source, module, old_lines + ) + base_classes = { + f"{module}.{node.name}" + for node in ast.walk(ast.parse(base_source)) + if isinstance(node, ast.ClassDef) + } + current_classes = { + f"{module}.{node.name}" + for node in ast.walk(ast.parse(current_source)) + if isinstance(node, ast.ClassDef) + } + if base_classes - current_classes: + raise MutationPolicyError("unmappable_changed_logic") + available = {item[2] for item in _callable_spans(current_source, module)[0]} + removed = previous - available + if current_unmapped or previous_unmapped or removed: raise MutationPolicyError("unmappable_changed_logic") - derived = sorted(current | (previous - removed if allow_unmapped else previous)) - if not derived: - raise MutationPolicyError("zero_changed_callables") - return derived + derived = sorted(current | previous) + declaration_changed = current_declarations or previous_declarations + if not derived and not declaration_changed: + raise MutationPolicyError("zero_changed_ownership") + return derived, declaration_changed + + +def changed_callables( + root: Path, base_sha: str, head_sha: str, target: str, *, allow_unmapped: bool = False +) -> list[str]: + """Derive complete current callable ownership for executable target hunks.""" + if allow_unmapped: + module = target.removeprefix("backend/").removesuffix(".py").replace("/", ".") + delta_base = _git(root, "merge-base", base_sha, head_sha) + old_lines, new_lines = _diff_lines(root, delta_base, head_sha, target) + current_source = _source_at(root, head_sha, target) + current, _, _ = _map_changed_lines(current_source, module, new_lines) + try: + base_source = _source_at(root, delta_base, target) + except MutationPolicyError: + base_source = "" + previous = _map_changed_lines(base_source, module, old_lines)[0] if base_source else set() + derived = sorted(current | previous) + if not derived: + raise MutationPolicyError("zero_changed_callables") + return derived + return changed_target_ownership(root, base_sha, head_sha, target)[0] def _read_claim(path: Path | None, root: Path, expected_chunk: str) -> list[dict[str, Any]]: @@ -335,7 +497,6 @@ def _read_claim(path: Path | None, root: Path, expected_chunk: str) -> list[dict callables = claim["callables"] if ( not isinstance(callables, list) - or not callables or len(callables) > 24 or any( not isinstance(item, str) or CALLABLE_RE.fullmatch(item) is None @@ -452,15 +613,34 @@ def build_selection( if chunk_id == "WS-QUAL-001-05M": bootstrap = POLICY_CAPABILITY_MARKER not in base_policy blocking_policy = blocking_policy or POLICY_CAPABILITY_MARKER in base_policy - derived_callables = { - target: changed_callables(root, base_sha, head_sha, target, allow_unmapped=bootstrap) + ownership = { + target: ( + (changed_callables(root, base_sha, head_sha, target, allow_unmapped=True), False) + if bootstrap + else changed_target_ownership(root, base_sha, head_sha, target) + ) for target in changed_targets } + derived_callables = {target: value[0] for target, value in ownership.items()} for target, required in derived_callables.items(): if set(required) != set(claims_by_target[target]["callables"]): raise MutationPolicyError("unowned_changed_callable") + if any( + not claim["callables"] + for target, claim in claims_by_target.items() + if target not in changed_targets + ): + raise MutationPolicyError("empty_claim_only_callables") if blocking_policy: targets = sorted(set(targets) | {CALIBRATION_TARGET}) + declaration_targets = sorted( + target for target, (_, has_declarations) in ownership.items() if has_declarations + ) + mutation_targets = sorted( + target for target, claim in claims_by_target.items() if claim["callables"] + ) + if blocking_policy: + mutation_targets = sorted(set(mutation_targets) | {CALIBRATION_TARGET}) tests = {node for claim in claims for node in claim["tests"]} if blocking_policy: tests.update(CALIBRATION_TESTS) @@ -478,6 +658,8 @@ def build_selection( "changed_paths": changed, "changed_targets": changed_targets, "changed_callables": derived_callables, + "declaration_targets": declaration_targets, + "mutation_targets": mutation_targets, "claims": claims, "target_owners": [ { @@ -574,7 +756,10 @@ def _write_mutmut_config(backend: Path, selection: dict[str, Any]) -> str: tomllib.loads(original) except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc: raise MutationPolicyError("invalid_mutation_config") from exc - relative_targets = [target.removeprefix("backend/") for target in selection["targets"]] + relative_targets = [ + target.removeprefix("backend/") + for target in selection.get("mutation_targets", selection["targets"]) + ] source_paths = sorted({target.split("/", 1)[0] for target in relative_targets}) test_nodes = [node.removeprefix("backend/") for node in selection["tests"]] lines = original.splitlines() diff --git a/backend/tests/test_mutation_policy.py b/backend/tests/test_mutation_policy.py index a078b187..682b2cee 100644 --- a/backend/tests/test_mutation_policy.py +++ b/backend/tests/test_mutation_policy.py @@ -28,6 +28,7 @@ from scripts.mutation_policy import _write_mutmut_config from scripts.mutation_policy import build_selection from scripts.mutation_policy import changed_callables +from scripts.mutation_policy import changed_target_ownership from scripts.mutation_policy import classify_outcomes from scripts.mutation_policy import discover_claim_path from scripts.mutation_policy import discover_selection @@ -82,6 +83,10 @@ def test_changed_targets_are_mandatory_and_claims_are_additive(self) -> None: "backend/scripts/changed.py", "backend/scripts/claimed.py", ] + assert selection["mutation_targets"] == [ + "backend/scripts/changed.py", + "backend/scripts/claimed.py", + ] assert selection["tests"] == ["backend/tests/test_claimed.py::test_claimed"] def test_claim_validation_fails_closed(self) -> None: @@ -113,6 +118,129 @@ def test_claim_validation_fails_closed(self) -> None: with pytest.raises(MutationPolicyError, match="stale_behavior_claim_chunk"): build_selection(root, self.base, head, "WS-QUAL-001-04M", claim) + def test_declaration_only_target_requires_tests_but_is_not_mutated(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + self._initialize(root) + target = root / "backend/scripts/claimed.py" + target.write_text( + '"""Declaration-owned module."""\n\n' + "from typing import Final\n\n" + "SETTING: Final = True\n\n" + 'class Contract:\n """Declaration-owned class."""\n\n value = True\n\n' + "def claimed():\n return True\n", + encoding="utf-8", + ) + self._git(root, "add", ".") + self._git(root, "commit", "-m", "declaration") + head = self._git(root, "rev-parse", "HEAD") + claim = root / ".ci/behavior-claims/WS-QUAL-001-04M.json" + claim.parent.mkdir(parents=True) + claim.write_text( + json.dumps( + { + "schema_version": 1, + "chunk_id": "WS-QUAL-001-04M", + "claims": [ + { + "target": "backend/scripts/claimed.py", + "callables": [], + "tests": ["backend/tests/test_claimed.py::test_claimed"], + "outcomes": ["return"], + "boundaries": [], + } + ], + } + ), + encoding="utf-8", + ) + + selection = build_selection(root, self.base, head, "WS-QUAL-001-04M", claim) + + assert selection["changed_callables"] == {"backend/scripts/claimed.py": []} + assert selection["declaration_targets"] == ["backend/scripts/claimed.py"] + assert selection["mutation_targets"] == [] + assert selection["tests"] == ["backend/tests/test_claimed.py::test_claimed"] + + def test_empty_callables_cannot_hide_changed_or_claim_only_behavior(self) -> None: + for change_target in (True, False): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + self._initialize(root) + if change_target: + (root / "backend/scripts/claimed.py").write_text( + "def claimed():\n return False\n", encoding="utf-8" + ) + else: + (root / "README.md").write_text("claim only\n", encoding="utf-8") + self._git(root, "add", ".") + self._git(root, "commit", "-m", "empty callable claim") + head = self._git(root, "rev-parse", "HEAD") + claim = root / ".ci/behavior-claims/WS-QUAL-001-04M.json" + claim.parent.mkdir(parents=True) + claim.write_text( + json.dumps( + { + "schema_version": 1, + "chunk_id": "WS-QUAL-001-04M", + "claims": [ + { + "target": "backend/scripts/claimed.py", + "callables": [], + "tests": ["backend/tests/test_claimed.py::test_claimed"], + "outcomes": ["return"], + "boundaries": [], + } + ], + } + ), + encoding="utf-8", + ) + expected = ( + "unowned_changed_callable" if change_target else "empty_claim_only_callables" + ) + with pytest.raises(MutationPolicyError, match=expected): + build_selection(root, self.base, head, "WS-QUAL-001-04M", claim) + + def test_mixed_declaration_and_callable_change_remains_mutated(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + self._initialize(root) + (root / "backend/scripts/claimed.py").write_text( + "SETTING = True\n\ndef claimed():\n return False\n", encoding="utf-8" + ) + self._git(root, "add", ".") + self._git(root, "commit", "-m", "mixed behavior") + head = self._git(root, "rev-parse", "HEAD") + claim = root / ".ci/behavior-claims/WS-QUAL-001-04M.json" + claim.parent.mkdir(parents=True) + claim.write_text( + json.dumps( + { + "schema_version": 1, + "chunk_id": "WS-QUAL-001-04M", + "claims": [ + { + "target": "backend/scripts/claimed.py", + "callables": ["scripts.claimed.claimed"], + "tests": ["backend/tests/test_claimed.py::test_claimed"], + "outcomes": ["return"], + "boundaries": [], + } + ], + } + ), + encoding="utf-8", + ) + + selection = build_selection(root, self.base, head, "WS-QUAL-001-04M", claim) + + assert selection["declaration_targets"] == ["backend/scripts/claimed.py"] + assert selection["mutation_targets"] == ["backend/scripts/claimed.py"] + assert selection["changed_callables"] == { + "backend/scripts/claimed.py": ["scripts.claimed.claimed"] + } + def test_outcomes_include_killed_survived_timeout_suspicious_and_error(self) -> None: with tempfile.TemporaryDirectory() as temporary: backend = Path(temporary) @@ -301,7 +429,6 @@ def test_claim_path_must_match_the_chunk_contract(self) -> None: @pytest.mark.parametrize( ("overrides", "error"), [ - ({"callables": []}, "invalid_claim_callables"), ( {"tests": ["backend/tests/test_claimed.py::test_claimed"] * 2}, "duplicate_claim_test_node", @@ -390,7 +517,11 @@ def test_mutmut_configuration_is_generated_from_selection(self) -> None: backend = Path(temporary) pyproject = backend / "pyproject.toml" selection = { - "targets": ["backend/scripts/example.py"], + "targets": [ + "backend/scripts/declaration.py", + "backend/scripts/example.py", + ], + "mutation_targets": ["backend/scripts/example.py"], "tests": ["backend/tests/test_example.py::test_example"], } pyproject.write_text("not = [valid", encoding="utf-8") @@ -613,7 +744,7 @@ def test_function_nested_in_function_maps_to_inner_owner(self) -> None: "scripts.claimed.outer.inner" ] - def test_module_level_and_deleted_callable_changes_fail_closed(self) -> None: + def test_declaration_only_changes_are_owned_without_inventing_a_callable(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) self._initialize(root) @@ -624,8 +755,11 @@ def test_module_level_and_deleted_callable_changes_fail_closed(self) -> None: self._git(root, "add", ".") self._git(root, "commit", "-m", "module") head = self._git(root, "rev-parse", "HEAD") - with pytest.raises(MutationPolicyError, match="unmappable_changed_logic"): - changed_callables(root, self.base, head, "backend/scripts/claimed.py") + assert changed_target_ownership( + root, self.base, head, "backend/scripts/claimed.py" + ) == ([], True) + + def test_deleted_callable_changes_still_fail_closed(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) self._initialize(root) @@ -637,6 +771,52 @@ def test_module_level_and_deleted_callable_changes_fail_closed(self) -> None: with pytest.raises(MutationPolicyError, match="unmappable_changed_logic"): changed_callables(root, self.base, head, "backend/scripts/claimed.py") + def test_module_control_flow_changes_still_fail_closed(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + self._initialize(root) + target = root / "backend/scripts/claimed.py" + target.write_text( + "def claimed():\n return True\n\nif True:\n SETTING = True\n", + encoding="utf-8", + ) + self._git(root, "add", ".") + self._git(root, "commit", "-m", "module control flow") + head = self._git(root, "rev-parse", "HEAD") + with pytest.raises(MutationPolicyError, match="unmappable_changed_logic"): + changed_target_ownership(root, self.base, head, "backend/scripts/claimed.py") + + @pytest.mark.parametrize( + "body", + ( + "def claimed():\n return True\n\nSETTING = compute_policy()\n", + "class Contract:\n value = side_effect()\n\ndef claimed():\n return True\n", + "@decorate()\nclass Contract:\n pass\n\ndef claimed():\n return True\n", + "@decorate\nclass Contract:\n pass\n\ndef claimed():\n return True\n", + ( + "from local import relationship\n\n" + "class Contract:\n value = relationship()\n\n" + "def claimed():\n return True\n" + ), + ( + "from sqlalchemy.orm import relationship\n\n" + "def relationship():\n return object()\n\n" + "class Contract:\n value = relationship()\n\n" + "def claimed():\n return True\n" + ), + ), + ) + def test_executable_declaration_expressions_fail_closed(self, body: str) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + self._initialize(root) + (root / "backend/scripts/claimed.py").write_text(body, encoding="utf-8") + self._git(root, "add", ".") + self._git(root, "commit", "-m", "executable declaration") + head = self._git(root, "rev-parse", "HEAD") + with pytest.raises(MutationPolicyError, match="unmappable_changed_logic"): + changed_target_ownership(root, self.base, head, "backend/scripts/claimed.py") + def test_callable_mapping_uses_merge_base_not_advanced_main(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) diff --git a/docs/operations_backend_testing.md b/docs/operations_backend_testing.md index 84ad0243..baeb3003 100644 --- a/docs/operations_backend_testing.md +++ b/docs/operations_backend_testing.md @@ -182,10 +182,15 @@ exact delta contains neither an eligible target nor a behavior claim. The gate selects eligible changed Python targets under `backend/app/` or `backend/scripts/`. One changed schema-v1 file under `.ci/behavior-claims/` provides qualified callable ownership, exact pytest nodes, typed observable -outcomes, and essential real boundaries. Exact executable diff hunks must map -to claimed callables. Missing, multiple, stale, unsafe, symlinked, narrowed, or -unmappable claims fail closed. Mutmut configuration is generated only inside -the disposable archive from the validated selection. +outcomes, and essential real boundaries. Added or changed imports, docstrings, +and inert module/class declaration hunks use an empty callable list when no +callable changed; their exact owning tests remain mandatory, and mixed targets +still mutate every changed callable. +Module/class control flow, executable expressions, renamed or removed +classes/callables, and all other executable diff hunks must map exactly or fail closed. +Missing, multiple, stale, unsafe, symlinked, narrowed, or unmappable claims also +fail closed. Mutmut configuration is generated only inside the disposable +archive from the validated callable selection. The hash-locked toolchain is read only from `scripts/mutation-requirements.txt` at protected base and installed with diff --git a/scripts/behavior-claim.schema.json b/scripts/behavior-claim.schema.json index 6083a079..a59ad978 100644 --- a/scripts/behavior-claim.schema.json +++ b/scripts/behavior-claim.schema.json @@ -19,7 +19,7 @@ "target": {"type": "string", "pattern": "^backend/(app|scripts)/.+\\.py$"}, "callables": { "type": "array", - "minItems": 1, + "minItems": 0, "maxItems": 24, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_.]+$"} From a02de6b15422be37bac7595bfb0ae5604edcb276 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 18:17:55 +0100 Subject: [PATCH 02/24] ci: bind declaration mutation correction --- .ci/behavior-claims/WS-QUAL-001-05M.json | 35 +++++++++--------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/.ci/behavior-claims/WS-QUAL-001-05M.json b/.ci/behavior-claims/WS-QUAL-001-05M.json index ee6355c9..fb66bc86 100644 --- a/.ci/behavior-claims/WS-QUAL-001-05M.json +++ b/.ci/behavior-claims/WS-QUAL-001-05M.json @@ -5,38 +5,29 @@ { "target": "backend/scripts/mutation_policy.py", "callables": [ - "scripts.mutation_policy.discover_claim_path", - "scripts.mutation_policy._diff_lines", - "scripts.mutation_policy._source_at", - "scripts.mutation_policy._safe_path", - "scripts.mutation_policy._minimal_runtime_environment", "scripts.mutation_policy._callable_spans", + "scripts.mutation_policy._callable_spans.declaration_value", "scripts.mutation_policy._callable_spans.visit", "scripts.mutation_policy._map_changed_lines", - "scripts.mutation_policy.changed_callables", "scripts.mutation_policy._read_claim", - "scripts.mutation_policy.build_selection", - "scripts.mutation_policy.discover_selection", "scripts.mutation_policy._write_mutmut_config", - "scripts.mutation_policy._mutant_filters", - "scripts.mutation_policy.classify_outcomes", - "scripts.mutation_policy.policy_self_test", - "scripts.mutation_policy._validate_calibration", - "scripts.mutation_policy.execute_pilot", - "scripts.mutation_policy._main" + "scripts.mutation_policy.build_selection", + "scripts.mutation_policy.changed_callables", + "scripts.mutation_policy.changed_target_ownership" ], "tests": [ "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_changed_targets_are_mandatory_and_claims_are_additive", "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_claim_validation_fails_closed", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_no_target_no_claim_is_typed_not_applicable", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_applicable_delta_requires_one_changed_claim", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_multiple_changed_claims_fail_closed", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_declaration_only_target_requires_tests_but_is_not_mutated", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_empty_callables_cannot_hide_changed_or_claim_only_behavior", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_mixed_declaration_and_callable_change_remains_mutated", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_mutmut_configuration_is_generated_from_selection", "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_changed_callable_mapping_covers_decorated_async_and_nested_methods", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_module_level_and_deleted_callable_changes_fail_closed", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_blocking_verdict_allows_only_weak_control_and_unselected_exclusions", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_incomplete_and_unknown_outcomes_fail_closed", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_strong_calibration_asserts_the_exact_boundary", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_weak_calibration_deliberately_asserts_only_the_result_type" + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_plain_class_header_change_fails_closed", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_declaration_only_changes_are_owned_without_inventing_a_callable", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_deleted_callable_changes_still_fail_closed", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_module_control_flow_changes_still_fail_closed", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_executable_declaration_expressions_fail_closed" ], "outcomes": [ "return", From d6b4e4f7e0fae2662011a22e540dfa9e448297fa Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 18:22:07 +0100 Subject: [PATCH 03/24] fix(ci): close declaration provenance bypasses --- backend/scripts/mutation_policy.py | 109 ++++++++++++++++++----------- 1 file changed, 67 insertions(+), 42 deletions(-) diff --git a/backend/scripts/mutation_policy.py b/backend/scripts/mutation_policy.py index 9413ffa6..75e57ab6 100644 --- a/backend/scripts/mutation_policy.py +++ b/backend/scripts/mutation_policy.py @@ -67,25 +67,6 @@ def _repository_root() -> Path: STRONG_CALIBRATION_FILTER = "scripts.mutation_policy.x__strong_calibration__mutmut_*" # workstream-mutation-capability:discover-v1 POLICY_CAPABILITY_MARKER = "workstream-mutation-capability:discover-v1" -_MODULE_DECLARATION_FACTORIES = frozenset({"frozenset"}) -_DECLARATION_FACTORY_IMPORTS = { - "dataclasses": frozenset({"dataclass"}), - "pydantic": frozenset({"Field"}), - "sqlalchemy": frozenset( - { - "CheckConstraint", - "DateTime", - "ForeignKey", - "ForeignKeyConstraint", - "Index", - "String", - "UniqueConstraint", - "Uuid", - "text", - } - ), - "sqlalchemy.orm": frozenset({"mapped_column", "relationship"}), -} RUNTIME_ENV_ALLOWLIST = { "HOME", "LANG", @@ -252,6 +233,25 @@ def _callable_spans( list[tuple[int, int]], ]: """Return callable, declaration, and unsupported executable spans.""" + module_declaration_factories = frozenset({"frozenset"}) + declaration_factory_imports = { + "dataclasses": frozenset({"dataclass"}), + "pydantic": frozenset({"Field"}), + "sqlalchemy": frozenset( + { + "CheckConstraint", + "DateTime", + "ForeignKey", + "ForeignKeyConstraint", + "Index", + "String", + "UniqueConstraint", + "Uuid", + "text", + } + ), + "sqlalchemy.orm": frozenset({"mapped_column", "relationship"}), + } try: tree = ast.parse(source) except SyntaxError as exc: @@ -259,27 +259,52 @@ def _callable_spans( callables: list[tuple[int, int, str]] = [] declarations: list[tuple[int, int]] = [] executable: list[tuple[int, int]] = [] - approved_factory_names: set[str] = set() - sqlalchemy_func_names: set[str] = set() + import_bindings: dict[str, set[str]] = {} + approved_imports: dict[str, str] = {} + sqlalchemy_func_imports: dict[str, str] = {} for statement in tree.body: - if not isinstance(statement, ast.ImportFrom) or statement.module is None: - continue - approved = _DECLARATION_FACTORY_IMPORTS.get(statement.module, frozenset()) - for item in statement.names: - local_name = item.asname or item.name - if item.name in approved: - approved_factory_names.add(local_name) - if statement.module in {"sqlalchemy", "sqlalchemy.sql"} and item.name == "func": - sqlalchemy_func_names.add(local_name) + if isinstance(statement, ast.ImportFrom) and statement.module is not None: + approved = declaration_factory_imports.get(statement.module, frozenset()) + for item in statement.names: + local_name = item.asname or item.name + source_name = f"{statement.module}.{item.name}" + import_bindings.setdefault(local_name, set()).add(source_name) + if item.name in approved: + approved_imports[local_name] = source_name + if statement.module in {"sqlalchemy", "sqlalchemy.sql"} and item.name == "func": + sqlalchemy_func_imports[local_name] = source_name + elif isinstance(statement, ast.Import): + for item in statement.names: + local_name = item.asname or item.name.split(".", 1)[0] + import_bindings.setdefault(local_name, set()).add(item.name) + + def bound_names(target: ast.expr) -> set[str]: + if isinstance(target, ast.Name): + return {target.id} + if isinstance(target, (ast.Tuple, ast.List)): + return set().union(*(bound_names(item) for item in target.elts)) + if isinstance(target, ast.Starred): + return bound_names(target.value) + return set() + shadowed_names: set[str] = set() for statement in tree.body: if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): shadowed_names.add(statement.name) elif isinstance(statement, (ast.Assign, ast.AnnAssign)): targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target] - shadowed_names.update(target.id for target in targets if isinstance(target, ast.Name)) - approved_factory_names.difference_update(shadowed_names) - sqlalchemy_func_names.difference_update(shadowed_names) + shadowed_names.update(set().union(*(bound_names(target) for target in targets))) + approved_factory_names = { + name + for name, source in approved_imports.items() + if import_bindings.get(name) == {source} and name not in shadowed_names + } + sqlalchemy_func_names = { + name + for name, source in sqlalchemy_func_imports.items() + if import_bindings.get(name) == {source} and name not in shadowed_names + } + safe_builtin_factories = module_declaration_factories - set(import_bindings) - shadowed_names def declaration_value(node: ast.expr | None, *, class_scope: bool) -> bool: if node is None: @@ -299,13 +324,9 @@ def declaration_value(node: ast.expr | None, *, class_scope: bool) -> bool: if isinstance(node, ast.Starred): return declaration_value(node.value, class_scope=class_scope) if isinstance(node, ast.Call): - name = ( - node.func.attr - if isinstance(node.func, ast.Attribute) - else getattr(node.func, "id", "") - ) + name = node.func.id if isinstance(node.func, ast.Name) else "" if ( - name == "split" + node.func.attr == "split" and not class_scope and isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Constant) @@ -313,14 +334,18 @@ def declaration_value(node: ast.expr | None, *, class_scope: bool) -> bool: ): return not node.args and not node.keywords approved_call = ( - name in approved_factory_names + (isinstance(node.func, ast.Name) and name in approved_factory_names) or ( isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name) and node.func.value.id in sqlalchemy_func_names - and name == "now" + and node.func.attr == "now" + ) + or ( + not class_scope + and isinstance(node.func, ast.Name) + and name in safe_builtin_factories ) - or (not class_scope and name in _MODULE_DECLARATION_FACTORIES) ) return approved_call and all( declaration_value(item, class_scope=class_scope) From c11ba41d249ff9914edc5862600e1683f48f1d0f Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 18:22:55 +0100 Subject: [PATCH 04/24] ci: align protected mutation ownership --- .ci/behavior-claims/WS-QUAL-001-05M.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.ci/behavior-claims/WS-QUAL-001-05M.json b/.ci/behavior-claims/WS-QUAL-001-05M.json index fb66bc86..5faf08a7 100644 --- a/.ci/behavior-claims/WS-QUAL-001-05M.json +++ b/.ci/behavior-claims/WS-QUAL-001-05M.json @@ -6,6 +6,7 @@ "target": "backend/scripts/mutation_policy.py", "callables": [ "scripts.mutation_policy._callable_spans", + "scripts.mutation_policy._callable_spans.bound_names", "scripts.mutation_policy._callable_spans.declaration_value", "scripts.mutation_policy._callable_spans.visit", "scripts.mutation_policy._map_changed_lines", From d133a5ca8bb47b34f7577b1304e1cc33f4fc9492 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 18:23:21 +0100 Subject: [PATCH 05/24] fix(ci): validate declaration call shape --- backend/scripts/mutation_policy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/scripts/mutation_policy.py b/backend/scripts/mutation_policy.py index 75e57ab6..57035fee 100644 --- a/backend/scripts/mutation_policy.py +++ b/backend/scripts/mutation_policy.py @@ -326,9 +326,9 @@ def declaration_value(node: ast.expr | None, *, class_scope: bool) -> bool: if isinstance(node, ast.Call): name = node.func.id if isinstance(node.func, ast.Name) else "" if ( - node.func.attr == "split" + isinstance(node.func, ast.Attribute) + and node.func.attr == "split" and not class_scope - and isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Constant) and isinstance(node.func.value.value, str) ): From 663cf00a50752e5f6014f35d4b6b4e317f6e5405 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 18:27:31 +0100 Subject: [PATCH 06/24] fix(ci): restrict declaration decorators --- backend/scripts/mutation_policy.py | 16 ++++++++++++++-- backend/tests/test_mutation_policy.py | 19 ++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/backend/scripts/mutation_policy.py b/backend/scripts/mutation_policy.py index 57035fee..9528dd02 100644 --- a/backend/scripts/mutation_policy.py +++ b/backend/scripts/mutation_policy.py @@ -304,6 +304,13 @@ def bound_names(target: ast.expr) -> set[str]: for name, source in sqlalchemy_func_imports.items() if import_bindings.get(name) == {source} and name not in shadowed_names } + dataclass_decorator_names = { + name + for name, source in approved_imports.items() + if source == "dataclasses.dataclass" + and import_bindings.get(name) == {source} + and name not in shadowed_names + } safe_builtin_factories = module_declaration_factories - set(import_bindings) - shadowed_names def declaration_value(node: ast.expr | None, *, class_scope: bool) -> bool: @@ -364,8 +371,13 @@ def visit(nodes: list[ast.stmt], parents: tuple[str, ...] = ()) -> None: elif isinstance(node, ast.ClassDef): start = min([node.lineno, *[item.lineno for item in node.decorator_list]]) decorators_valid = all( - (isinstance(item, ast.Name) and item.id in approved_factory_names) - or (isinstance(item, ast.Call) and declaration_value(item, class_scope=True)) + (isinstance(item, ast.Name) and item.id in dataclass_decorator_names) + or ( + isinstance(item, ast.Call) + and isinstance(item.func, ast.Name) + and item.func.id in dataclass_decorator_names + and declaration_value(item, class_scope=True) + ) for item in node.decorator_list ) bases_valid = all( diff --git a/backend/tests/test_mutation_policy.py b/backend/tests/test_mutation_policy.py index 682b2cee..75679850 100644 --- a/backend/tests/test_mutation_policy.py +++ b/backend/tests/test_mutation_policy.py @@ -125,9 +125,11 @@ def test_declaration_only_target_requires_tests_but_is_not_mutated(self) -> None target = root / "backend/scripts/claimed.py" target.write_text( '"""Declaration-owned module."""\n\n' + "from dataclasses import dataclass\n" "from typing import Final\n\n" "SETTING: Final = True\n\n" - 'class Contract:\n """Declaration-owned class."""\n\n value = True\n\n' + '@dataclass(frozen=True)\nclass Contract:\n """Declaration-owned class."""\n\n' + " value: bool = True\n\n" "def claimed():\n return True\n", encoding="utf-8", ) @@ -804,6 +806,21 @@ def test_module_control_flow_changes_still_fail_closed(self) -> None: "class Contract:\n value = relationship()\n\n" "def claimed():\n return True\n" ), + ( + "from sqlalchemy.orm import relationship\n\n" + "@relationship\nclass Contract:\n value = True\n\n" + "def claimed():\n return True\n" + ), + ( + "from sqlalchemy.orm import relationship\n\n" + "@relationship()\nclass Contract:\n value = True\n\n" + "def claimed():\n return True\n" + ), + ( + "from pydantic import Field\n\n" + "@Field()\nclass Contract:\n value = True\n\n" + "def claimed():\n return True\n" + ), ), ) def test_executable_declaration_expressions_fail_closed(self, body: str) -> None: From 6e03f87d7b8569cb903ed5a0c8a8814494d4d821 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 18:32:20 +0100 Subject: [PATCH 07/24] fix(ci): constrain dataclass declaration decorators --- backend/scripts/mutation_policy.py | 41 ++++++++++++++++++++------- backend/tests/test_mutation_policy.py | 15 ++++++++++ 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/backend/scripts/mutation_policy.py b/backend/scripts/mutation_policy.py index 9528dd02..8bd15506 100644 --- a/backend/scripts/mutation_policy.py +++ b/backend/scripts/mutation_policy.py @@ -312,6 +312,36 @@ def bound_names(target: ast.expr) -> set[str]: and name not in shadowed_names } safe_builtin_factories = module_declaration_factories - set(import_bindings) - shadowed_names + dataclass_keyword_names = frozenset( + { + "eq", + "frozen", + "init", + "kw_only", + "match_args", + "order", + "repr", + "slots", + "unsafe_hash", + "weakref_slot", + } + ) + + def valid_dataclass_decorator(node: ast.expr) -> bool: + if isinstance(node, ast.Name): + return node.id in dataclass_decorator_names + if not ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id in dataclass_decorator_names + ): + return False + return not node.args and all( + item.arg in dataclass_keyword_names + and isinstance(item.value, ast.Constant) + and isinstance(item.value.value, bool) + for item in node.keywords + ) def declaration_value(node: ast.expr | None, *, class_scope: bool) -> bool: if node is None: @@ -370,16 +400,7 @@ def visit(nodes: list[ast.stmt], parents: tuple[str, ...] = ()) -> None: visit(node.body, (*parents, node.name)) elif isinstance(node, ast.ClassDef): start = min([node.lineno, *[item.lineno for item in node.decorator_list]]) - decorators_valid = all( - (isinstance(item, ast.Name) and item.id in dataclass_decorator_names) - or ( - isinstance(item, ast.Call) - and isinstance(item.func, ast.Name) - and item.func.id in dataclass_decorator_names - and declaration_value(item, class_scope=True) - ) - for item in node.decorator_list - ) + decorators_valid = all(valid_dataclass_decorator(item) for item in node.decorator_list) bases_valid = all( declaration_value(item, class_scope=True) for item in (*node.bases, *(item.value for item in node.keywords)) diff --git a/backend/tests/test_mutation_policy.py b/backend/tests/test_mutation_policy.py index 75679850..33b415e7 100644 --- a/backend/tests/test_mutation_policy.py +++ b/backend/tests/test_mutation_policy.py @@ -821,6 +821,21 @@ def test_module_control_flow_changes_still_fail_closed(self) -> None: "@Field()\nclass Contract:\n value = True\n\n" "def claimed():\n return True\n" ), + ( + "from dataclasses import dataclass\n\n" + "@dataclass(Evil)\nclass Contract:\n value = True\n\n" + "def claimed():\n return True\n" + ), + ( + "from dataclasses import dataclass\n\n" + "@dataclass(*ARGS)\nclass Contract:\n value = True\n\n" + "def claimed():\n return True\n" + ), + ( + "from dataclasses import dataclass\n\n" + "@dataclass(**OPTIONS)\nclass Contract:\n value = True\n\n" + "def claimed():\n return True\n" + ), ), ) def test_executable_declaration_expressions_fail_closed(self, body: str) -> None: From 30bc80fe724e25ccf05eb2fa4423c55da5d6b61e Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 18:32:52 +0100 Subject: [PATCH 08/24] ci: bind dataclass decorator validation --- .ci/behavior-claims/WS-QUAL-001-05M.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.ci/behavior-claims/WS-QUAL-001-05M.json b/.ci/behavior-claims/WS-QUAL-001-05M.json index 5faf08a7..f1466ccd 100644 --- a/.ci/behavior-claims/WS-QUAL-001-05M.json +++ b/.ci/behavior-claims/WS-QUAL-001-05M.json @@ -8,6 +8,7 @@ "scripts.mutation_policy._callable_spans", "scripts.mutation_policy._callable_spans.bound_names", "scripts.mutation_policy._callable_spans.declaration_value", + "scripts.mutation_policy._callable_spans.valid_dataclass_decorator", "scripts.mutation_policy._callable_spans.visit", "scripts.mutation_policy._map_changed_lines", "scripts.mutation_policy._read_claim", From 545395a988a3fd3eb069acfc7bd7aabafe108160 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 18:42:43 +0100 Subject: [PATCH 09/24] test(ci): bind complete mutation policy contract --- .ci/behavior-claims/WS-QUAL-001-05M.json | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/.ci/behavior-claims/WS-QUAL-001-05M.json b/.ci/behavior-claims/WS-QUAL-001-05M.json index f1466ccd..5ffe88ad 100644 --- a/.ci/behavior-claims/WS-QUAL-001-05M.json +++ b/.ci/behavior-claims/WS-QUAL-001-05M.json @@ -18,18 +18,7 @@ "scripts.mutation_policy.changed_target_ownership" ], "tests": [ - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_changed_targets_are_mandatory_and_claims_are_additive", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_claim_validation_fails_closed", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_declaration_only_target_requires_tests_but_is_not_mutated", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_empty_callables_cannot_hide_changed_or_claim_only_behavior", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_mixed_declaration_and_callable_change_remains_mutated", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_mutmut_configuration_is_generated_from_selection", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_changed_callable_mapping_covers_decorated_async_and_nested_methods", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_plain_class_header_change_fails_closed", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_declaration_only_changes_are_owned_without_inventing_a_callable", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_deleted_callable_changes_still_fail_closed", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_module_control_flow_changes_still_fail_closed", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_executable_declaration_expressions_fail_closed" + "backend/tests/test_mutation_policy.py::TestMutationPolicy" ], "outcomes": [ "return", From 5b4e48c152a727c7146c53c0e781c65a9710beeb Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 18:46:01 +0100 Subject: [PATCH 10/24] test(ci): execute owned mutation scenarios --- .ci/behavior-claims/WS-QUAL-001-05M.json | 5 ++++- backend/tests/test_mutation_policy.py | 28 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/.ci/behavior-claims/WS-QUAL-001-05M.json b/.ci/behavior-claims/WS-QUAL-001-05M.json index 5ffe88ad..db5a2c26 100644 --- a/.ci/behavior-claims/WS-QUAL-001-05M.json +++ b/.ci/behavior-claims/WS-QUAL-001-05M.json @@ -18,7 +18,10 @@ "scripts.mutation_policy.changed_target_ownership" ], "tests": [ - "backend/tests/test_mutation_policy.py::TestMutationPolicy" + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_changed_policy_behavior_contract", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_claim_shapes_fail_closed", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_typed_claim_metadata_fails_closed", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_executable_declaration_expressions_fail_closed" ], "outcomes": [ "return", diff --git a/backend/tests/test_mutation_policy.py b/backend/tests/test_mutation_policy.py index 33b415e7..8771cfa4 100644 --- a/backend/tests/test_mutation_policy.py +++ b/backend/tests/test_mutation_policy.py @@ -1236,6 +1236,34 @@ def test_main_reports_policy_errors( assert _main() == 1 assert "mutation_policy_error:invalid_revision" in capsys.readouterr().err + def test_changed_policy_behavior_contract(self) -> None: + """Exercise the complete non-parameterized contract owned by this correction.""" + scenarios = ( + self.test_changed_targets_are_mandatory_and_claims_are_additive, + self.test_claim_validation_fails_closed, + self.test_declaration_only_target_requires_tests_but_is_not_mutated, + self.test_empty_callables_cannot_hide_changed_or_claim_only_behavior, + self.test_mixed_declaration_and_callable_change_remains_mutated, + self.test_claim_path_must_match_the_chunk_contract, + self.test_malformed_and_duplicate_claims_fail_closed, + self.test_mutmut_configuration_is_generated_from_selection, + self.test_callable_filters_are_exact_and_deterministic, + self.test_no_target_no_claim_is_typed_not_applicable, + self.test_deleted_target_fails_closed, + self.test_applicable_delta_requires_one_changed_claim, + self.test_multiple_changed_claims_fail_closed, + self.test_changed_callable_mapping_covers_decorated_async_and_nested_methods, + self.test_plain_class_header_change_fails_closed, + self.test_function_nested_in_function_maps_to_inner_owner, + self.test_declaration_only_changes_are_owned_without_inventing_a_callable, + self.test_deleted_callable_changes_still_fail_closed, + self.test_module_control_flow_changes_still_fail_closed, + self.test_callable_mapping_uses_merge_base_not_advanced_main, + self.test_claim_only_callable_must_exist_in_target_ast, + ) + for scenario in scenarios: + scenario() + def _initialize(self, root: Path) -> None: self._git(root, "init") self._git(root, "config", "user.email", "test@example.com") From 3f0abf61ecc8f154661e6be6cd7332622b094037 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 19:04:52 +0100 Subject: [PATCH 11/24] Revert "test(ci): execute owned mutation scenarios" This reverts commit 5b4e48c152a727c7146c53c0e781c65a9710beeb. --- .ci/behavior-claims/WS-QUAL-001-05M.json | 5 +---- backend/tests/test_mutation_policy.py | 28 ------------------------ 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/.ci/behavior-claims/WS-QUAL-001-05M.json b/.ci/behavior-claims/WS-QUAL-001-05M.json index db5a2c26..5ffe88ad 100644 --- a/.ci/behavior-claims/WS-QUAL-001-05M.json +++ b/.ci/behavior-claims/WS-QUAL-001-05M.json @@ -18,10 +18,7 @@ "scripts.mutation_policy.changed_target_ownership" ], "tests": [ - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_changed_policy_behavior_contract", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_claim_shapes_fail_closed", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_typed_claim_metadata_fails_closed", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_executable_declaration_expressions_fail_closed" + "backend/tests/test_mutation_policy.py::TestMutationPolicy" ], "outcomes": [ "return", diff --git a/backend/tests/test_mutation_policy.py b/backend/tests/test_mutation_policy.py index 8771cfa4..33b415e7 100644 --- a/backend/tests/test_mutation_policy.py +++ b/backend/tests/test_mutation_policy.py @@ -1236,34 +1236,6 @@ def test_main_reports_policy_errors( assert _main() == 1 assert "mutation_policy_error:invalid_revision" in capsys.readouterr().err - def test_changed_policy_behavior_contract(self) -> None: - """Exercise the complete non-parameterized contract owned by this correction.""" - scenarios = ( - self.test_changed_targets_are_mandatory_and_claims_are_additive, - self.test_claim_validation_fails_closed, - self.test_declaration_only_target_requires_tests_but_is_not_mutated, - self.test_empty_callables_cannot_hide_changed_or_claim_only_behavior, - self.test_mixed_declaration_and_callable_change_remains_mutated, - self.test_claim_path_must_match_the_chunk_contract, - self.test_malformed_and_duplicate_claims_fail_closed, - self.test_mutmut_configuration_is_generated_from_selection, - self.test_callable_filters_are_exact_and_deterministic, - self.test_no_target_no_claim_is_typed_not_applicable, - self.test_deleted_target_fails_closed, - self.test_applicable_delta_requires_one_changed_claim, - self.test_multiple_changed_claims_fail_closed, - self.test_changed_callable_mapping_covers_decorated_async_and_nested_methods, - self.test_plain_class_header_change_fails_closed, - self.test_function_nested_in_function_maps_to_inner_owner, - self.test_declaration_only_changes_are_owned_without_inventing_a_callable, - self.test_deleted_callable_changes_still_fail_closed, - self.test_module_control_flow_changes_still_fail_closed, - self.test_callable_mapping_uses_merge_base_not_advanced_main, - self.test_claim_only_callable_must_exist_in_target_ast, - ) - for scenario in scenarios: - scenario() - def _initialize(self, root: Path) -> None: self._git(root, "init") self._git(root, "config", "user.email", "test@example.com") From c077d27f112c43e9c43af801e3a139e968b34d51 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 19:04:52 +0100 Subject: [PATCH 12/24] Revert "test(ci): bind complete mutation policy contract" This reverts commit 545395a988a3fd3eb069acfc7bd7aabafe108160. --- .ci/behavior-claims/WS-QUAL-001-05M.json | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.ci/behavior-claims/WS-QUAL-001-05M.json b/.ci/behavior-claims/WS-QUAL-001-05M.json index 5ffe88ad..f1466ccd 100644 --- a/.ci/behavior-claims/WS-QUAL-001-05M.json +++ b/.ci/behavior-claims/WS-QUAL-001-05M.json @@ -18,7 +18,18 @@ "scripts.mutation_policy.changed_target_ownership" ], "tests": [ - "backend/tests/test_mutation_policy.py::TestMutationPolicy" + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_changed_targets_are_mandatory_and_claims_are_additive", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_claim_validation_fails_closed", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_declaration_only_target_requires_tests_but_is_not_mutated", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_empty_callables_cannot_hide_changed_or_claim_only_behavior", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_mixed_declaration_and_callable_change_remains_mutated", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_mutmut_configuration_is_generated_from_selection", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_changed_callable_mapping_covers_decorated_async_and_nested_methods", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_plain_class_header_change_fails_closed", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_declaration_only_changes_are_owned_without_inventing_a_callable", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_deleted_callable_changes_still_fail_closed", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_module_control_flow_changes_still_fail_closed", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_executable_declaration_expressions_fail_closed" ], "outcomes": [ "return", From 1e1a776513b48be793aec2d276a3a923528e0b5f Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 19:04:52 +0100 Subject: [PATCH 13/24] Revert "ci: bind dataclass decorator validation" This reverts commit 30bc80fe724e25ccf05eb2fa4423c55da5d6b61e. --- .ci/behavior-claims/WS-QUAL-001-05M.json | 1 - 1 file changed, 1 deletion(-) diff --git a/.ci/behavior-claims/WS-QUAL-001-05M.json b/.ci/behavior-claims/WS-QUAL-001-05M.json index f1466ccd..5faf08a7 100644 --- a/.ci/behavior-claims/WS-QUAL-001-05M.json +++ b/.ci/behavior-claims/WS-QUAL-001-05M.json @@ -8,7 +8,6 @@ "scripts.mutation_policy._callable_spans", "scripts.mutation_policy._callable_spans.bound_names", "scripts.mutation_policy._callable_spans.declaration_value", - "scripts.mutation_policy._callable_spans.valid_dataclass_decorator", "scripts.mutation_policy._callable_spans.visit", "scripts.mutation_policy._map_changed_lines", "scripts.mutation_policy._read_claim", From 8f62ab5d33a8b5a1233f13ee4f7380149ad95eeb Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 19:04:52 +0100 Subject: [PATCH 14/24] Revert "fix(ci): constrain dataclass declaration decorators" This reverts commit 6e03f87d7b8569cb903ed5a0c8a8814494d4d821. --- backend/scripts/mutation_policy.py | 41 +++++++-------------------- backend/tests/test_mutation_policy.py | 15 ---------- 2 files changed, 10 insertions(+), 46 deletions(-) diff --git a/backend/scripts/mutation_policy.py b/backend/scripts/mutation_policy.py index 8bd15506..9528dd02 100644 --- a/backend/scripts/mutation_policy.py +++ b/backend/scripts/mutation_policy.py @@ -312,36 +312,6 @@ def bound_names(target: ast.expr) -> set[str]: and name not in shadowed_names } safe_builtin_factories = module_declaration_factories - set(import_bindings) - shadowed_names - dataclass_keyword_names = frozenset( - { - "eq", - "frozen", - "init", - "kw_only", - "match_args", - "order", - "repr", - "slots", - "unsafe_hash", - "weakref_slot", - } - ) - - def valid_dataclass_decorator(node: ast.expr) -> bool: - if isinstance(node, ast.Name): - return node.id in dataclass_decorator_names - if not ( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id in dataclass_decorator_names - ): - return False - return not node.args and all( - item.arg in dataclass_keyword_names - and isinstance(item.value, ast.Constant) - and isinstance(item.value.value, bool) - for item in node.keywords - ) def declaration_value(node: ast.expr | None, *, class_scope: bool) -> bool: if node is None: @@ -400,7 +370,16 @@ def visit(nodes: list[ast.stmt], parents: tuple[str, ...] = ()) -> None: visit(node.body, (*parents, node.name)) elif isinstance(node, ast.ClassDef): start = min([node.lineno, *[item.lineno for item in node.decorator_list]]) - decorators_valid = all(valid_dataclass_decorator(item) for item in node.decorator_list) + decorators_valid = all( + (isinstance(item, ast.Name) and item.id in dataclass_decorator_names) + or ( + isinstance(item, ast.Call) + and isinstance(item.func, ast.Name) + and item.func.id in dataclass_decorator_names + and declaration_value(item, class_scope=True) + ) + for item in node.decorator_list + ) bases_valid = all( declaration_value(item, class_scope=True) for item in (*node.bases, *(item.value for item in node.keywords)) diff --git a/backend/tests/test_mutation_policy.py b/backend/tests/test_mutation_policy.py index 33b415e7..75679850 100644 --- a/backend/tests/test_mutation_policy.py +++ b/backend/tests/test_mutation_policy.py @@ -821,21 +821,6 @@ def test_module_control_flow_changes_still_fail_closed(self) -> None: "@Field()\nclass Contract:\n value = True\n\n" "def claimed():\n return True\n" ), - ( - "from dataclasses import dataclass\n\n" - "@dataclass(Evil)\nclass Contract:\n value = True\n\n" - "def claimed():\n return True\n" - ), - ( - "from dataclasses import dataclass\n\n" - "@dataclass(*ARGS)\nclass Contract:\n value = True\n\n" - "def claimed():\n return True\n" - ), - ( - "from dataclasses import dataclass\n\n" - "@dataclass(**OPTIONS)\nclass Contract:\n value = True\n\n" - "def claimed():\n return True\n" - ), ), ) def test_executable_declaration_expressions_fail_closed(self, body: str) -> None: From 1a8a251d18411f5c4e4798597e070deb09a6b4e8 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 19:04:52 +0100 Subject: [PATCH 15/24] Revert "fix(ci): restrict declaration decorators" This reverts commit 663cf00a50752e5f6014f35d4b6b4e317f6e5405. --- backend/scripts/mutation_policy.py | 16 ++-------------- backend/tests/test_mutation_policy.py | 19 +------------------ 2 files changed, 3 insertions(+), 32 deletions(-) diff --git a/backend/scripts/mutation_policy.py b/backend/scripts/mutation_policy.py index 9528dd02..57035fee 100644 --- a/backend/scripts/mutation_policy.py +++ b/backend/scripts/mutation_policy.py @@ -304,13 +304,6 @@ def bound_names(target: ast.expr) -> set[str]: for name, source in sqlalchemy_func_imports.items() if import_bindings.get(name) == {source} and name not in shadowed_names } - dataclass_decorator_names = { - name - for name, source in approved_imports.items() - if source == "dataclasses.dataclass" - and import_bindings.get(name) == {source} - and name not in shadowed_names - } safe_builtin_factories = module_declaration_factories - set(import_bindings) - shadowed_names def declaration_value(node: ast.expr | None, *, class_scope: bool) -> bool: @@ -371,13 +364,8 @@ def visit(nodes: list[ast.stmt], parents: tuple[str, ...] = ()) -> None: elif isinstance(node, ast.ClassDef): start = min([node.lineno, *[item.lineno for item in node.decorator_list]]) decorators_valid = all( - (isinstance(item, ast.Name) and item.id in dataclass_decorator_names) - or ( - isinstance(item, ast.Call) - and isinstance(item.func, ast.Name) - and item.func.id in dataclass_decorator_names - and declaration_value(item, class_scope=True) - ) + (isinstance(item, ast.Name) and item.id in approved_factory_names) + or (isinstance(item, ast.Call) and declaration_value(item, class_scope=True)) for item in node.decorator_list ) bases_valid = all( diff --git a/backend/tests/test_mutation_policy.py b/backend/tests/test_mutation_policy.py index 75679850..682b2cee 100644 --- a/backend/tests/test_mutation_policy.py +++ b/backend/tests/test_mutation_policy.py @@ -125,11 +125,9 @@ def test_declaration_only_target_requires_tests_but_is_not_mutated(self) -> None target = root / "backend/scripts/claimed.py" target.write_text( '"""Declaration-owned module."""\n\n' - "from dataclasses import dataclass\n" "from typing import Final\n\n" "SETTING: Final = True\n\n" - '@dataclass(frozen=True)\nclass Contract:\n """Declaration-owned class."""\n\n' - " value: bool = True\n\n" + 'class Contract:\n """Declaration-owned class."""\n\n value = True\n\n' "def claimed():\n return True\n", encoding="utf-8", ) @@ -806,21 +804,6 @@ def test_module_control_flow_changes_still_fail_closed(self) -> None: "class Contract:\n value = relationship()\n\n" "def claimed():\n return True\n" ), - ( - "from sqlalchemy.orm import relationship\n\n" - "@relationship\nclass Contract:\n value = True\n\n" - "def claimed():\n return True\n" - ), - ( - "from sqlalchemy.orm import relationship\n\n" - "@relationship()\nclass Contract:\n value = True\n\n" - "def claimed():\n return True\n" - ), - ( - "from pydantic import Field\n\n" - "@Field()\nclass Contract:\n value = True\n\n" - "def claimed():\n return True\n" - ), ), ) def test_executable_declaration_expressions_fail_closed(self, body: str) -> None: From 5eff288138d5e66312c70324cfd904a52ba7e311 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 19:04:53 +0100 Subject: [PATCH 16/24] Revert "fix(ci): validate declaration call shape" This reverts commit d133a5ca8bb47b34f7577b1304e1cc33f4fc9492. --- backend/scripts/mutation_policy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/scripts/mutation_policy.py b/backend/scripts/mutation_policy.py index 57035fee..75e57ab6 100644 --- a/backend/scripts/mutation_policy.py +++ b/backend/scripts/mutation_policy.py @@ -326,9 +326,9 @@ def declaration_value(node: ast.expr | None, *, class_scope: bool) -> bool: if isinstance(node, ast.Call): name = node.func.id if isinstance(node.func, ast.Name) else "" if ( - isinstance(node.func, ast.Attribute) - and node.func.attr == "split" + node.func.attr == "split" and not class_scope + and isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Constant) and isinstance(node.func.value.value, str) ): From 67217fe68ba9abd0a299129de9f3e18f89a96468 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 19:04:53 +0100 Subject: [PATCH 17/24] Revert "ci: align protected mutation ownership" This reverts commit c11ba41d249ff9914edc5862600e1683f48f1d0f. --- .ci/behavior-claims/WS-QUAL-001-05M.json | 1 - 1 file changed, 1 deletion(-) diff --git a/.ci/behavior-claims/WS-QUAL-001-05M.json b/.ci/behavior-claims/WS-QUAL-001-05M.json index 5faf08a7..fb66bc86 100644 --- a/.ci/behavior-claims/WS-QUAL-001-05M.json +++ b/.ci/behavior-claims/WS-QUAL-001-05M.json @@ -6,7 +6,6 @@ "target": "backend/scripts/mutation_policy.py", "callables": [ "scripts.mutation_policy._callable_spans", - "scripts.mutation_policy._callable_spans.bound_names", "scripts.mutation_policy._callable_spans.declaration_value", "scripts.mutation_policy._callable_spans.visit", "scripts.mutation_policy._map_changed_lines", From 1be5435ee10db510f41b941d3650eb36dfe924f2 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 19:04:53 +0100 Subject: [PATCH 18/24] Revert "fix(ci): close declaration provenance bypasses" This reverts commit d6b4e4f7e0fae2662011a22e540dfa9e448297fa. --- backend/scripts/mutation_policy.py | 109 +++++++++++------------------ 1 file changed, 42 insertions(+), 67 deletions(-) diff --git a/backend/scripts/mutation_policy.py b/backend/scripts/mutation_policy.py index 75e57ab6..9413ffa6 100644 --- a/backend/scripts/mutation_policy.py +++ b/backend/scripts/mutation_policy.py @@ -67,6 +67,25 @@ def _repository_root() -> Path: STRONG_CALIBRATION_FILTER = "scripts.mutation_policy.x__strong_calibration__mutmut_*" # workstream-mutation-capability:discover-v1 POLICY_CAPABILITY_MARKER = "workstream-mutation-capability:discover-v1" +_MODULE_DECLARATION_FACTORIES = frozenset({"frozenset"}) +_DECLARATION_FACTORY_IMPORTS = { + "dataclasses": frozenset({"dataclass"}), + "pydantic": frozenset({"Field"}), + "sqlalchemy": frozenset( + { + "CheckConstraint", + "DateTime", + "ForeignKey", + "ForeignKeyConstraint", + "Index", + "String", + "UniqueConstraint", + "Uuid", + "text", + } + ), + "sqlalchemy.orm": frozenset({"mapped_column", "relationship"}), +} RUNTIME_ENV_ALLOWLIST = { "HOME", "LANG", @@ -233,25 +252,6 @@ def _callable_spans( list[tuple[int, int]], ]: """Return callable, declaration, and unsupported executable spans.""" - module_declaration_factories = frozenset({"frozenset"}) - declaration_factory_imports = { - "dataclasses": frozenset({"dataclass"}), - "pydantic": frozenset({"Field"}), - "sqlalchemy": frozenset( - { - "CheckConstraint", - "DateTime", - "ForeignKey", - "ForeignKeyConstraint", - "Index", - "String", - "UniqueConstraint", - "Uuid", - "text", - } - ), - "sqlalchemy.orm": frozenset({"mapped_column", "relationship"}), - } try: tree = ast.parse(source) except SyntaxError as exc: @@ -259,52 +259,27 @@ def _callable_spans( callables: list[tuple[int, int, str]] = [] declarations: list[tuple[int, int]] = [] executable: list[tuple[int, int]] = [] - import_bindings: dict[str, set[str]] = {} - approved_imports: dict[str, str] = {} - sqlalchemy_func_imports: dict[str, str] = {} + approved_factory_names: set[str] = set() + sqlalchemy_func_names: set[str] = set() for statement in tree.body: - if isinstance(statement, ast.ImportFrom) and statement.module is not None: - approved = declaration_factory_imports.get(statement.module, frozenset()) - for item in statement.names: - local_name = item.asname or item.name - source_name = f"{statement.module}.{item.name}" - import_bindings.setdefault(local_name, set()).add(source_name) - if item.name in approved: - approved_imports[local_name] = source_name - if statement.module in {"sqlalchemy", "sqlalchemy.sql"} and item.name == "func": - sqlalchemy_func_imports[local_name] = source_name - elif isinstance(statement, ast.Import): - for item in statement.names: - local_name = item.asname or item.name.split(".", 1)[0] - import_bindings.setdefault(local_name, set()).add(item.name) - - def bound_names(target: ast.expr) -> set[str]: - if isinstance(target, ast.Name): - return {target.id} - if isinstance(target, (ast.Tuple, ast.List)): - return set().union(*(bound_names(item) for item in target.elts)) - if isinstance(target, ast.Starred): - return bound_names(target.value) - return set() - + if not isinstance(statement, ast.ImportFrom) or statement.module is None: + continue + approved = _DECLARATION_FACTORY_IMPORTS.get(statement.module, frozenset()) + for item in statement.names: + local_name = item.asname or item.name + if item.name in approved: + approved_factory_names.add(local_name) + if statement.module in {"sqlalchemy", "sqlalchemy.sql"} and item.name == "func": + sqlalchemy_func_names.add(local_name) shadowed_names: set[str] = set() for statement in tree.body: if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): shadowed_names.add(statement.name) elif isinstance(statement, (ast.Assign, ast.AnnAssign)): targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target] - shadowed_names.update(set().union(*(bound_names(target) for target in targets))) - approved_factory_names = { - name - for name, source in approved_imports.items() - if import_bindings.get(name) == {source} and name not in shadowed_names - } - sqlalchemy_func_names = { - name - for name, source in sqlalchemy_func_imports.items() - if import_bindings.get(name) == {source} and name not in shadowed_names - } - safe_builtin_factories = module_declaration_factories - set(import_bindings) - shadowed_names + shadowed_names.update(target.id for target in targets if isinstance(target, ast.Name)) + approved_factory_names.difference_update(shadowed_names) + sqlalchemy_func_names.difference_update(shadowed_names) def declaration_value(node: ast.expr | None, *, class_scope: bool) -> bool: if node is None: @@ -324,9 +299,13 @@ def declaration_value(node: ast.expr | None, *, class_scope: bool) -> bool: if isinstance(node, ast.Starred): return declaration_value(node.value, class_scope=class_scope) if isinstance(node, ast.Call): - name = node.func.id if isinstance(node.func, ast.Name) else "" + name = ( + node.func.attr + if isinstance(node.func, ast.Attribute) + else getattr(node.func, "id", "") + ) if ( - node.func.attr == "split" + name == "split" and not class_scope and isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Constant) @@ -334,18 +313,14 @@ def declaration_value(node: ast.expr | None, *, class_scope: bool) -> bool: ): return not node.args and not node.keywords approved_call = ( - (isinstance(node.func, ast.Name) and name in approved_factory_names) + name in approved_factory_names or ( isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name) and node.func.value.id in sqlalchemy_func_names - and node.func.attr == "now" - ) - or ( - not class_scope - and isinstance(node.func, ast.Name) - and name in safe_builtin_factories + and name == "now" ) + or (not class_scope and name in _MODULE_DECLARATION_FACTORIES) ) return approved_call and all( declaration_value(item, class_scope=class_scope) From bd852b51d7a028125fb99efef069b657ac3ceb4c Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 19:04:53 +0100 Subject: [PATCH 19/24] Revert "ci: bind declaration mutation correction" This reverts commit a02de6b15422be37bac7595bfb0ae5604edcb276. --- .ci/behavior-claims/WS-QUAL-001-05M.json | 35 +++++++++++++++--------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/.ci/behavior-claims/WS-QUAL-001-05M.json b/.ci/behavior-claims/WS-QUAL-001-05M.json index fb66bc86..ee6355c9 100644 --- a/.ci/behavior-claims/WS-QUAL-001-05M.json +++ b/.ci/behavior-claims/WS-QUAL-001-05M.json @@ -5,29 +5,38 @@ { "target": "backend/scripts/mutation_policy.py", "callables": [ + "scripts.mutation_policy.discover_claim_path", + "scripts.mutation_policy._diff_lines", + "scripts.mutation_policy._source_at", + "scripts.mutation_policy._safe_path", + "scripts.mutation_policy._minimal_runtime_environment", "scripts.mutation_policy._callable_spans", - "scripts.mutation_policy._callable_spans.declaration_value", "scripts.mutation_policy._callable_spans.visit", "scripts.mutation_policy._map_changed_lines", + "scripts.mutation_policy.changed_callables", "scripts.mutation_policy._read_claim", - "scripts.mutation_policy._write_mutmut_config", "scripts.mutation_policy.build_selection", - "scripts.mutation_policy.changed_callables", - "scripts.mutation_policy.changed_target_ownership" + "scripts.mutation_policy.discover_selection", + "scripts.mutation_policy._write_mutmut_config", + "scripts.mutation_policy._mutant_filters", + "scripts.mutation_policy.classify_outcomes", + "scripts.mutation_policy.policy_self_test", + "scripts.mutation_policy._validate_calibration", + "scripts.mutation_policy.execute_pilot", + "scripts.mutation_policy._main" ], "tests": [ "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_changed_targets_are_mandatory_and_claims_are_additive", "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_claim_validation_fails_closed", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_declaration_only_target_requires_tests_but_is_not_mutated", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_empty_callables_cannot_hide_changed_or_claim_only_behavior", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_mixed_declaration_and_callable_change_remains_mutated", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_mutmut_configuration_is_generated_from_selection", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_no_target_no_claim_is_typed_not_applicable", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_applicable_delta_requires_one_changed_claim", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_multiple_changed_claims_fail_closed", "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_changed_callable_mapping_covers_decorated_async_and_nested_methods", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_plain_class_header_change_fails_closed", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_declaration_only_changes_are_owned_without_inventing_a_callable", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_deleted_callable_changes_still_fail_closed", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_module_control_flow_changes_still_fail_closed", - "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_executable_declaration_expressions_fail_closed" + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_module_level_and_deleted_callable_changes_fail_closed", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_blocking_verdict_allows_only_weak_control_and_unselected_exclusions", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_incomplete_and_unknown_outcomes_fail_closed", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_strong_calibration_asserts_the_exact_boundary", + "backend/tests/test_mutation_policy.py::TestMutationPolicy::test_weak_calibration_deliberately_asserts_only_the_result_type" ], "outcomes": [ "return", From a5925bf5ae2d1a47ce3fe2a8369542b25dc304a8 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 19:04:53 +0100 Subject: [PATCH 20/24] Revert "fix(ci): support declaration mutation evidence" This reverts commit 96c11ac7f6cc75e525f5af3fad799822b490b723. --- .ci/behavior-claims/README.md | 8 +- CONTRIBUTING.md | 7 +- backend/scripts/mutation_policy.py | 231 +++----------------------- backend/tests/test_mutation_policy.py | 190 +-------------------- docs/operations_backend_testing.md | 13 +- scripts/behavior-claim.schema.json | 2 +- 6 files changed, 36 insertions(+), 415 deletions(-) diff --git a/.ci/behavior-claims/README.md b/.ci/behavior-claims/README.md index 270e3950..ce96a4c5 100644 --- a/.ci/behavior-claims/README.md +++ b/.ci/behavior-claims/README.md @@ -7,13 +7,7 @@ is selected independently, and a claim cannot remove or replace one. The filename and `chunk_id` must match. Targets are repository-relative Python files under `backend/app/` or `backend/scripts/`; each target also names its qualified callables, exact owning pytest nodes, typed observable outcomes, and -any essential real boundaries. A target whose delta contains only added or -changed imports, docstrings, or inert module/class declarations uses an empty -callable list: its owning tests remain -mandatory, while no unrelated callable is invented for mutation. Mixed targets -still mutate every changed callable and run the declaration-owning tests. -Control flow, executable expressions, removed or renamed classes/callables, and -other unmapped logic continue to fail closed. Unknown fields, unsafe paths, missing files, +any essential real boundaries. Unknown fields, unsafe paths, missing files, duplicate entries, unowned changed targets, or stale chunk identifiers fail closed. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b0f697df..d21304ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,12 +75,9 @@ rerun affected checks; unchanged evidence does not need ceremonial repetition. Eligible Python changes under `backend/app/` or `backend/scripts/` require one schema-v1 claim at `.ci/behavior-claims/.json`. Start from [the copyable example](.ci/behavior-claims/example.behavior-claim.json) and name -each exact changed callable, its owning pytest node, the observable outcome, and +the exact changed callable, its owning pytest node, the observable outcome, and any essential PostgreSQL, MinIO, HTTP, lock, trigger, or concurrency boundary. -Import or inert module/class declaration-only deltas use an empty callable list -but still require exact owning tests. Inspect both `declaration_targets` and -`mutation_targets` in the generated selection. The -[claim guide](.ci/behavior-claims/README.md) contains the closed rules. +The [claim guide](.ci/behavior-claims/README.md) contains the closed rules. Run `cd backend && .venv/bin/python -m pytest -q ` before opening the PR. From the repository root, validate discovery against the PR diff --git a/backend/scripts/mutation_policy.py b/backend/scripts/mutation_policy.py index 9413ffa6..cdd64b66 100644 --- a/backend/scripts/mutation_policy.py +++ b/backend/scripts/mutation_policy.py @@ -67,25 +67,6 @@ def _repository_root() -> Path: STRONG_CALIBRATION_FILTER = "scripts.mutation_policy.x__strong_calibration__mutmut_*" # workstream-mutation-capability:discover-v1 POLICY_CAPABILITY_MARKER = "workstream-mutation-capability:discover-v1" -_MODULE_DECLARATION_FACTORIES = frozenset({"frozenset"}) -_DECLARATION_FACTORY_IMPORTS = { - "dataclasses": frozenset({"dataclass"}), - "pydantic": frozenset({"Field"}), - "sqlalchemy": frozenset( - { - "CheckConstraint", - "DateTime", - "ForeignKey", - "ForeignKeyConstraint", - "Index", - "String", - "UniqueConstraint", - "Uuid", - "text", - } - ), - "sqlalchemy.orm": frozenset({"mapped_column", "relationship"}), -} RUNTIME_ENV_ALLOWLIST = { "HOME", "LANG", @@ -246,87 +227,14 @@ def _source_at(root: Path, revision: str, path: str) -> str: def _callable_spans( source: str, module: str -) -> tuple[ - list[tuple[int, int, str]], - list[tuple[int, int]], - list[tuple[int, int]], -]: - """Return callable, declaration, and unsupported executable spans.""" +) -> tuple[list[tuple[int, int, str]], list[tuple[int, int]]]: + """Return qualified callable spans and module/class executable spans.""" try: tree = ast.parse(source) except SyntaxError as exc: raise MutationPolicyError("invalid_target_syntax") from exc callables: list[tuple[int, int, str]] = [] - declarations: list[tuple[int, int]] = [] executable: list[tuple[int, int]] = [] - approved_factory_names: set[str] = set() - sqlalchemy_func_names: set[str] = set() - for statement in tree.body: - if not isinstance(statement, ast.ImportFrom) or statement.module is None: - continue - approved = _DECLARATION_FACTORY_IMPORTS.get(statement.module, frozenset()) - for item in statement.names: - local_name = item.asname or item.name - if item.name in approved: - approved_factory_names.add(local_name) - if statement.module in {"sqlalchemy", "sqlalchemy.sql"} and item.name == "func": - sqlalchemy_func_names.add(local_name) - shadowed_names: set[str] = set() - for statement in tree.body: - if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): - shadowed_names.add(statement.name) - elif isinstance(statement, (ast.Assign, ast.AnnAssign)): - targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target] - shadowed_names.update(target.id for target in targets if isinstance(target, ast.Name)) - approved_factory_names.difference_update(shadowed_names) - sqlalchemy_func_names.difference_update(shadowed_names) - - def declaration_value(node: ast.expr | None, *, class_scope: bool) -> bool: - if node is None: - return True - if isinstance(node, (ast.Constant, ast.Name, ast.Attribute)): - return True - if isinstance(node, (ast.List, ast.Tuple, ast.Set)): - return all(declaration_value(item, class_scope=class_scope) for item in node.elts) - if isinstance(node, ast.Dict): - return all( - declaration_value(item, class_scope=class_scope) - for item in (*node.keys, *node.values) - if item is not None - ) - if isinstance(node, ast.UnaryOp): - return declaration_value(node.operand, class_scope=class_scope) - if isinstance(node, ast.Starred): - return declaration_value(node.value, class_scope=class_scope) - if isinstance(node, ast.Call): - name = ( - node.func.attr - if isinstance(node.func, ast.Attribute) - else getattr(node.func, "id", "") - ) - if ( - name == "split" - and not class_scope - and isinstance(node.func, ast.Attribute) - and isinstance(node.func.value, ast.Constant) - and isinstance(node.func.value.value, str) - ): - return not node.args and not node.keywords - approved_call = ( - name in approved_factory_names - or ( - isinstance(node.func, ast.Attribute) - and isinstance(node.func.value, ast.Name) - and node.func.value.id in sqlalchemy_func_names - and name == "now" - ) - or (not class_scope and name in _MODULE_DECLARATION_FACTORIES) - ) - return approved_call and all( - declaration_value(item, class_scope=class_scope) - for item in (*node.args, *(item.value for item in node.keywords)) - ) - return False def visit(nodes: list[ast.stmt], parents: tuple[str, ...] = ()) -> None: for node in nodes: @@ -338,122 +246,52 @@ def visit(nodes: list[ast.stmt], parents: tuple[str, ...] = ()) -> None: visit(node.body, (*parents, node.name)) elif isinstance(node, ast.ClassDef): start = min([node.lineno, *[item.lineno for item in node.decorator_list]]) - decorators_valid = all( - (isinstance(item, ast.Name) and item.id in approved_factory_names) - or (isinstance(item, ast.Call) and declaration_value(item, class_scope=True)) - for item in node.decorator_list - ) - bases_valid = all( - declaration_value(item, class_scope=True) - for item in (*node.bases, *(item.value for item in node.keywords)) - ) - destination = declarations if decorators_valid and bases_valid else executable - destination.append((start, node.lineno)) + executable.append((start, node.lineno)) visit(node.body, (*parents, node.name)) - elif isinstance(node, (ast.Import, ast.ImportFrom)): - declarations.append((node.lineno, end)) - elif isinstance(node, ast.Assign) and declaration_value( - node.value, class_scope=bool(parents) - ): - declarations.append((node.lineno, end)) - elif isinstance(node, ast.AnnAssign) and declaration_value( - node.value, class_scope=bool(parents) - ): - declarations.append((node.lineno, end)) - elif ( - isinstance(node, ast.Expr) - and isinstance(node.value, ast.Constant) - and isinstance(node.value.value, str) - ): - declarations.append((node.lineno, end)) else: executable.append((node.lineno, end)) visit(tree.body) - return callables, declarations, executable + return callables, executable -def _map_changed_lines(source: str, module: str, lines: set[int]) -> tuple[set[str], bool, bool]: - callables, declarations, executable = _callable_spans(source, module) +def _map_changed_lines(source: str, module: str, lines: set[int]) -> tuple[set[str], bool]: + callables, executable = _callable_spans(source, module) owners: set[str] = set() unmapped = False - declaration_changed = False for line in lines: matches = [item for item in callables if item[0] <= line <= item[1]] if matches: owners.add(min(matches, key=lambda item: item[1] - item[0])[2]) - elif any(start <= line <= end for start, end in declarations): - declaration_changed = True elif any(start <= line <= end for start, end in executable): unmapped = True - return owners, declaration_changed, unmapped + return owners, unmapped -def changed_target_ownership( - root: Path, base_sha: str, head_sha: str, target: str -) -> tuple[list[str], bool]: - """Return exact callable owners and whether declaration evidence is also required.""" +def changed_callables( + root: Path, base_sha: str, head_sha: str, target: str, *, allow_unmapped: bool = False +) -> list[str]: + """Derive complete current callable ownership for executable target hunks.""" module = target.removeprefix("backend/").removesuffix(".py").replace("/", ".") delta_base = _git(root, "merge-base", base_sha, head_sha) old_lines, new_lines = _diff_lines(root, delta_base, head_sha, target) current_source = _source_at(root, head_sha, target) - current, current_declarations, current_unmapped = _map_changed_lines( - current_source, module, new_lines - ) + current, current_unmapped = _map_changed_lines(current_source, module, new_lines) try: base_source = _source_at(root, delta_base, target) except MutationPolicyError: base_source = "" previous: set[str] = set() - previous_declarations = False previous_unmapped = False if base_source: - previous, previous_declarations, previous_unmapped = _map_changed_lines( - base_source, module, old_lines - ) - base_classes = { - f"{module}.{node.name}" - for node in ast.walk(ast.parse(base_source)) - if isinstance(node, ast.ClassDef) - } - current_classes = { - f"{module}.{node.name}" - for node in ast.walk(ast.parse(current_source)) - if isinstance(node, ast.ClassDef) - } - if base_classes - current_classes: - raise MutationPolicyError("unmappable_changed_logic") - available = {item[2] for item in _callable_spans(current_source, module)[0]} - removed = previous - available - if current_unmapped or previous_unmapped or removed: + previous, previous_unmapped = _map_changed_lines(base_source, module, old_lines) + removed = previous - {item[2] for item in _callable_spans(current_source, module)[0]} + if (current_unmapped or previous_unmapped or removed) and not allow_unmapped: raise MutationPolicyError("unmappable_changed_logic") - derived = sorted(current | previous) - declaration_changed = current_declarations or previous_declarations - if not derived and not declaration_changed: - raise MutationPolicyError("zero_changed_ownership") - return derived, declaration_changed - - -def changed_callables( - root: Path, base_sha: str, head_sha: str, target: str, *, allow_unmapped: bool = False -) -> list[str]: - """Derive complete current callable ownership for executable target hunks.""" - if allow_unmapped: - module = target.removeprefix("backend/").removesuffix(".py").replace("/", ".") - delta_base = _git(root, "merge-base", base_sha, head_sha) - old_lines, new_lines = _diff_lines(root, delta_base, head_sha, target) - current_source = _source_at(root, head_sha, target) - current, _, _ = _map_changed_lines(current_source, module, new_lines) - try: - base_source = _source_at(root, delta_base, target) - except MutationPolicyError: - base_source = "" - previous = _map_changed_lines(base_source, module, old_lines)[0] if base_source else set() - derived = sorted(current | previous) - if not derived: - raise MutationPolicyError("zero_changed_callables") - return derived - return changed_target_ownership(root, base_sha, head_sha, target)[0] + derived = sorted(current | (previous - removed if allow_unmapped else previous)) + if not derived: + raise MutationPolicyError("zero_changed_callables") + return derived def _read_claim(path: Path | None, root: Path, expected_chunk: str) -> list[dict[str, Any]]: @@ -497,6 +335,7 @@ def _read_claim(path: Path | None, root: Path, expected_chunk: str) -> list[dict callables = claim["callables"] if ( not isinstance(callables, list) + or not callables or len(callables) > 24 or any( not isinstance(item, str) or CALLABLE_RE.fullmatch(item) is None @@ -613,34 +452,15 @@ def build_selection( if chunk_id == "WS-QUAL-001-05M": bootstrap = POLICY_CAPABILITY_MARKER not in base_policy blocking_policy = blocking_policy or POLICY_CAPABILITY_MARKER in base_policy - ownership = { - target: ( - (changed_callables(root, base_sha, head_sha, target, allow_unmapped=True), False) - if bootstrap - else changed_target_ownership(root, base_sha, head_sha, target) - ) + derived_callables = { + target: changed_callables(root, base_sha, head_sha, target, allow_unmapped=bootstrap) for target in changed_targets } - derived_callables = {target: value[0] for target, value in ownership.items()} for target, required in derived_callables.items(): if set(required) != set(claims_by_target[target]["callables"]): raise MutationPolicyError("unowned_changed_callable") - if any( - not claim["callables"] - for target, claim in claims_by_target.items() - if target not in changed_targets - ): - raise MutationPolicyError("empty_claim_only_callables") if blocking_policy: targets = sorted(set(targets) | {CALIBRATION_TARGET}) - declaration_targets = sorted( - target for target, (_, has_declarations) in ownership.items() if has_declarations - ) - mutation_targets = sorted( - target for target, claim in claims_by_target.items() if claim["callables"] - ) - if blocking_policy: - mutation_targets = sorted(set(mutation_targets) | {CALIBRATION_TARGET}) tests = {node for claim in claims for node in claim["tests"]} if blocking_policy: tests.update(CALIBRATION_TESTS) @@ -658,8 +478,6 @@ def build_selection( "changed_paths": changed, "changed_targets": changed_targets, "changed_callables": derived_callables, - "declaration_targets": declaration_targets, - "mutation_targets": mutation_targets, "claims": claims, "target_owners": [ { @@ -756,10 +574,7 @@ def _write_mutmut_config(backend: Path, selection: dict[str, Any]) -> str: tomllib.loads(original) except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc: raise MutationPolicyError("invalid_mutation_config") from exc - relative_targets = [ - target.removeprefix("backend/") - for target in selection.get("mutation_targets", selection["targets"]) - ] + relative_targets = [target.removeprefix("backend/") for target in selection["targets"]] source_paths = sorted({target.split("/", 1)[0] for target in relative_targets}) test_nodes = [node.removeprefix("backend/") for node in selection["tests"]] lines = original.splitlines() diff --git a/backend/tests/test_mutation_policy.py b/backend/tests/test_mutation_policy.py index 682b2cee..a078b187 100644 --- a/backend/tests/test_mutation_policy.py +++ b/backend/tests/test_mutation_policy.py @@ -28,7 +28,6 @@ from scripts.mutation_policy import _write_mutmut_config from scripts.mutation_policy import build_selection from scripts.mutation_policy import changed_callables -from scripts.mutation_policy import changed_target_ownership from scripts.mutation_policy import classify_outcomes from scripts.mutation_policy import discover_claim_path from scripts.mutation_policy import discover_selection @@ -83,10 +82,6 @@ def test_changed_targets_are_mandatory_and_claims_are_additive(self) -> None: "backend/scripts/changed.py", "backend/scripts/claimed.py", ] - assert selection["mutation_targets"] == [ - "backend/scripts/changed.py", - "backend/scripts/claimed.py", - ] assert selection["tests"] == ["backend/tests/test_claimed.py::test_claimed"] def test_claim_validation_fails_closed(self) -> None: @@ -118,129 +113,6 @@ def test_claim_validation_fails_closed(self) -> None: with pytest.raises(MutationPolicyError, match="stale_behavior_claim_chunk"): build_selection(root, self.base, head, "WS-QUAL-001-04M", claim) - def test_declaration_only_target_requires_tests_but_is_not_mutated(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - self._initialize(root) - target = root / "backend/scripts/claimed.py" - target.write_text( - '"""Declaration-owned module."""\n\n' - "from typing import Final\n\n" - "SETTING: Final = True\n\n" - 'class Contract:\n """Declaration-owned class."""\n\n value = True\n\n' - "def claimed():\n return True\n", - encoding="utf-8", - ) - self._git(root, "add", ".") - self._git(root, "commit", "-m", "declaration") - head = self._git(root, "rev-parse", "HEAD") - claim = root / ".ci/behavior-claims/WS-QUAL-001-04M.json" - claim.parent.mkdir(parents=True) - claim.write_text( - json.dumps( - { - "schema_version": 1, - "chunk_id": "WS-QUAL-001-04M", - "claims": [ - { - "target": "backend/scripts/claimed.py", - "callables": [], - "tests": ["backend/tests/test_claimed.py::test_claimed"], - "outcomes": ["return"], - "boundaries": [], - } - ], - } - ), - encoding="utf-8", - ) - - selection = build_selection(root, self.base, head, "WS-QUAL-001-04M", claim) - - assert selection["changed_callables"] == {"backend/scripts/claimed.py": []} - assert selection["declaration_targets"] == ["backend/scripts/claimed.py"] - assert selection["mutation_targets"] == [] - assert selection["tests"] == ["backend/tests/test_claimed.py::test_claimed"] - - def test_empty_callables_cannot_hide_changed_or_claim_only_behavior(self) -> None: - for change_target in (True, False): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - self._initialize(root) - if change_target: - (root / "backend/scripts/claimed.py").write_text( - "def claimed():\n return False\n", encoding="utf-8" - ) - else: - (root / "README.md").write_text("claim only\n", encoding="utf-8") - self._git(root, "add", ".") - self._git(root, "commit", "-m", "empty callable claim") - head = self._git(root, "rev-parse", "HEAD") - claim = root / ".ci/behavior-claims/WS-QUAL-001-04M.json" - claim.parent.mkdir(parents=True) - claim.write_text( - json.dumps( - { - "schema_version": 1, - "chunk_id": "WS-QUAL-001-04M", - "claims": [ - { - "target": "backend/scripts/claimed.py", - "callables": [], - "tests": ["backend/tests/test_claimed.py::test_claimed"], - "outcomes": ["return"], - "boundaries": [], - } - ], - } - ), - encoding="utf-8", - ) - expected = ( - "unowned_changed_callable" if change_target else "empty_claim_only_callables" - ) - with pytest.raises(MutationPolicyError, match=expected): - build_selection(root, self.base, head, "WS-QUAL-001-04M", claim) - - def test_mixed_declaration_and_callable_change_remains_mutated(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - self._initialize(root) - (root / "backend/scripts/claimed.py").write_text( - "SETTING = True\n\ndef claimed():\n return False\n", encoding="utf-8" - ) - self._git(root, "add", ".") - self._git(root, "commit", "-m", "mixed behavior") - head = self._git(root, "rev-parse", "HEAD") - claim = root / ".ci/behavior-claims/WS-QUAL-001-04M.json" - claim.parent.mkdir(parents=True) - claim.write_text( - json.dumps( - { - "schema_version": 1, - "chunk_id": "WS-QUAL-001-04M", - "claims": [ - { - "target": "backend/scripts/claimed.py", - "callables": ["scripts.claimed.claimed"], - "tests": ["backend/tests/test_claimed.py::test_claimed"], - "outcomes": ["return"], - "boundaries": [], - } - ], - } - ), - encoding="utf-8", - ) - - selection = build_selection(root, self.base, head, "WS-QUAL-001-04M", claim) - - assert selection["declaration_targets"] == ["backend/scripts/claimed.py"] - assert selection["mutation_targets"] == ["backend/scripts/claimed.py"] - assert selection["changed_callables"] == { - "backend/scripts/claimed.py": ["scripts.claimed.claimed"] - } - def test_outcomes_include_killed_survived_timeout_suspicious_and_error(self) -> None: with tempfile.TemporaryDirectory() as temporary: backend = Path(temporary) @@ -429,6 +301,7 @@ def test_claim_path_must_match_the_chunk_contract(self) -> None: @pytest.mark.parametrize( ("overrides", "error"), [ + ({"callables": []}, "invalid_claim_callables"), ( {"tests": ["backend/tests/test_claimed.py::test_claimed"] * 2}, "duplicate_claim_test_node", @@ -517,11 +390,7 @@ def test_mutmut_configuration_is_generated_from_selection(self) -> None: backend = Path(temporary) pyproject = backend / "pyproject.toml" selection = { - "targets": [ - "backend/scripts/declaration.py", - "backend/scripts/example.py", - ], - "mutation_targets": ["backend/scripts/example.py"], + "targets": ["backend/scripts/example.py"], "tests": ["backend/tests/test_example.py::test_example"], } pyproject.write_text("not = [valid", encoding="utf-8") @@ -744,7 +613,7 @@ def test_function_nested_in_function_maps_to_inner_owner(self) -> None: "scripts.claimed.outer.inner" ] - def test_declaration_only_changes_are_owned_without_inventing_a_callable(self) -> None: + def test_module_level_and_deleted_callable_changes_fail_closed(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) self._initialize(root) @@ -755,11 +624,8 @@ def test_declaration_only_changes_are_owned_without_inventing_a_callable(self) - self._git(root, "add", ".") self._git(root, "commit", "-m", "module") head = self._git(root, "rev-parse", "HEAD") - assert changed_target_ownership( - root, self.base, head, "backend/scripts/claimed.py" - ) == ([], True) - - def test_deleted_callable_changes_still_fail_closed(self) -> None: + with pytest.raises(MutationPolicyError, match="unmappable_changed_logic"): + changed_callables(root, self.base, head, "backend/scripts/claimed.py") with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) self._initialize(root) @@ -771,52 +637,6 @@ def test_deleted_callable_changes_still_fail_closed(self) -> None: with pytest.raises(MutationPolicyError, match="unmappable_changed_logic"): changed_callables(root, self.base, head, "backend/scripts/claimed.py") - def test_module_control_flow_changes_still_fail_closed(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - self._initialize(root) - target = root / "backend/scripts/claimed.py" - target.write_text( - "def claimed():\n return True\n\nif True:\n SETTING = True\n", - encoding="utf-8", - ) - self._git(root, "add", ".") - self._git(root, "commit", "-m", "module control flow") - head = self._git(root, "rev-parse", "HEAD") - with pytest.raises(MutationPolicyError, match="unmappable_changed_logic"): - changed_target_ownership(root, self.base, head, "backend/scripts/claimed.py") - - @pytest.mark.parametrize( - "body", - ( - "def claimed():\n return True\n\nSETTING = compute_policy()\n", - "class Contract:\n value = side_effect()\n\ndef claimed():\n return True\n", - "@decorate()\nclass Contract:\n pass\n\ndef claimed():\n return True\n", - "@decorate\nclass Contract:\n pass\n\ndef claimed():\n return True\n", - ( - "from local import relationship\n\n" - "class Contract:\n value = relationship()\n\n" - "def claimed():\n return True\n" - ), - ( - "from sqlalchemy.orm import relationship\n\n" - "def relationship():\n return object()\n\n" - "class Contract:\n value = relationship()\n\n" - "def claimed():\n return True\n" - ), - ), - ) - def test_executable_declaration_expressions_fail_closed(self, body: str) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - self._initialize(root) - (root / "backend/scripts/claimed.py").write_text(body, encoding="utf-8") - self._git(root, "add", ".") - self._git(root, "commit", "-m", "executable declaration") - head = self._git(root, "rev-parse", "HEAD") - with pytest.raises(MutationPolicyError, match="unmappable_changed_logic"): - changed_target_ownership(root, self.base, head, "backend/scripts/claimed.py") - def test_callable_mapping_uses_merge_base_not_advanced_main(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) diff --git a/docs/operations_backend_testing.md b/docs/operations_backend_testing.md index baeb3003..84ad0243 100644 --- a/docs/operations_backend_testing.md +++ b/docs/operations_backend_testing.md @@ -182,15 +182,10 @@ exact delta contains neither an eligible target nor a behavior claim. The gate selects eligible changed Python targets under `backend/app/` or `backend/scripts/`. One changed schema-v1 file under `.ci/behavior-claims/` provides qualified callable ownership, exact pytest nodes, typed observable -outcomes, and essential real boundaries. Added or changed imports, docstrings, -and inert module/class declaration hunks use an empty callable list when no -callable changed; their exact owning tests remain mandatory, and mixed targets -still mutate every changed callable. -Module/class control flow, executable expressions, renamed or removed -classes/callables, and all other executable diff hunks must map exactly or fail closed. -Missing, multiple, stale, unsafe, symlinked, narrowed, or unmappable claims also -fail closed. Mutmut configuration is generated only inside the disposable -archive from the validated callable selection. +outcomes, and essential real boundaries. Exact executable diff hunks must map +to claimed callables. Missing, multiple, stale, unsafe, symlinked, narrowed, or +unmappable claims fail closed. Mutmut configuration is generated only inside +the disposable archive from the validated selection. The hash-locked toolchain is read only from `scripts/mutation-requirements.txt` at protected base and installed with diff --git a/scripts/behavior-claim.schema.json b/scripts/behavior-claim.schema.json index a59ad978..6083a079 100644 --- a/scripts/behavior-claim.schema.json +++ b/scripts/behavior-claim.schema.json @@ -19,7 +19,7 @@ "target": {"type": "string", "pattern": "^backend/(app|scripts)/.+\\.py$"}, "callables": { "type": "array", - "minItems": 0, + "minItems": 1, "maxItems": 24, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_.]+$"} From 79e1369bad038c892b4c4e5fc23e191211c66305 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 19:07:48 +0100 Subject: [PATCH 21/24] ci: retire blocking mutation workflow --- .../STATUS.md | 25 ++- .ci/behavior-claims/README.md | 10 +- .github/workflows/mutation-pilot.yml | 146 ------------------ CONTRIBUTING.md | 36 +---- docs/operations_backend_testing.md | 66 ++------ 5 files changed, 40 insertions(+), 243 deletions(-) delete mode 100644 .github/workflows/mutation-pilot.yml diff --git a/.agent-loop/initiatives/WS-QUAL-001-backend-coverage-floor/STATUS.md b/.agent-loop/initiatives/WS-QUAL-001-backend-coverage-floor/STATUS.md index d832b3ea..96b1f944 100644 --- a/.agent-loop/initiatives/WS-QUAL-001-backend-coverage-floor/STATUS.md +++ b/.agent-loop/initiatives/WS-QUAL-001-backend-coverage-floor/STATUS.md @@ -10,7 +10,7 @@ wall time, and a 464.471-second slowest lane. The global blocking floor remains 78 percent by explicit human decision. Named new or materially changed subsystem checks remain blocking at 90 percent. -## Current gate +## Mutation-gate disposition `WS-QUAL-001-PLAN3` merged through PR #272. Its planning-only correction `WS-QUAL-001-PLAN3R1` merged through PR #278 after resolving all late CodeRabbit @@ -27,19 +27,18 @@ error, timeout, or suspicious outcomes. Strong calibration killed two representative mutants and the deliberately weak calibration left two alive. The human accepted this evidence and explicitly started `WS-QUAL-001-05M`. -The corrected proposal remains two-stage: +The subsequent blocking rollout proved unsuitable for ordinary work: its +callable-wide selection treated unchanged executable lines as part of every +small changed declaration and produced unresolvable survivor sets. The hosted +workflow is therefore retired pending a separately reviewed changed-line-aware +design. Existing policy and evidence files remain historical input, not an +active PR requirement. -1. `04M` — merged bounded, pinned, changed-scope mutation pilot with complete - evidence and no blocking score. -2. Human calibration checkpoint — accepted. -3. `05M` — implemented and internally reviewed bounded blocking survivor - policy for eligible changed logic and explicit test-only behavior claims; - exact-head hosted CI and external review remain before human merge. - -The mutation score remains observational. Existing Backend semantic lanes, -global 78-percent coverage, and protected 90-percent subsystem floors remain -unchanged and blocking on their existing terms. +Existing Backend semantic lanes, global 78-percent coverage, protected +90-percent subsystem floors, lint, and review gates remain unchanged and +blocking on their existing terms. ## Stop condition -Stop after the 05M PR is merge-ready. Do not start another QUAL chunk. +Do not restart mutation enforcement without a fresh bounded plan and proof that +unchanged executable lines cannot block a declaration-only change. diff --git a/.ci/behavior-claims/README.md b/.ci/behavior-claims/README.md index ce96a4c5..a92ee502 100644 --- a/.ci/behavior-claims/README.md +++ b/.ci/behavior-claims/README.md @@ -1,5 +1,9 @@ # Behavior mutation claims +The hosted behavior-mutation workflow is temporarily retired. These files are +retained as historical design input and are not currently required for pull +requests. Do not infer a blocking check from the policy or examples below. + Schema-v1 claim files provide bounded owning pytest nodes for mutation targets. They are additive: every eligible changed production or CI-runtime Python target is selected independently, and a claim cannot remove or replace one. @@ -11,7 +15,7 @@ any essential real boundaries. Unknown fields, unsafe paths, missing files, duplicate entries, unowned changed targets, or stale chunk identifiers fail closed. -The required behavior-mutation check discovers the one claim changed by the +The retired behavior-mutation check discovered the one claim changed by the pull request; labels, workflow inputs, environment variables, and PR prose cannot select it. Copy `example.behavior-claim.json`, rename it to the bounded chunk identifier, and replace every example target, callable, test, outcome, @@ -19,14 +23,14 @@ and boundary. Eligible production changes without exactly one changed claim fail closed. A test-only behavior claim is additive and cannot remove an eligible changed target. -The check has no mutation percentage. Killed mutants pass. A meaningful +The retired check had no mutation percentage. Killed mutants passed. A meaningful survivor, timeout, suspicious result, engine error, malformed or stale evidence, target escape, or excluded mutant inside the selected callable scope blocks. The only surviving control allowed by policy is Workstream's exact deliberately weak calibration callable; contributors cannot add survivor allowlists, free-form exemptions, or source mutation pragmas. -Changes with no eligible target and no claim produce typed `not_applicable` +Under the retired design, changes with no eligible target and no claim produced typed `not_applicable` evidence before the mutation toolchain is installed. Ordinary PR verdicts are calculated by the evaluator and Git-delta helper archived from protected base, not by PR-head policy code. diff --git a/.github/workflows/mutation-pilot.yml b/.github/workflows/mutation-pilot.yml deleted file mode 100644 index 0dc1b954..00000000 --- a/.github/workflows/mutation-pilot.yml +++ /dev/null @@ -1,146 +0,0 @@ -name: Behavior Mutation Gate - -on: - pull_request: - push: - branches: - - main - -concurrency: - group: behavior-mutation-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - pilot: - runs-on: ubuntu-latest - timeout-minutes: 15 - env: - MUTATION_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - MUTATION_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} - - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - fetch-depth: 0 - persist-credentials: false - - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 - with: - python-version: "3.12" - - - id: custody - name: Bind exact tree and protected evaluator authority - shell: bash - run: | - set -euo pipefail - gate_dir="${RUNNER_TEMP}/workstream-mutation-gate" - evaluator_dir="${gate_dir}/protected-evaluator" - test "$(git rev-parse HEAD)" = "${MUTATION_HEAD_SHA}" - test -z "$(git status --porcelain)" - head_tree="$(git rev-parse 'HEAD^{tree}')" - base_sha="$(git rev-parse "${MUTATION_BASE_SHA}^{commit}")" - install -d -m 700 "${evaluator_dir}/backend/scripts" "${evaluator_dir}/scripts" "${gate_dir}/protected-backend" - git show "${base_sha}:backend/scripts/mutation_policy.py" > "${evaluator_dir}/backend/scripts/mutation_policy.py" - git show "${base_sha}:scripts/git_delta.py" > "${evaluator_dir}/scripts/git_delta.py" - evaluator="${evaluator_dir}/backend/scripts/mutation_policy.py" - bootstrap=false - if ! grep -q -- 'workstream-mutation-capability:discover-v1' "${evaluator}"; then - bootstrap=true - evaluator="${GITHUB_WORKSPACE}/backend/scripts/mutation_policy.py" - fi - git show "${base_sha}:scripts/mutation-requirements.txt" > "${gate_dir}/protected-requirements.txt" - if [[ "${bootstrap}" == "true" ]]; then - cp "${GITHUB_WORKSPACE}/scripts/mutation-requirements.txt" "${gate_dir}/protected-requirements.txt" - fi - git show "${base_sha}:backend/pyproject.toml" > "${gate_dir}/protected-backend/pyproject.toml" - git show "${base_sha}:backend/uv.lock" > "${gate_dir}/protected-backend/uv.lock" - manifest_sha256="$(sha256sum "${gate_dir}/protected-requirements.txt" | cut -d ' ' -f 1)" - echo "head_tree=${head_tree}" >> "${GITHUB_OUTPUT}" - echo "base_sha=${base_sha}" >> "${GITHUB_OUTPUT}" - echo "evaluator=${evaluator}" >> "${GITHUB_OUTPUT}" - echo "bootstrap=${bootstrap}" >> "${GITHUB_OUTPUT}" - echo "manifest_sha256=${manifest_sha256}" >> "${GITHUB_OUTPUT}" - - - id: preflight - name: Discover exact mutation applicability before installation - shell: bash - run: | - set -euo pipefail - gate_dir="${RUNNER_TEMP}/workstream-mutation-gate" - python "${{ steps.custody.outputs.evaluator }}" \ - --repository-root . \ - --base-sha "${{ steps.custody.outputs.base_sha }}" \ - --head-sha "${MUTATION_HEAD_SHA}" \ - --discover \ - --selection-output "${gate_dir}/selection.json" - applicability="$(python -c 'import json,sys; print(json.load(open(sys.argv[1]))["applicability"])' "${gate_dir}/selection.json")" - echo "applicability=${applicability}" >> "${GITHUB_OUTPUT}" - - - name: Verify protected-main blocking evaluator - if: ${{ github.event_name == 'push' }} - shell: bash - run: python "${{ steps.custody.outputs.evaluator }}" --self-test - - - name: Install protected hash-locked mutation toolchain - if: ${{ steps.preflight.outputs.applicability == 'applicable' }} - shell: bash - run: | - set -euo pipefail - gate_dir="${RUNNER_TEMP}/workstream-mutation-gate" - python -m venv "${gate_dir}/venv" - "${gate_dir}/venv/bin/python" -m pip install \ - --disable-pip-version-check \ - --require-hashes \ - -r "${gate_dir}/protected-requirements.txt" - test "$("${gate_dir}/venv/bin/python" -c 'import importlib.metadata; print(importlib.metadata.version("mutmut"))')" = "3.7.0" - test "$("${gate_dir}/venv/bin/uv" --version)" = "uv 0.11.7 (x86_64-unknown-linux-gnu)" - UV_PROJECT_ENVIRONMENT="${gate_dir}/venv" \ - "${gate_dir}/venv/bin/uv" sync \ - --project "${gate_dir}/protected-backend" \ - --locked \ - --extra dev \ - --inexact \ - --no-install-project - - - name: Run required bounded behavior mutation - if: ${{ steps.preflight.outputs.applicability == 'applicable' }} - shell: bash - run: | - set -euo pipefail - gate_dir="${RUNNER_TEMP}/workstream-mutation-gate" - enforcement=--enforce - if [[ "${{ steps.custody.outputs.bootstrap }}" == "true" ]]; then - enforcement="" - fi - timeout --signal=TERM --kill-after=15s 720s \ - "${gate_dir}/venv/bin/python" "${{ steps.custody.outputs.evaluator }}" \ - --repository-root . \ - --base-sha "${{ steps.custody.outputs.base_sha }}" \ - --head-sha "${MUTATION_HEAD_SHA}" \ - --discover \ - --selection-output "${gate_dir}/executed-selection.json" \ - --execute ${enforcement} \ - --manifest "${gate_dir}/protected-requirements.txt" \ - --manifest-digest "${{ steps.custody.outputs.manifest_sha256 }}" \ - --mutmut-executable "${gate_dir}/venv/bin/mutmut" \ - --evidence-output "${gate_dir}/evidence.json" \ - --timeout-seconds 700 - test -z "$(git status --porcelain --untracked-files=no)" - test "$(git rev-parse 'HEAD^{tree}')" = "${{ steps.custody.outputs.head_tree }}" - - - name: Upload exact-head mutation evidence - if: ${{ always() }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: behavior-mutation-${{ env.MUTATION_HEAD_SHA }} - path: | - ${{ runner.temp }}/workstream-mutation-gate/selection.json - ${{ runner.temp }}/workstream-mutation-gate/executed-selection.json - ${{ runner.temp }}/workstream-mutation-gate/evidence.json - include-hidden-files: true - if-no-files-found: error - retention-days: 7 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d21304ae..9ee13939 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -72,34 +72,14 @@ rerun affected checks; unchanged evidence does not need ceremonial repetition. ## Behavior Mutation Claims -Eligible Python changes under `backend/app/` or `backend/scripts/` require one -schema-v1 claim at `.ci/behavior-claims/.json`. Start from -[the copyable example](.ci/behavior-claims/example.behavior-claim.json) and name -the exact changed callable, its owning pytest node, the observable outcome, and -any essential PostgreSQL, MinIO, HTTP, lock, trigger, or concurrency boundary. -The [claim guide](.ci/behavior-claims/README.md) contains the closed rules. - -Run `cd backend && .venv/bin/python -m pytest -q ` before -opening the PR. From the repository root, validate discovery against the PR -delta with: - -```bash -backend/.venv/bin/python backend/scripts/mutation_policy.py \ - --repository-root . \ - --base-sha "$(git merge-base origin/main HEAD)" \ - --head-sha "$(git rev-parse HEAD)" \ - --discover \ - --selection-output /tmp/workstream-mutation-selection.json -``` - -Inspect `applicability`, `changed_targets`, `changed_callables`, `tests`, and -`target_owners` in that output. The required mutation check derives targets from the exact git -delta and uses the claim only for bounded callable/test ownership. It does not -use a global score: meaningful survivors and incomplete or unsafe evidence -block. Repair a survivor by strengthening the owning behavior assertion or by -correcting the production behavior; do not add skips, exclusions, allowlists, -or mutation pragmas. Unrelated changes return `not_applicable` automatically -and do not install the mutation engine. +The hosted behavior-mutation check is temporarily retired because its +callable-wide survivor policy blocked declaration-only changes by mutating +unchanged executable lines. Do not treat a behavior claim as a required PR +gate while the replacement is being designed. + +Existing claim, schema, policy, dependency, and evidence files remain as +historical design input. They do not replace focused tests, hosted Backend +lanes, coverage floors, internal review, CodeRabbit, or human merge approval. ## Durable Records diff --git a/docs/operations_backend_testing.md b/docs/operations_backend_testing.md index 84ad0243..c0dbd40b 100644 --- a/docs/operations_backend_testing.md +++ b/docs/operations_backend_testing.md @@ -171,56 +171,16 @@ does not override otherwise passing correctness, custody, service-contract, API, and coverage gates. Never lower coverage, skip nodes, or add a silent fallback to meet the target. -## Required changed-scope behavior mutation - -`Behavior Mutation Gate` is an independent required check. It does not join the -Backend fan-in, alter the 78 percent global floor, or alter any protected 90 -percent subsystem floor. It always emits a stable pull-request result. Internal -preflight returns typed `not_applicable` before dependency installation when the -exact delta contains neither an eligible target nor a behavior claim. - -The gate selects eligible changed Python targets under `backend/app/` or -`backend/scripts/`. One changed schema-v1 file under `.ci/behavior-claims/` -provides qualified callable ownership, exact pytest nodes, typed observable -outcomes, and essential real boundaries. Exact executable diff hunks must map -to claimed callables. Missing, multiple, stale, unsafe, symlinked, narrowed, or -unmappable claims fail closed. Mutmut configuration is generated only inside -the disposable archive from the validated selection. - -The hash-locked toolchain is read only from -`scripts/mutation-requirements.txt` at protected base and installed with -`pip --require-hashes`. The same protected base supplies `backend/uv.lock` and -`backend/pyproject.toml`; `uv sync --locked` installs the runtime and test -dependencies needed by owning backend tests without trusting dependency edits -from the pull-request head. Ordinary PR selection, classification, and verdict use -the evaluator and Git-delta helper archived from protected base; PR-head policy -code is not its own authority. Execution receives no secrets, uses read-only -permissions and no persisted checkout credentials, removes token environment -variables, and mutates only an exact-head disposable archive. Special entries, -source-tree drift, custody drift, and baseline failure block. - -The independent job has a 15-minute cap, 720-second shell limit, and 700-second -engine limit. Seven-day evidence binds the exact revisions/tree, protected -manifest, generated configuration, selection, targets, tests, elapsed time, -every mutant outcome, and the closed verdict. There is no score threshold. -Killed mutants pass. Meaningful survivors, timeout, suspicious, error, unknown, -or incomplete outcomes block. Excluded mutants pass only outside the selected -callable filters. The repository's exact weak calibration survivor is the sole -allowed control; contributors cannot add classifications, allowlists, free-form -exemptions, or source mutation pragmas. - -For local discovery, run the command documented in -`.ci/behavior-claims/README.md` and inspect the generated selection before -publishing. The hosted artifact contains: - -- `selection.json`: pre-install applicability and exact claim/target selection; -- `executed-selection.json`: the selection regenerated immediately before - execution; -- `evidence.json`: exact-head custody, configuration digests, elapsed time, - calibration, complete mutant outcomes, and the closed verdict. - -For `not_applicable`, only `selection.json` is expected. For an applicable -failure, first compare both selections, then inspect `verdict.status` and -`verdict.blockers` in `evidence.json`. A selected survivor must be repaired in -the owning assertion or production behavior. Missing evidence means the named -earlier step failed; use its job log rather than manufacturing an artifact. +## Retired changed-scope behavior mutation + +The hosted `Behavior Mutation Gate` is temporarily removed. Its callable-wide +selection mutated unchanged executable lines whenever a small declaration or +callable fragment changed, creating blockers that could not be resolved by the +owning behavior assertions without implementation snapshots or gate bypasses. + +Backend semantic lanes, the repository-wide 78 percent coverage floor, named +90 percent subsystem floors, lint, docstring coverage, service-contract proof, +internal reviews, CodeRabbit, and human merge approval remain unchanged. The +mutation policy, claim schema, examples, pinned manifest, and prior evidence +remain in the repository as design input for a future changed-line-aware gate. +They are not active contribution requirements. From 22eda94b1578f6e6554928e1e686ac84a4e79d15 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 19:10:20 +0100 Subject: [PATCH 22/24] test(ci): bind retired mutation workflow state --- scripts/test_lightweight_agent_gates.py | 44 ++----------------------- 1 file changed, 2 insertions(+), 42 deletions(-) diff --git a/scripts/test_lightweight_agent_gates.py b/scripts/test_lightweight_agent_gates.py index 08c54754..f5ccadb6 100644 --- a/scripts/test_lightweight_agent_gates.py +++ b/scripts/test_lightweight_agent_gates.py @@ -124,50 +124,10 @@ def test_backend_uses_distributed_semantic_lanes_and_stable_fan_in(self) -> None self.assertIn("pull_request_review:", agent_gates) self.assertIn("--require-pr-approval", agent_gates) - def test_behavior_mutation_gate_is_bounded_protected_and_independent(self) -> None: - workflow = Path(".github/workflows/mutation-pilot.yml").read_text(encoding="utf-8") + def test_retired_behavior_mutation_gate_stays_out_of_required_ci(self) -> None: backend = Path(".github/workflows/backend.yml").read_text(encoding="utf-8") - self.assertIn(" pull_request:\n", workflow) - self.assertIn(" push:\n", workflow) - self.assertNotIn("pull_request_target", workflow) - self.assertNotIn(" paths:", workflow) - self.assertIn("permissions:\n contents: read", workflow) - self.assertNotIn("contents: write", workflow) - self.assertNotIn("continue-on-error", workflow) - self.assertIn("timeout-minutes: 15", workflow) - self.assertIn("timeout --signal=TERM --kill-after=15s 720s", workflow) - self.assertIn("persist-credentials: false", workflow) - self.assertIn("--require-hashes", workflow) - self.assertIn( - 'git show "${base_sha}:scripts/mutation-requirements.txt"', workflow - ) - self.assertIn( - 'git show "${base_sha}:backend/pyproject.toml"', workflow - ) - self.assertIn('git show "${base_sha}:backend/uv.lock"', workflow) - self.assertIn('if [[ "${bootstrap}" == "true" ]]', workflow) - self.assertIn('"${gate_dir}/venv/bin/uv" sync', workflow) - self.assertIn("--locked", workflow) - self.assertIn("--inexact", workflow) - self.assertIn( - 'git show "${base_sha}:backend/scripts/mutation_policy.py"', workflow - ) - self.assertIn('git show "${base_sha}:scripts/git_delta.py"', workflow) - self.assertLess( - workflow.index("Discover exact mutation applicability before installation"), - workflow.index("Install protected hash-locked mutation toolchain"), - ) - self.assertIn("steps.preflight.outputs.applicability == 'applicable'", workflow) - self.assertIn("--discover", workflow) - self.assertIn("workstream-mutation-capability:discover-v1", workflow) - self.assertIn("--enforce", workflow) - self.assertIn("--self-test", workflow) - self.assertIn("--timeout-seconds 700", workflow) - self.assertIn("retention-days: 7", workflow) - self.assertIn("include-hidden-files: true", workflow) - self.assertNotIn('pip install -e "backend[dev]"', workflow) - self.assertNotIn('pip install -e ".[dev]"', workflow) + self.assertFalse(Path(".github/workflows/mutation-pilot.yml").exists()) self.assertNotIn("mutation-pilot", backend) if __name__ == "__main__": From 54cd358a7e5f7a42ba0ffa4e2368cf239fe0e070 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 19:11:26 +0100 Subject: [PATCH 23/24] docs(ci): close retired mutation chunk --- .../WS-QUAL-001-backend-coverage-floor/CHUNK_MAP.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.agent-loop/initiatives/WS-QUAL-001-backend-coverage-floor/CHUNK_MAP.md b/.agent-loop/initiatives/WS-QUAL-001-backend-coverage-floor/CHUNK_MAP.md index 6937f2a7..e7f5da0b 100644 --- a/.agent-loop/initiatives/WS-QUAL-001-backend-coverage-floor/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-QUAL-001-backend-coverage-floor/CHUNK_MAP.md @@ -24,12 +24,12 @@ remain stopped historical experiments. Do not resume them. | `WS-QUAL-001-PLAN3R1` | Resolve five valid late CodeRabbit findings from PR #272 | L1 | Merged PR #278 | | `WS-QUAL-001-04P` | Establish protected hash-verified mutation dependency authority | L1 | Merged PR #281 | | `WS-QUAL-001-04M` | Pilot pinned changed-scope mutation evidence without a score gate | L1 | Merged PR #285 as `7f395d47`; hosted calibration accepted | -| `WS-QUAL-001-05M` | Add calibrated blocking behavior-mutation policy | L1 | Active by explicit human instruction | +| `WS-QUAL-001-05M` | Add calibrated blocking behavior-mutation policy | L1 | Retired after callable-wide enforcement proved unsuitable; requires a fresh changed-line-aware plan | ## Dependency rule `PLAN3 -> PLAN3R1 -> 04P -> 04M -> human calibration checkpoint -> 05M`. -Each chunk maps to one PR. `04M` may prove that the candidate engine or target -strategy is unsuitable and stop without `05M`. Planning does not pre-authorize -either implementation chunk. +The completed pilot evidence remains historical input. Do not restart `05M` or +another blocking mutation workflow without a fresh bounded plan proving that +unchanged executable lines cannot block a declaration-only change. From 1d8c45a90562587f718b1eac72b79ec9a6525965 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 21:47:29 +0100 Subject: [PATCH 24/24] docs(ci): bind mutation reactivation guard --- ...S-QUAL-001-05M-external-review-response.md | 38 +++++++++++++ .ci/behavior-claims/README.md | 54 +++++++++++-------- CONTRIBUTING.md | 3 ++ docs/operations_backend_testing.md | 4 +- 4 files changed, 75 insertions(+), 24 deletions(-) diff --git a/.agent-loop/initiatives/WS-QUAL-001-backend-coverage-floor/reviews/WS-QUAL-001-05M-external-review-response.md b/.agent-loop/initiatives/WS-QUAL-001-backend-coverage-floor/reviews/WS-QUAL-001-05M-external-review-response.md index 1e9c2a6f..e1ec212c 100644 --- a/.agent-loop/initiatives/WS-QUAL-001-backend-coverage-floor/reviews/WS-QUAL-001-05M-external-review-response.md +++ b/.agent-loop/initiatives/WS-QUAL-001-backend-coverage-floor/reviews/WS-QUAL-001-05M-external-review-response.md @@ -24,3 +24,41 @@ behavior. The textual TOML rewrite remains fail closed; typed failure is the required safety property for unsupported legacy shapes. Exact-head hosted CI and CodeRabbit rereview remain required after publication. + +## PR #289 retirement review on `54cd358a` + +Comments addressed: + +- Rewrote the PR description using the complete repository trust-bundle + template so its title, intent, scope, evidence, reviewer results, deliberate + workflow retirement, remaining risk, and follow-up boundary match the final + diff. +- Added the same reactivation guard to `CONTRIBUTING.md`, the behavior-claim + guide, and the Backend operations guide: enforcement cannot resume without an + approved fresh changed-line-aware plan proving unchanged executable lines do + not block declaration-only changes. +- Reworded the remaining claim-discovery and fail-closed guidance as historical + behavior rather than an active contribution requirement. + +Comments deferred: + +- Two mutation-policy inline threads are outdated because the referenced + implementation was fully reverted and is absent from the final PR diff. + +Human decisions needed: + +- A repository administrator must remove the retired check from external + branch-protection settings if it was configured there. + +Commands rerun: + +```text +python3 scripts/check_markdown_links.py +python3 scripts/check_stale_workstream_wording.py +git diff --check +``` + +Remaining risks: + +- Behavior mutation is no longer enforced in hosted CI. Reintroduction requires + the separately approved changed-line-aware design recorded in current status. diff --git a/.ci/behavior-claims/README.md b/.ci/behavior-claims/README.md index a92ee502..db06d9e7 100644 --- a/.ci/behavior-claims/README.md +++ b/.ci/behavior-claims/README.md @@ -2,40 +2,48 @@ The hosted behavior-mutation workflow is temporarily retired. These files are retained as historical design input and are not currently required for pull -requests. Do not infer a blocking check from the policy or examples below. - -Schema-v1 claim files provide bounded owning pytest nodes for mutation targets. -They are additive: every eligible changed production or CI-runtime Python target -is selected independently, and a claim cannot remove or replace one. - -The filename and `chunk_id` must match. Targets are repository-relative Python -files under `backend/app/` or `backend/scripts/`; each target also names its +requests. The claim-discovery and fail-closed rules below describe the retired +design only; do not infer a blocking check from them. Behavior-mutation +enforcement must not resume until a fresh changed-line-aware plan is approved +and proves that unchanged executable lines cannot block a declaration-only +change. + +Historically, schema-v1 claim files provided bounded owning pytest nodes for +mutation targets. They were additive: every eligible changed production or +CI-runtime Python target was selected independently, and a claim could not +remove or replace one. + +Under that retired design, the filename and `chunk_id` had to match. Targets +were repository-relative Python files under `backend/app/` or +`backend/scripts/`; each target also named its qualified callables, exact owning pytest nodes, typed observable outcomes, and any essential real boundaries. Unknown fields, unsafe paths, missing files, -duplicate entries, unowned changed targets, or stale chunk identifiers fail +duplicate entries, unowned changed targets, or stale chunk identifiers failed closed. The retired behavior-mutation check discovered the one claim changed by the pull request; labels, workflow inputs, environment variables, and PR prose -cannot select it. Copy `example.behavior-claim.json`, rename it to the bounded -chunk identifier, and replace every example target, callable, test, outcome, -and boundary. Eligible production changes without exactly one changed claim -fail closed. A test-only behavior claim is additive and cannot remove an +could not select it. Contributors copied `example.behavior-claim.json`, renamed +it to the bounded chunk identifier, and replaced every example target, +callable, test, outcome, and boundary. Eligible production changes without exactly one changed claim +failed closed. A test-only behavior claim was additive and could not remove an eligible changed target. The retired check had no mutation percentage. Killed mutants passed. A meaningful survivor, timeout, suspicious result, engine error, malformed or stale evidence, -target escape, or excluded mutant inside the selected callable scope blocks. -The only surviving control allowed by policy is Workstream's exact deliberately -weak calibration callable; contributors cannot add survivor allowlists, +target escape, or excluded mutant inside the selected callable scope blocked. +The only surviving control allowed by policy was Workstream's exact deliberately +weak calibration callable; contributors could not add survivor allowlists, free-form exemptions, or source mutation pragmas. -Under the retired design, changes with no eligible target and no claim produced typed `not_applicable` -evidence before the mutation toolchain is installed. Ordinary PR verdicts are +Under the retired design, changes with no eligible target and no claim produced +typed `not_applicable` +evidence before the mutation toolchain was installed. Ordinary PR verdicts were calculated by the evaluator and Git-delta helper archived from protected base, not by PR-head policy code. -Validate claim discovery locally from the repository root: +For historical diagnostics only, claim discovery can still be inspected locally +from the repository root: ```bash backend/.venv/bin/python backend/scripts/mutation_policy.py \ @@ -46,7 +54,7 @@ backend/.venv/bin/python backend/scripts/mutation_policy.py \ --selection-output /tmp/workstream-mutation-selection.json ``` -An unrelated delta reports `applicability: not_applicable`. An applicable -delta must report the exact changed targets, callable ownership, and owning -tests expected by the contributor. Discovery errors are policy failures; fix -the claim or delta rather than editing generated evidence. +Under the retired design, an unrelated delta reported +`applicability: not_applicable`. An applicable delta reported the exact changed +targets, callable ownership, and owning tests expected by the contributor. +This command does not produce active PR evidence or authorize reactivation. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ee13939..c2cc7d7a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,6 +80,9 @@ gate while the replacement is being designed. Existing claim, schema, policy, dependency, and evidence files remain as historical design input. They do not replace focused tests, hosted Backend lanes, coverage floors, internal review, CodeRabbit, or human merge approval. +Behavior-mutation enforcement must not resume until a fresh changed-line-aware +plan is approved and proves that unchanged executable lines cannot block a +declaration-only change. ## Durable Records diff --git a/docs/operations_backend_testing.md b/docs/operations_backend_testing.md index c0dbd40b..0adc6c4a 100644 --- a/docs/operations_backend_testing.md +++ b/docs/operations_backend_testing.md @@ -183,4 +183,6 @@ Backend semantic lanes, the repository-wide 78 percent coverage floor, named internal reviews, CodeRabbit, and human merge approval remain unchanged. The mutation policy, claim schema, examples, pinned manifest, and prior evidence remain in the repository as design input for a future changed-line-aware gate. -They are not active contribution requirements. +They are not active contribution requirements. Behavior-mutation enforcement +must not resume until a fresh changed-line-aware plan is approved and proves +that unchanged executable lines cannot block a declaration-only change.