Files
cleveragents-core/features/steps/invariant_models_steps.py
HAL9000 5c5309f35d
CI / lint (push) Successful in 50s
CI / benchmark-regression (push) Failing after 46s
CI / typecheck (push) Successful in 1m21s
CI / security (push) Successful in 1m20s
CI / quality (push) Successful in 1m3s
CI / build (push) Successful in 35s
CI / helm (push) Successful in 32s
CI / integration_tests (push) Successful in 3m22s
CI / push-validation (push) Successful in 33s
CI / e2e_tests (push) Successful in 55s
CI / unit_tests (push) Successful in 5m49s
CI / docker (push) Successful in 2m1s
CI / coverage (push) Successful in 13m13s
CI / status-check (push) Successful in 3s
CI / benchmark-publish (push) Failing after 24m24s
fix(ci): add missing effective set count step definition
The Behave scenario at line 671 of consolidated_domain_models.feature
asserts 'the effective set should have {count:d} invariants' but no
step handler existed, causing UndefinedStepError and CI failure.

Adds the missing step: @then('the effective set should have {count:d} invariants')
to step_invariant_models_steps.py, mirroring the existing invariant-set count pattern.
2026-05-16 04:14:17 +00:00

773 lines
26 KiB
Python

"""Step definitions for invariant_models.feature.
Tests the Invariant domain models, InvariantService, merge precedence,
enforcement records, and violation models.
"""
from __future__ import annotations
import re
from behave import given, then, when # type: ignore[import-untyped]
from pydantic import ValidationError as PydanticValidationError
from cleveragents.application.services.invariant_service import InvariantService
from cleveragents.core.exceptions import NotFoundError, ValidationError
from cleveragents.domain.models.core.invariant import (
Invariant,
InvariantEnforcementRecord,
InvariantScope,
InvariantSet,
InvariantViolation,
merge_invariants,
)
# -- ULID pattern for validation --
ULID_RE = re.compile(r"^[0-9A-HJKMNP-TV-Z]{26}$")
# ================================================================
# Invariant Creation
# ================================================================
@when('I create an invariant with text "{text}" scope "{scope}" and source "{source}"')
def step_create_invariant(context, text, scope, source):
context.invariant = Invariant(
text=text,
scope=InvariantScope(scope),
source_name=source,
)
@then("the invariant should be created")
def step_invariant_created(context):
assert context.invariant is not None
@then('the invariant scope should be "{scope}"')
def step_invariant_scope(context, scope):
assert context.invariant.scope.value == scope
@then('the invariant source name should be "{source}"')
def step_invariant_source(context, source):
assert context.invariant.source_name == source
@then("the invariant should be active")
def step_invariant_active(context):
assert context.invariant.active is True
@then("the invariant id should be a valid ULID")
def step_invariant_ulid(context):
assert ULID_RE.match(context.invariant.id), (
f"Expected ULID, got: {context.invariant.id}"
)
@then('the invariant text should be "{text}"')
def step_invariant_text(context, text):
assert context.invariant.text == text
@when("I try to create an invariant with empty text")
def step_create_empty_text(context):
try:
Invariant(text="", scope=InvariantScope.GLOBAL, source_name="system")
context.error = None
except Exception as e:
context.error = e
@when("I try to create an invariant with whitespace-only text")
def step_create_whitespace_text(context):
try:
Invariant(text=" ", scope=InvariantScope.GLOBAL, source_name="system")
context.error = None
except Exception as e:
context.error = e
# NOTE: "a validation error should be raised" and "the error should mention"
# are already defined in features/steps/domain_models_steps.py and shared
# across all features via Behave's global step registry.
# ================================================================
# InvariantScope Enum
# ================================================================
@then('InvariantScope should have values "{values}"')
def step_scope_values(context, values):
expected = {v.strip() for v in values.split(",")}
actual = {s.value for s in InvariantScope}
assert expected == actual, f"Expected {expected}, got {actual}"
# ================================================================
# Merge Precedence
# ================================================================
def _parse_invariant_table(context, scope: InvariantScope):
"""Parse a Behave table into a list of Invariant objects."""
invariants = []
if context.table:
for row in context.table:
text = row["text"].strip()
source = row["source"].strip()
if text:
invariants.append(
Invariant(
text=text,
scope=scope,
source_name=source,
)
)
return invariants
@given("I have plan invariants")
def step_plan_invariants(context):
context.plan_invariants = _parse_invariant_table(context, InvariantScope.PLAN)
@given("I have action invariants")
def step_action_invariants(context):
context.action_invariants = _parse_invariant_table(context, InvariantScope.ACTION)
@given("I have project invariants")
def step_project_invariants(context):
context.project_invariants = _parse_invariant_table(context, InvariantScope.PROJECT)
@given("I have global invariants")
def step_global_invariants(context):
context.global_invariants = _parse_invariant_table(context, InvariantScope.GLOBAL)
@given("I have plan invariants with an inactive entry")
def step_plan_invariants_inactive(context):
inv = Invariant(
text="Inactive constraint",
scope=InvariantScope.PLAN,
source_name="plan1",
active=False,
)
context.plan_invariants = [inv]
@when("I merge the invariants")
def step_merge(context):
context.merged = merge_invariants(
getattr(context, "plan_invariants", []),
getattr(context, "action_invariants", []),
getattr(context, "project_invariants", []),
getattr(context, "global_invariants", []),
)
@then("the merged set should have {count:d} invariants")
def step_merged_count(context, count):
assert len(context.merged) == count, f"Expected {count}, got {len(context.merged)}"
@then('the merged invariant at index {idx:d} should have text "{text}"')
def step_merged_text(context, idx, text):
assert context.merged[idx].text == text
@then('the merged invariant at index {idx:d} should have scope "{scope}"')
def step_merged_scope(context, idx, scope):
assert context.merged[idx].scope.value == scope
@then("action invariants appear before project invariants in merge")
def step_action_before_project(context):
"""Verify ACTION tier comes before PROJECT tier in the merged result."""
action_invs = [i for i in context.merged if i.scope == InvariantScope.ACTION]
project_invs = [i for i in context.merged if i.scope == InvariantScope.PROJECT]
assert len(action_invs) > 0, "No action invariants found in merged result"
assert len(project_invs) > 0, "No project invariants found in merged result"
first_action_idx = context.merged.index(action_invs[0])
first_project_idx = context.merged.index(project_invs[0])
assert first_action_idx < first_project_idx, (
f"Action invariant at index {first_action_idx} "
f"should appear before project invariant at index {first_project_idx}"
)
@then("plan invariants appear before action invariants in merge")
def step_plan_before_action(context):
"""Verify PLAN tier comes before ACTION tier in the merged result."""
plan_invs = [i for i in context.merged if i.scope == InvariantScope.PLAN]
action_invs = [i for i in context.merged if i.scope == InvariantScope.ACTION]
assert len(plan_invs) > 0, "No plan invariants found in merged result"
assert len(action_invs) > 0, "No action invariants found in merged result"
first_plan_idx = context.merged.index(plan_invs[0])
first_action_idx = context.merged.index(action_invs[0])
assert first_plan_idx < first_action_idx, (
f"Plan invariant at index {first_plan_idx} "
f"should appear before action invariant at index {first_action_idx}"
)
@then("action invariants are preserved in merge result")
def step_action_invariants_preserved(context):
"""Verify that action-scoped invariants appear in the merged output."""
action_invs = [i for i in context.merged if i.scope == InvariantScope.ACTION]
assert len(action_invs) > 0, "Expected action-scoped invariants in merge result"
# ================================================================
# InvariantSet
# ================================================================
@when("I merge using InvariantSet")
def step_merge_invariant_set(context):
inv_set = InvariantSet.merge(
getattr(context, "plan_invariants", []),
getattr(context, "action_invariants", []),
getattr(context, "project_invariants", []),
getattr(context, "global_invariants", []),
)
context.invariant_set = inv_set
@then("the invariant set should have {count:d} invariants")
def step_set_count(context, count):
assert len(context.invariant_set.invariants) == count
# ================================================================
# Service: Add / List / Remove
# ================================================================
@given("an invariant service")
def step_service(context):
context.service = InvariantService()
@when(
'I add an invariant via service with text "{text}" scope "{scope}" source "{source}"'
)
def step_service_add(context, text, scope, source):
context.service_invariant = context.service.add_invariant(
text=text,
scope=InvariantScope(scope),
source_name=source,
)
@then("the service should return the created invariant")
def step_service_created(context):
assert context.service_invariant is not None
@then('the service invariant text should be "{text}"')
def step_service_text(context, text):
assert context.service_invariant.text == text
@when("I try to add an invariant via service with empty text")
def step_service_add_empty_text(context):
try:
context.service.add_invariant(
text="", scope=InvariantScope.GLOBAL, source_name="system"
)
context.error = None
except ValidationError as e:
context.error = e
@when("I try to add an invariant via service with empty source")
def step_service_add_empty_source(context):
try:
context.service.add_invariant(
text="Some text", scope=InvariantScope.GLOBAL, source_name=""
)
context.error = None
except ValidationError as e:
context.error = e
@then("a service validation error should be raised")
def step_service_validation_error(context):
assert context.error is not None
assert isinstance(context.error, ValidationError)
@given("an invariant service with {count:d} invariants")
def step_service_with_n(context, count):
context.service = InvariantService()
context.service_ids = []
for i in range(count):
inv = context.service.add_invariant(
text=f"Constraint {i + 1}",
scope=InvariantScope.GLOBAL,
source_name="system",
)
context.service_ids.append(inv.id)
@when("I list all invariants via service")
def step_service_list_all(context):
context.service_results = context.service.list_invariants()
@then("the service should return {count:d} invariants")
def step_service_list_count(context, count):
assert len(context.service_results) == count
@given("an invariant service with mixed scopes")
def step_service_mixed_scopes(context):
context.service = InvariantService()
context.service.add_invariant("G1", InvariantScope.GLOBAL, "system")
context.service.add_invariant("G2", InvariantScope.GLOBAL, "system")
context.service.add_invariant("P1", InvariantScope.PROJECT, "myapp")
@when('I list invariants via service with scope "{scope}"')
def step_service_list_scope(context, scope):
context.service_results = context.service.list_invariants(
scope=InvariantScope(scope)
)
@then('all returned invariants should have scope "{scope}"')
def step_all_scope(context, scope):
for inv in context.service_results:
assert inv.scope.value == scope
@given("an invariant service with mixed sources")
def step_service_mixed_sources(context):
context.service = InvariantService()
context.service.add_invariant("P1", InvariantScope.PROJECT, "myapp")
context.service.add_invariant("P2", InvariantScope.PROJECT, "myapp")
context.service.add_invariant("P3", InvariantScope.PROJECT, "other")
@when('I list invariants via service with source "{source}"')
def step_service_list_source(context, source):
context.service_results = context.service.list_invariants(source_name=source)
@then('all returned invariants should have source "{source}"')
def step_all_source(context, source):
for inv in context.service_results:
assert inv.source_name == source
@given("an invariant service with {count:d} invariant")
def step_service_with_one(context, count):
context.service = InvariantService()
context.service_ids = []
for i in range(count):
inv = context.service.add_invariant(
text=f"Constraint {i + 1}",
scope=InvariantScope.GLOBAL,
source_name="system",
)
context.service_ids.append(inv.id)
@when("I remove the invariant via service")
def step_service_remove(context):
# Store a reference to the original before removal so we can verify
# that the original is unchanged (frozen value-object semantics).
context.original_inv = context.service._invariants[context.service_ids[0]]
context.removed_inv = context.service.remove_invariant(context.service_ids[0])
@then("the invariant should be inactive")
def step_invariant_inactive(context):
assert context.removed_inv.active is False
@then("listing active invariants should return {count:d}")
def step_list_active(context, count):
results = context.service.list_invariants()
assert len(results) == count
@when("I try to remove a non-existent invariant")
def step_remove_nonexistent(context):
try:
context.service.remove_invariant("01ZZZZZZZZZZZZZZZZZZZZZZZZ")
context.error = None
except NotFoundError as e:
context.error = e
# NOTE: "a not found error should be raised" is already defined in
# features/steps/plan_lifecycle_service_steps.py and shared globally.
@when("I try to remove an invariant with empty ID")
def step_remove_empty_id(context):
try:
context.service.remove_invariant("")
context.error = None
except ValidationError as e:
context.error = e
# ================================================================
# Effective Invariant Computation
# ================================================================
@given("an invariant service with invariants at all scopes")
def step_service_all_scopes(context):
context.service = InvariantService()
context.service.add_invariant("Global rule", InvariantScope.GLOBAL, "system")
context.service.add_invariant("Project rule", InvariantScope.PROJECT, "proj1")
context.service.add_invariant("Plan rule", InvariantScope.PLAN, "plan1")
@given("an invariant service with invariants at all four scopes")
def step_service_all_four_scopes(context):
context.service = InvariantService()
context.service.add_invariant("Global rule", InvariantScope.GLOBAL, "system")
context.service.add_invariant("Project rule", InvariantScope.PROJECT, "proj1")
context.service.add_invariant("Action rule", InvariantScope.ACTION, "action1")
context.service.add_invariant("Plan rule", InvariantScope.PLAN, "plan1")
@when('I get effective invariants for plan "{plan_id}" and project "{project}"')
def step_effective(context, plan_id, project):
context.effective = context.service.get_effective_invariants(
plan_id=plan_id, project_name=project
)
@when(
'I get effective invariants for plan "{plan_id}", action "{action}", and project "{project}"'
)
def step_effective_with_action(context, plan_id, action, project):
context.effective = context.service.get_effective_invariants(
plan_id=plan_id, action_name=action, project_name=project
)
@then("the effective set should contain plan invariants first")
def step_effective_plan_first(context):
plan_invs = [i for i in context.effective if i.scope == InvariantScope.PLAN]
if plan_invs:
first_plan_idx = context.effective.index(plan_invs[0])
assert first_plan_idx == 0
@then("the effective set should contain action invariants second")
def step_effective_action_second(context):
"""Verify ACTION tier appears after PLAN and before PROJECT."""
plan_invs = [i for i in context.effective if i.scope == InvariantScope.PLAN]
action_invs = [i for i in context.effective if i.scope == InvariantScope.ACTION]
project_invs = [i for i in context.effective if i.scope == InvariantScope.PROJECT]
assert len(plan_invs) > 0, "No plan invariants found"
assert len(action_invs) > 0, "No action invariants found — bug #9126 not fixed!"
assert len(project_invs) > 0, "No project invariants found"
first_action_idx = context.effective.index(action_invs[0])
if plan_invs:
last_plan_idx = context.effective.index(plan_invs[-1])
assert first_action_idx > last_plan_idx
first_project_idx = context.effective.index(project_invs[0])
assert first_project_idx > first_action_idx
@then("the effective set should contain project invariants after action")
def step_effective_project_after_action(context):
"""Verify PROJECT tier appears after ACTION tier."""
action_invs = [i for i in context.effective if i.scope == InvariantScope.ACTION]
project_invs = [i for i in context.effective if i.scope == InvariantScope.PROJECT]
assert len(action_invs) > 0, "No action invariants found"
assert len(project_invs) > 0, "No project invariants found"
first_proj_idx = context.effective.index(project_invs[0])
last_action_idx = context.effective.index(action_invs[-1])
assert first_proj_idx > last_action_idx
@then("the effective set should contain project invariants second")
def step_effective_project_second(context):
proj_invs = [i for i in context.effective if i.scope == InvariantScope.PROJECT]
plan_invs = [i for i in context.effective if i.scope == InvariantScope.PLAN]
if proj_invs:
first_proj_idx = context.effective.index(proj_invs[0])
assert first_proj_idx >= len(plan_invs)
@then("the effective set should contain global invariants last")
def step_effective_global_last(context):
glob_invs = [i for i in context.effective if i.scope == InvariantScope.GLOBAL]
if glob_invs:
last_glob_idx = context.effective.index(glob_invs[-1])
assert last_glob_idx == len(context.effective) - 1
@given("an invariant service with duplicate text across scopes")
def step_service_duplicates(context):
context.service = InvariantService()
context.service.add_invariant("Log all changes", InvariantScope.GLOBAL, "system")
context.service.add_invariant("Log all changes", InvariantScope.PROJECT, "proj1")
context.service.add_invariant("Log all changes", InvariantScope.PLAN, "plan1")
@then("the effective set should have no duplicates")
def step_no_duplicates(context):
texts = [inv.text.lower() for inv in context.effective]
assert len(texts) == len(set(texts)), f"Duplicates found: {texts}"
@then("the effective set should have {count:d} invariants")
def step_effective_count(context, count):
assert len(context.effective) == count, (
f"Expected {count}, got {len(context.effective)}"
)
# ================================================================
# Enforcement
# ================================================================
@when('I enforce invariants for plan "{plan_id}"')
def step_enforce(context, plan_id):
invs = context.service.list_invariants()
context.enforcement_records = context.service.enforce_invariants(
plan_id=plan_id, invariants=invs
)
@then("{count:d} enforcement records should be created")
def step_enforcement_count(context, count):
assert len(context.enforcement_records) == count
@then("each enforcement record should be marked as enforced")
def step_enforcement_enforced(context):
for rec in context.enforcement_records:
assert rec.enforced is True
@then("each enforcement record should have a decision ID")
def step_enforcement_decision_id(context):
for rec in context.enforcement_records:
assert rec.decision_id != ""
@when('I enforce invariants with actor response "{response}"')
def step_enforce_with_response(context, response):
invs = context.service.list_invariants()
context.enforcement_records = context.service.enforce_invariants(
plan_id="PLAN_XYZ", invariants=invs, actor_response=response
)
@then('the enforcement record actor response should be "{response}"')
def step_enforcement_response(context, response):
assert context.enforcement_records[0].actor_response == response
@when("I try to enforce invariants with empty plan ID")
def step_enforce_empty_plan(context):
try:
context.service.enforce_invariants(plan_id="", invariants=[])
context.error = None
except ValidationError as e:
context.error = e
# ================================================================
# InvariantViolation
# ================================================================
@when('I create an invariant violation with severity "{severity}"')
def step_create_violation(context, severity):
try:
context.violation = InvariantViolation(
invariant_id="01TESTINVARIANTID000000000",
violated_text="Some constraint was violated",
severity=severity,
)
context.error = None
except Exception as e:
context.error = e
context.violation = None
@then("the violation should be created")
def step_violation_created(context):
assert context.violation is not None
@then('the invariant violation severity should be "{severity}"')
def step_violation_severity(context, severity):
assert context.violation.severity == severity
@when('I try to create a violation with invalid severity "{severity}"')
def step_create_invalid_violation(context, severity):
try:
InvariantViolation(
invariant_id="01TESTINVARIANTID000000000",
violated_text="Some constraint",
severity=severity,
)
context.error = None
except Exception as e:
context.error = e
# ================================================================
# InvariantEnforcementRecord
# ================================================================
@when("I create an enforcement record with enforced true")
def step_create_record_true(context):
context.record = InvariantEnforcementRecord(
invariant_id="01TESTINVARIANTID000000000",
enforced=True,
actor_response="OK",
decision_id="01TESTDECISIONID0000000000",
)
@when("I create an enforcement record with enforced false")
def step_create_record_false(context):
context.record = InvariantEnforcementRecord(
invariant_id="01TESTINVARIANTID000000000",
enforced=False,
)
@then("the record should be created")
def step_record_created(context):
assert context.record is not None
@then("the record enforced flag should be true")
def step_record_enforced_true(context):
assert context.record.enforced is True
@then("the record enforced flag should be false")
def step_record_enforced_false(context):
assert context.record.enforced is False
# ================================================================
# Immutability contract (frozen=True) — Issue #3116
# ================================================================
@then("mutating the invariant active field should raise an error")
def step_invariant_mutation_raises(context):
try:
context.invariant.active = False
context.error = None
except Exception as e:
context.error = e
assert context.error is not None, "Expected mutation to raise an error"
assert isinstance(context.error, PydanticValidationError), (
f"Expected PydanticValidationError, got {type(context.error)}"
)
@then("mutating the violation severity field should raise an error")
def step_violation_mutation_raises(context):
try:
context.violation.severity = "info"
context.error = None
except Exception as e:
context.error = e
assert context.error is not None, "Expected mutation to raise an error"
assert isinstance(context.error, PydanticValidationError), (
f"Expected PydanticValidationError, got {type(context.error)}"
)
@then("mutating the record enforced field should raise an error")
def step_record_mutation_raises(context):
try:
context.record.enforced = False
context.error = None
except Exception as e:
context.error = e
assert context.error is not None, "Expected mutation to raise an error"
assert isinstance(context.error, PydanticValidationError), (
f"Expected PydanticValidationError, got {type(context.error)}"
)
@then("the invariant should be hashable")
def step_invariant_hashable(context):
h = hash(context.invariant)
assert isinstance(h, int), f"Expected int hash, got {type(h)}"
@then("the violation should be hashable")
def step_violation_hashable(context):
h = hash(context.violation)
assert isinstance(h, int), f"Expected int hash, got {type(h)}"
@then("the record should be hashable")
def step_record_hashable(context):
h = hash(context.record)
assert isinstance(h, int), f"Expected int hash, got {type(h)}"
@then("the removed invariant is a different object than the original")
def step_removed_is_new_object(context):
# The service stores the deactivated copy; the original object is unchanged.
# Verify the returned object has active=False and is a valid Invariant.
assert context.removed_inv.active is False
assert isinstance(context.removed_inv, Invariant)
# Verify object identity: the returned copy must be a *different* object.
assert context.removed_inv is not context.original_inv, (
"Expected model_copy to return a new object, but got the same object"
)
@then("the original invariant should still be active")
def step_original_still_active(context):
# The original frozen value-object must be unchanged after soft-delete.
assert context.original_inv.active is True, (
"Expected original invariant to remain active=True after soft-delete "
"(frozen value-object must not be mutated)"
)
# ================================================================
# InvariantSet immutability and hashability — Issue #3116
# ================================================================
@then("mutating the invariant set invariants field should raise an error")
def step_invariant_set_mutation_raises(context):
try:
context.invariant_set.invariants = []
context.error = None
except Exception as e:
context.error = e
assert context.error is not None, "Expected mutation to raise an error"
assert isinstance(context.error, PydanticValidationError), (
f"Expected PydanticValidationError, got {type(context.error)}"
)
@then("the invariant set should be hashable")
def step_invariant_set_hashable(context):
h = hash(context.invariant_set)
assert isinstance(h, int), f"Expected int hash, got {type(h)}"