mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 00:48:07 +00:00
273 lines
9.4 KiB
Python
273 lines
9.4 KiB
Python
import ast
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
|
|
# Config
|
|
TARGET_FILES = ["services/access_control.py"]
|
|
# IMPORTANT: mutation gate must exercise all access-control invariants.
|
|
# Running only tests.test_access_control misses RBAC/CSRF/tier contracts and
|
|
# lets critical mutants survive.
|
|
# CRITICAL: do not reduce this suite to a single module.
|
|
# Regressing to tests.test_access_control alone reproduces R113 false-pass risk:
|
|
# mutation score collapses (~27%) and CI adversarial extended gate fails.
|
|
TEST_COMMAND = [
|
|
sys.executable,
|
|
"-m",
|
|
"unittest",
|
|
"tests.test_access_control",
|
|
"tests.security.test_rbac",
|
|
"tests.test_csrf_loopback",
|
|
"tests.test_s34_observability_tiers",
|
|
]
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
|
|
)
|
|
logger = logging.getLogger("mutation_test")
|
|
|
|
|
|
class MutationVisitor(ast.NodeTransformer):
|
|
def __init__(self):
|
|
self.mutations = []
|
|
self.current_mutation_index = -1
|
|
self.mutation_counter = 0
|
|
self.applied_desc = None
|
|
|
|
def visit_Compare(self, node):
|
|
# Mutate '==' to '!=' and vice versa
|
|
if len(node.ops) == 1:
|
|
op = node.ops[0]
|
|
if isinstance(op, ast.Eq):
|
|
self._maybe_mutate(
|
|
node, lambda n: [setattr(n, "ops", [ast.NotEq()])], "Eq -> NotEq"
|
|
)
|
|
elif isinstance(op, ast.NotEq):
|
|
self._maybe_mutate(
|
|
node, lambda n: [setattr(n, "ops", [ast.Eq()])], "NotEq -> Eq"
|
|
)
|
|
return self.generic_visit(node)
|
|
|
|
def visit_BoolOp(self, node):
|
|
# Mutate 'and' to 'or' and vice versa
|
|
op = node.op
|
|
if isinstance(op, ast.And):
|
|
self._maybe_mutate(node, lambda n: setattr(n, "op", ast.Or()), "And -> Or")
|
|
elif isinstance(op, ast.Or):
|
|
self._maybe_mutate(node, lambda n: setattr(n, "op", ast.And()), "Or -> And")
|
|
return self.generic_visit(node)
|
|
|
|
def _maybe_mutate(self, node, action, desc):
|
|
if self.current_mutation_index == self.mutation_counter:
|
|
logger.debug(f"Applying mutation {self.mutation_counter}: {desc}")
|
|
action(node)
|
|
self.applied_desc = desc
|
|
self.mutation_counter += 1
|
|
|
|
|
|
def count_mutations(file_path: str) -> int:
|
|
with open(file_path, "r", encoding="utf-8") as f:
|
|
tree = ast.parse(f.read())
|
|
|
|
visitor = MutationVisitor()
|
|
visitor.visit(tree)
|
|
return visitor.mutation_counter
|
|
|
|
|
|
def apply_mutation(file_path: str, mutation_index: int) -> str:
|
|
with open(file_path, "r", encoding="utf-8") as f:
|
|
tree = ast.parse(f.read())
|
|
|
|
visitor = MutationVisitor()
|
|
visitor.current_mutation_index = mutation_index
|
|
visitor.visit(tree)
|
|
|
|
# Write back
|
|
with open(file_path, "w", encoding="utf-8") as f:
|
|
f.write(ast.unparse(tree))
|
|
|
|
return getattr(visitor, "applied_desc", "Unknown")
|
|
|
|
|
|
def _purge_target_bytecode(target_file: str) -> None:
|
|
"""
|
|
Remove target module bytecode cache before each subprocess test run.
|
|
|
|
CRITICAL: mutation rewrites the same source file many times in rapid
|
|
succession. On some filesystems/timestamp granularities, stale .pyc reuse
|
|
can misclassify baseline/mutant outcomes.
|
|
"""
|
|
target_dir = os.path.dirname(target_file)
|
|
module_name = os.path.splitext(os.path.basename(target_file))[0]
|
|
pycache_dir = os.path.join(target_dir, "__pycache__")
|
|
if not os.path.isdir(pycache_dir):
|
|
return
|
|
prefix = module_name + "."
|
|
for entry in os.listdir(pycache_dir):
|
|
if entry.startswith(prefix) and entry.endswith(".pyc"):
|
|
try:
|
|
os.remove(os.path.join(pycache_dir, entry))
|
|
except OSError:
|
|
# Best-effort cleanup; test subprocess will still run.
|
|
pass
|
|
|
|
|
|
def run_test_suite(
|
|
target_file: str, *, context: str, retry_on_failure: bool = False
|
|
) -> bool:
|
|
try:
|
|
attempts = 2 if retry_on_failure else 1
|
|
for attempt in range(1, attempts + 1):
|
|
_purge_target_bytecode(target_file)
|
|
result = subprocess.run(
|
|
TEST_COMMAND, capture_output=True, text=True, timeout=30
|
|
)
|
|
# IMPORTANT: treat "Ran 0 tests" as failure for mutation integrity.
|
|
# A green return code with zero executed tests invalidates mutation score.
|
|
combined = f"{result.stdout or ''}\n{result.stderr or ''}"
|
|
m = re.search(r"Ran\s+(\d+)\s+tests", combined)
|
|
tests_run = int(m.group(1)) if m else 0
|
|
if tests_run == 0:
|
|
logger.error(
|
|
"Mutation %s invalid: test command executed 0 tests. "
|
|
"Output tail: %s",
|
|
context,
|
|
combined[-500:].replace("\n", " "),
|
|
)
|
|
if retry_on_failure and attempt < attempts:
|
|
logger.warning(
|
|
"Retrying mutation %s test command (attempt %s/%s).",
|
|
context,
|
|
attempt + 1,
|
|
attempts,
|
|
)
|
|
time.sleep(0.2)
|
|
continue
|
|
return False
|
|
if result.returncode != 0:
|
|
logger.error(
|
|
"Mutation %s test command failed (rc=%s). Output tail: %s",
|
|
context,
|
|
result.returncode,
|
|
combined[-500:].replace("\n", " "),
|
|
)
|
|
if retry_on_failure and attempt < attempts:
|
|
logger.warning(
|
|
"Retrying mutation %s test command (attempt %s/%s).",
|
|
context,
|
|
attempt + 1,
|
|
attempts,
|
|
)
|
|
time.sleep(0.2)
|
|
continue
|
|
return False
|
|
return True
|
|
return False
|
|
except subprocess.TimeoutExpired:
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Test run failed: {e}")
|
|
return False
|
|
|
|
|
|
def run_mutation_workflow() -> bool:
|
|
report = {"total_mutants": 0, "killed": 0, "survived": 0, "details": []}
|
|
processed_targets = 0
|
|
|
|
for target in TARGET_FILES:
|
|
target_path = os.path.abspath(target)
|
|
backup_path = target_path + ".bak"
|
|
|
|
if not os.path.exists(target_path):
|
|
logger.error(f"Target file not found: {target}")
|
|
return False
|
|
|
|
processed_targets += 1
|
|
|
|
logger.info(f"Targeting {target}...")
|
|
|
|
# 1. Check baseline
|
|
# shutil.copy2(target_path, backup_path) # Backup first just in case
|
|
# But wait, run_test_suite runs on current state.
|
|
|
|
logger.info("Running baseline tests...")
|
|
if not run_test_suite(target_path, context="baseline", retry_on_failure=True):
|
|
# CRITICAL: baseline failure must fail the process with non-zero exit.
|
|
# Returning success here would let upstream gates treat mutation as pass.
|
|
logger.error("Baseline tests failed! Cannot proceed with mutation testing.")
|
|
return False
|
|
|
|
# 2. Count mutations
|
|
num_mutations = count_mutations(target_path)
|
|
logger.info(f"Found {num_mutations} mutation points in {target}")
|
|
report["total_mutants"] += num_mutations
|
|
|
|
# 3. Apply mutations one by one
|
|
try:
|
|
shutil.copy2(target_path, backup_path)
|
|
|
|
for i in range(num_mutations):
|
|
# Restore clean
|
|
shutil.copy2(backup_path, target_path)
|
|
|
|
# Apply mutation
|
|
desc = apply_mutation(target_path, i)
|
|
logger.info(f"Mutant {i+1}/{num_mutations}: {desc}")
|
|
|
|
# Run tests
|
|
passed = run_test_suite(target_path, context="mutant")
|
|
|
|
if not passed:
|
|
logger.info("-> KILLED")
|
|
report["killed"] += 1
|
|
else:
|
|
logger.warning("-> SURVIVED")
|
|
report["survived"] += 1
|
|
report["details"].append(
|
|
{
|
|
"file": target,
|
|
"mutation_index": i,
|
|
"description": desc,
|
|
"status": "SURVIVED",
|
|
}
|
|
)
|
|
finally:
|
|
# Restore original
|
|
if os.path.exists(backup_path):
|
|
shutil.copy2(backup_path, target_path)
|
|
os.remove(backup_path)
|
|
logger.info("Restored original file.")
|
|
|
|
if processed_targets == 0:
|
|
logger.error("No mutation targets processed.")
|
|
return False
|
|
|
|
# Generate Report
|
|
score = 0
|
|
if report["total_mutants"] > 0:
|
|
score = (report["killed"] / report["total_mutants"]) * 100
|
|
|
|
logger.info("=" * 40)
|
|
logger.info(f"Mutation Score: {score:.2f}%")
|
|
logger.info(
|
|
f"Total: {report['total_mutants']}, Killed: {report['killed']}, Survived: {report['survived']}"
|
|
)
|
|
logger.info("=" * 40)
|
|
|
|
report_file = os.path.join(".planning", "mutation_report.json")
|
|
os.makedirs(os.path.dirname(report_file), exist_ok=True)
|
|
with open(report_file, "w") as f:
|
|
json.dump(report, f, indent=2)
|
|
logger.info(f"Report saved to {report_file}")
|
|
return True
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(0 if run_mutation_workflow() else 1)
|