forked from cleveragents/cleveragents-core
81c141716b
## Summary
- Refactors WF02 integration helper into focused modules to satisfy maintainability guidelines and keep command responsibilities isolated.
- Moves WF02 artifact assertions onto lifecycle-produced change artifacts and verifies user-facing `plan artifacts` flow with mocked providers.
- Reworks WF02 coverage-gate assertions to run through validation registration/attachment/execution lifecycle and apply-gate outcomes.
## What changed in this fix round (cycle-6)
- **CI must-fix — trusted profile lifecycle integration failure:**
- Fixed failing integration test `WF02 Trusted Profile Auto Runs Strategize And Execute` caused by legacy AutomationProfile fields that no longer exist.
- Updated WF02 trusted-profile assertions from removed legacy fields (`auto_strategize`, `auto_execute`, `auto_apply`) to current spec-aligned fields (`decompose_task`, `create_tool`, `select_tool`).
- Key module: `robot/wf02_test_generation_commands.py` (`wf02_trusted_lifecycle`).
## Prior fix rounds retained
- **P1 must-fix — validation payload assertions strengthened:**
- Added explicit assertions for validation payload correctness in both failing and passing branches.
- Assertions now verify:
- validation identity (`local/auth-coverage` on `local/api-service-repo`)
- required-mode semantics (`mode=required`, required pass/fail counters, `all_required_passed`)
- `passed` boolean for each branch
- measured-vs-target semantics (`measured_coverage` compared against `target`).
- Key module: `robot/wf02_test_generation_validation.py`.
- **P2 should-fix — artifact membership assertions tightened:**
- Artifact assertions now enforce exact expected membership and count instead of permissive subset checks.
- Persisted changeset assertions were aligned to the same strict set/count checks.
- Key module: `robot/wf02_test_generation_artifacts.py`.
- **P2 should-fix — removed unused helper:**
- Deleted unused `_bind_changeset_to_plan` helper to eliminate dead code.
- Key module: `robot/wf02_test_generation_artifacts.py`.
- **P2 should-fix — concrete typing:**
- Replaced `_setup_validation_db` return type from `tuple[Any, Any]` to concrete typed return (`Callable[[], _NoCloseSession]`, `Session`).
- Key module: `robot/wf02_test_generation_validation.py`.
## Quality gates
- `nox -e lint` ✅
- `nox -e typecheck` ✅
- `nox -e unit_tests` ✅
- `nox -e integration_tests` ✅
- `nox -e e2e_tests` ✅
- `nox -e coverage_report` ✅ (coverage: **98.74%**)
## Scope / limitations
- No deferred items identified in cycle-6.
Closes #766
Reviewed-on: cleveragents/cleveragents-core#1176
Reviewed-by: Jeffrey Phillips Freeman <jeffrey.freeman@cleverthis.com>
Co-authored-by: Brent Edwards <brent.edwards@cleverthis.com>
Co-committed-by: Brent Edwards <brent.edwards@cleverthis.com>
453 lines
16 KiB
Python
453 lines
16 KiB
Python
"""WF02 coverage validation lifecycle assertions."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import importlib.util
|
|
import inspect
|
|
import json
|
|
import sys
|
|
import trace
|
|
import traceback
|
|
import uuid
|
|
from collections.abc import Callable
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
from wf02_test_generation_common import (
|
|
auth_module_fixture,
|
|
create_trusted_plan,
|
|
create_wf02_action,
|
|
generate_tests_from_mock_provider,
|
|
setup_lifecycle,
|
|
write_generated_files_safely,
|
|
)
|
|
from wf02_test_generation_lifecycle import (
|
|
record_files_via_execution_context,
|
|
)
|
|
|
|
from cleveragents.application.services.plan_apply_service import (
|
|
ApplyOutcome,
|
|
PlanApplyService,
|
|
)
|
|
from cleveragents.application.services.resource_registry_service import (
|
|
ResourceRegistryService,
|
|
)
|
|
from cleveragents.application.services.tool_registry_service import ToolRegistryService
|
|
from cleveragents.application.services.validation_pipeline import (
|
|
ValidationCommand,
|
|
ValidationPipeline,
|
|
)
|
|
from cleveragents.domain.models.core.change import InMemoryChangeSetStore
|
|
from cleveragents.domain.models.core.tool import ValidationMode
|
|
from cleveragents.infrastructure.database.models import Base
|
|
from cleveragents.infrastructure.database.repositories import (
|
|
ToolRegistryRepository,
|
|
ValidationAttachmentRepository,
|
|
)
|
|
|
|
|
|
class _NoCloseSession:
|
|
"""Thin proxy that delegates every attribute to a real SQLAlchemy Session
|
|
but suppresses ``close()``.
|
|
|
|
Repository helpers call ``session.close()`` after each operation. In
|
|
integration tests we share a single in-memory SQLite session across
|
|
multiple repositories so that all writes are visible without a second
|
|
connection. Closing the session would invalidate the connection and
|
|
lose all in-memory data.
|
|
|
|
``object.__setattr__`` / ``object.__getattribute__`` are used in the
|
|
dunder overrides to access this wrapper's own ``_s`` slot without
|
|
triggering the proxy's ``__getattr__`` / ``__setattr__``, which would
|
|
recurse into the wrapped session instead.
|
|
"""
|
|
|
|
def __init__(self, session: object) -> None:
|
|
object.__setattr__(self, "_s", session)
|
|
|
|
def close(self) -> None:
|
|
pass
|
|
|
|
def __setattr__(self, name: str, value: object) -> None:
|
|
setattr(object.__getattribute__(self, "_s"), name, value)
|
|
|
|
def __getattr__(self, name: str) -> object:
|
|
return getattr(object.__getattribute__(self, "_s"), name)
|
|
|
|
|
|
def _setup_validation_db() -> tuple[Callable[[], _NoCloseSession], Session]:
|
|
"""Create in-memory DB and return (session_factory, session)."""
|
|
engine = create_engine("sqlite:///:memory:", echo=False)
|
|
Base.metadata.create_all(engine)
|
|
session = sessionmaker(bind=engine, expire_on_commit=False)()
|
|
wrapper = _NoCloseSession(session)
|
|
|
|
def factory() -> _NoCloseSession:
|
|
return wrapper
|
|
|
|
return factory, session
|
|
|
|
|
|
def _assert_validation_payload(
|
|
validation_summary: dict[str, Any],
|
|
*,
|
|
expected_passed: bool,
|
|
target: int,
|
|
) -> None:
|
|
"""Assert validation summary payload semantics for WF02 coverage gate."""
|
|
assert validation_summary["total"] == 1
|
|
assert validation_summary["informational_passed"] == 0
|
|
assert validation_summary["informational_failed"] == 0
|
|
|
|
if expected_passed:
|
|
assert validation_summary["required_passed"] == 1
|
|
assert validation_summary["required_failed"] == 0
|
|
assert validation_summary["all_required_passed"] is True
|
|
else:
|
|
assert validation_summary["required_passed"] == 0
|
|
assert validation_summary["required_failed"] == 1
|
|
assert validation_summary["all_required_passed"] is False
|
|
|
|
results = validation_summary.get("results")
|
|
assert isinstance(results, list) and len(results) == 1
|
|
result = results[0]
|
|
assert result["validation_name"] == "local/auth-coverage"
|
|
assert result["resource_name"] == "local/api-service-repo"
|
|
assert result["mode"] == "required"
|
|
assert result["passed"] is expected_passed
|
|
|
|
data = result.get("data")
|
|
assert isinstance(data, dict)
|
|
measured = data.get("measured_coverage")
|
|
assert isinstance(measured, float)
|
|
assert data.get("target") == target
|
|
|
|
if expected_passed:
|
|
assert measured >= float(target)
|
|
else:
|
|
assert measured < float(target)
|
|
|
|
|
|
def _measure_auth_coverage_with_trace(
|
|
fixture_root: Path,
|
|
) -> tuple[float, str, str, int]:
|
|
"""Measure auth.py line coverage by tracing test function execution."""
|
|
import os
|
|
from contextlib import redirect_stderr, redirect_stdout
|
|
from io import StringIO
|
|
|
|
target_file = (fixture_root / "src" / "auth.py").resolve()
|
|
source_lines = target_file.read_text(encoding="utf-8").splitlines()
|
|
executable_lines = {
|
|
line_no
|
|
for line_no, text in enumerate(source_lines, start=1)
|
|
if text.strip() and not text.strip().startswith("#")
|
|
}
|
|
if not executable_lines:
|
|
return 0.0, "", "No executable lines found in auth.py", 3
|
|
|
|
tracer = trace.Trace(count=True, trace=False)
|
|
old_cwd = Path.cwd()
|
|
old_sys_path = list(sys.path)
|
|
stdout_capture = StringIO()
|
|
stderr_capture = StringIO()
|
|
try:
|
|
os.chdir(fixture_root)
|
|
sys.path.insert(0, str(fixture_root))
|
|
importlib.invalidate_caches()
|
|
with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):
|
|
for test_file in sorted((fixture_root / "tests").glob("test_*.py")):
|
|
if test_file.suffix != ".py":
|
|
continue
|
|
if not test_file.name.startswith("test_auth"):
|
|
continue
|
|
|
|
module_name = f"_wf02_test_{test_file.stem}_{uuid.uuid4().hex}"
|
|
spec = importlib.util.spec_from_file_location(module_name, test_file)
|
|
if spec is None or spec.loader is None:
|
|
return (
|
|
0.0,
|
|
stdout_capture.getvalue(),
|
|
f"Unable to load {test_file}",
|
|
4,
|
|
)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
|
|
fixture_values: dict[str, object] = {
|
|
"auth_credentials": ("admin", "secret"),
|
|
}
|
|
|
|
for attr_name in dir(module):
|
|
if not attr_name.startswith("test_"):
|
|
continue
|
|
maybe_test = getattr(module, attr_name)
|
|
if not callable(maybe_test):
|
|
continue
|
|
|
|
signature = inspect.signature(maybe_test)
|
|
kwargs: dict[str, object] = {}
|
|
for param_name, param in signature.parameters.items():
|
|
if param.kind not in (
|
|
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
inspect.Parameter.KEYWORD_ONLY,
|
|
):
|
|
raise AssertionError(
|
|
f"Unsupported test function parameter kind for "
|
|
f"{attr_name}: {param.kind}"
|
|
)
|
|
|
|
if param_name in fixture_values:
|
|
kwargs[param_name] = fixture_values[param_name]
|
|
continue
|
|
|
|
if param.default is inspect.Parameter.empty:
|
|
raise AssertionError(
|
|
"Unsupported required test fixture parameter "
|
|
f"'{param_name}' in {attr_name}"
|
|
)
|
|
|
|
tracer.runfunc(maybe_test, **kwargs)
|
|
|
|
for loaded_name in list(sys.modules):
|
|
if loaded_name == module_name or loaded_name.startswith(
|
|
"_wf02_test_"
|
|
):
|
|
sys.modules.pop(loaded_name, None)
|
|
for cached_name in ("src.auth", "src"):
|
|
sys.modules.pop(cached_name, None)
|
|
importlib.invalidate_caches()
|
|
except Exception:
|
|
return 0.0, stdout_capture.getvalue(), traceback.format_exc(), 1
|
|
finally:
|
|
sys.path = old_sys_path
|
|
os.chdir(old_cwd)
|
|
|
|
counts = tracer.results().counts
|
|
covered_lines = {
|
|
lineno
|
|
for (filename, lineno), count in counts.items()
|
|
if count > 0
|
|
and Path(filename).name == "auth.py"
|
|
and "src" in Path(filename).parts
|
|
}
|
|
measured_pct = (
|
|
len(covered_lines & executable_lines) / len(executable_lines)
|
|
) * 100.0
|
|
return measured_pct, stdout_capture.getvalue(), stderr_capture.getvalue(), 0
|
|
|
|
|
|
def _register_attach_and_run_validation(
|
|
fixture_root: Path,
|
|
plan_id: str,
|
|
target: int,
|
|
) -> dict[str, Any]:
|
|
"""Run validation through registration/attachment/execution lifecycle."""
|
|
session_factory, _ = _setup_validation_db()
|
|
tool_repo = ToolRegistryRepository(session_factory=session_factory)
|
|
attachment_repo = ValidationAttachmentRepository(session_factory=session_factory)
|
|
tool_service = ToolRegistryService(
|
|
tool_repo=tool_repo,
|
|
attachment_repo=attachment_repo,
|
|
)
|
|
now = datetime.now(tz=UTC).isoformat()
|
|
tool_service.register_tool(
|
|
{
|
|
"name": "local/auth-coverage",
|
|
"description": "Auth module coverage gate",
|
|
"tool_type": "validation",
|
|
"source": "builtin",
|
|
"timeout": 300,
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
"resource_bindings": [],
|
|
"mode": "required",
|
|
}
|
|
)
|
|
|
|
resource_service = ResourceRegistryService(session_factory=session_factory)
|
|
resource_service.bootstrap_builtin_types()
|
|
registered_resource = resource_service.register_resource(
|
|
type_name="git-checkout",
|
|
name="local/api-service-repo",
|
|
location=str(fixture_root),
|
|
description="WF02 coverage fixture repository",
|
|
)
|
|
|
|
resource_id = registered_resource.resource_id
|
|
tool_service.attach_validation(
|
|
validation_name="local/auth-coverage",
|
|
resource_id=resource_id,
|
|
mode="required",
|
|
plan_id=plan_id,
|
|
args={"target": target},
|
|
)
|
|
|
|
attachments = tool_service.list_validations_for_resource(
|
|
resource_id=resource_id,
|
|
plan_id=plan_id,
|
|
)
|
|
assert attachments, "Expected at least one attached validation"
|
|
|
|
commands: list[ValidationCommand] = []
|
|
for attachment in attachments:
|
|
args_json = attachment.get("args_json")
|
|
args = json.loads(args_json) if isinstance(args_json, str) and args_json else {}
|
|
mode_text = str(attachment.get("mode", "required"))
|
|
mode = (
|
|
ValidationMode.REQUIRED
|
|
if mode_text == "required"
|
|
else ValidationMode.INFORMATIONAL
|
|
)
|
|
commands.append(
|
|
ValidationCommand(
|
|
validation_name=str(attachment["validation_name"]),
|
|
resource_id=str(attachment["resource_id"]),
|
|
resource_name="local/api-service-repo",
|
|
mode=mode,
|
|
arguments=args,
|
|
timeout_seconds=30.0,
|
|
)
|
|
)
|
|
|
|
def executor(
|
|
validation_name: str,
|
|
arguments: dict[str, object],
|
|
) -> dict[str, object]:
|
|
target_value = arguments.get("target")
|
|
if not isinstance(target_value, int):
|
|
raise TypeError("target must be an int")
|
|
measured_pct, stdout, stderr, exit_code = _measure_auth_coverage_with_trace(
|
|
fixture_root
|
|
)
|
|
return {
|
|
"passed": exit_code == 0 and measured_pct >= float(target_value),
|
|
"message": (
|
|
f"{validation_name}: measured={measured_pct:.1f}% "
|
|
f"target={target_value}% exit_code={exit_code}"
|
|
),
|
|
"data": {
|
|
"stdout": stdout,
|
|
"stderr": stderr,
|
|
"measured_coverage": measured_pct,
|
|
"target": target_value,
|
|
},
|
|
}
|
|
|
|
plan_metadata: dict[str, Any] = {}
|
|
summary = ValidationPipeline(
|
|
commands=commands,
|
|
executor=executor,
|
|
max_workers=1,
|
|
).run_for_plan(plan_metadata=plan_metadata)
|
|
assert summary.results, "Expected at least one validation result"
|
|
|
|
validation_summary = plan_metadata.get("validation_summary")
|
|
assert isinstance(validation_summary, dict), "Expected validation_summary metadata"
|
|
return validation_summary
|
|
|
|
|
|
def wf02_coverage_validation() -> None:
|
|
"""Verify coverage gate blocks failing coverage then applies passing coverage."""
|
|
target = 80
|
|
|
|
lifecycle_fail = setup_lifecycle()
|
|
create_wf02_action(lifecycle_fail)
|
|
fail_plan_id = create_trusted_plan(lifecycle_fail)
|
|
lifecycle_fail.try_auto_run(fail_plan_id)
|
|
store_fail = InMemoryChangeSetStore()
|
|
apply_fail = PlanApplyService(
|
|
lifecycle_service=lifecycle_fail, changeset_store=store_fail
|
|
)
|
|
|
|
with auth_module_fixture() as (fail_fixture_root, _):
|
|
placeholder_path = "tests/test_auth_placeholder.py"
|
|
(fail_fixture_root / placeholder_path).write_text(
|
|
"""def test_placeholder() -> None:
|
|
assert True
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
fail_changeset_id = record_files_via_execution_context(
|
|
plan_id=fail_plan_id,
|
|
store=store_fail,
|
|
files={placeholder_path: "placeholder"},
|
|
)
|
|
fail_plan = lifecycle_fail.get_plan(fail_plan_id)
|
|
fail_plan.changeset_id = fail_changeset_id
|
|
fail_plan.sandbox_refs = [f"sandbox-{fail_plan_id[:8]}"]
|
|
fail_plan.validation_summary = _register_attach_and_run_validation(
|
|
fail_fixture_root,
|
|
fail_plan_id,
|
|
target,
|
|
)
|
|
_assert_validation_payload(
|
|
fail_plan.validation_summary,
|
|
expected_passed=False,
|
|
target=target,
|
|
)
|
|
lifecycle_fail.save_plan(fail_plan)
|
|
|
|
lifecycle_fail.apply_plan(fail_plan_id)
|
|
lifecycle_fail.start_apply(fail_plan_id)
|
|
fail_result = apply_fail.apply_with_validation_gate(fail_plan_id)
|
|
assert fail_result.outcome == ApplyOutcome.CONSTRAINED
|
|
|
|
constrained_plan = lifecycle_fail.get_plan(fail_plan_id)
|
|
assert constrained_plan.processing_state.value == "constrained"
|
|
|
|
lifecycle_pass = setup_lifecycle()
|
|
create_wf02_action(lifecycle_pass)
|
|
pass_plan_id = create_trusted_plan(lifecycle_pass)
|
|
lifecycle_pass.try_auto_run(pass_plan_id)
|
|
store_pass = InMemoryChangeSetStore()
|
|
apply_pass = PlanApplyService(
|
|
lifecycle_service=lifecycle_pass, changeset_store=store_pass
|
|
)
|
|
|
|
with auth_module_fixture() as (pass_fixture_root, _):
|
|
generated_files = generate_tests_from_mock_provider(
|
|
pass_fixture_root,
|
|
"src/auth",
|
|
target,
|
|
include_coverage_suite=True,
|
|
)
|
|
normalized_files = write_generated_files_safely(
|
|
pass_fixture_root,
|
|
generated_files,
|
|
)
|
|
pass_changeset_id = record_files_via_execution_context(
|
|
plan_id=pass_plan_id,
|
|
store=store_pass,
|
|
files=normalized_files,
|
|
)
|
|
|
|
pass_plan = lifecycle_pass.get_plan(pass_plan_id)
|
|
pass_plan.changeset_id = pass_changeset_id
|
|
pass_plan.sandbox_refs = [f"sandbox-{pass_plan_id[:8]}"]
|
|
pass_plan.validation_summary = _register_attach_and_run_validation(
|
|
pass_fixture_root,
|
|
pass_plan_id,
|
|
target,
|
|
)
|
|
_assert_validation_payload(
|
|
pass_plan.validation_summary,
|
|
expected_passed=True,
|
|
target=target,
|
|
)
|
|
lifecycle_pass.save_plan(pass_plan)
|
|
|
|
lifecycle_pass.apply_plan(pass_plan_id)
|
|
lifecycle_pass.start_apply(pass_plan_id)
|
|
pass_result = apply_pass.apply_with_validation_gate(pass_plan_id)
|
|
assert pass_result.outcome == ApplyOutcome.APPLIED
|
|
|
|
applied_plan = lifecycle_pass.get_plan(pass_plan_id)
|
|
assert applied_plan.processing_state.value == "applied"
|
|
|
|
print("wf02-coverage-validation-ok")
|