diff --git a/src/context_compiler/engine.py b/src/context_compiler/engine.py index f66c625..722c2b0 100644 --- a/src/context_compiler/engine.py +++ b/src/context_compiler/engine.py @@ -16,6 +16,8 @@ STATE_PREMISE, STATE_VERSION, ) +from .grammar import DirectiveKind +from .grammar import _parse_directive as _parse_canonical_directive PolicyValue = Literal["use", "prohibit"] @@ -332,67 +334,31 @@ def _apply_replacement_explicit(self, new_item: str, old_item: str) -> None: def _parse_directive(user_input: str) -> Action | None: - if _contains_compound_directive(user_input): - return Action(kind="compound_directive_invalid") + parsed = _parse_canonical_directive(user_input) + if parsed is None: + return None - if user_input == _CLEAR_PREMISE: + if parsed.kind is DirectiveKind.SET_PREMISE: + return Action(kind="set_premise", value=parsed.operands["value"]) + if parsed.kind is DirectiveKind.CHANGE_PREMISE: + return Action(kind="change_premise", value=parsed.operands["value"]) + if parsed.kind is DirectiveKind.USE_ITEM: + return Action(kind="use_item", item=parsed.operands["item"]) + if parsed.kind is DirectiveKind.PROHIBIT_ITEM: + return Action(kind="prohibit_item", item=parsed.operands["item"]) + if parsed.kind is DirectiveKind.REMOVE_POLICY: + return Action(kind="remove_policy_item", item=parsed.operands["item"]) + if parsed.kind is DirectiveKind.REPLACE_USE: + return Action( + kind="replace_use", + new_item=parsed.operands["new_item"], + old_item=parsed.operands["old_item"], + ) + if parsed.kind is DirectiveKind.CLEAR_PREMISE: return Action(kind="clear_premise") - if user_input == _RESET_POLICIES: + if parsed.kind is DirectiveKind.RESET_POLICIES: return Action(kind="reset_policies") - if user_input == _CLEAR_STATE: - return Action(kind="clear_state") - - if user_input == _REMOVE_POLICY_BASE: - return Action(kind="remove_policy_item", item="") - remove_policy_prefix = f"{_REMOVE_POLICY_BASE} " - if user_input.startswith(remove_policy_prefix): - return Action(kind="remove_policy_item", item=user_input[len(remove_policy_prefix) :]) - - if ( - user_input.startswith(_CHANGE_PREMISE_PREFIX) - and not user_input.startswith(f"{_CHANGE_PREMISE_BASE} ") - and user_input != _CHANGE_PREMISE_BASE - ): - return None - - if user_input == _SET_PREMISE_BASE: - return Action(kind="set_premise", value="") - set_prefix = f"{_SET_PREMISE_BASE} " - if user_input.startswith(set_prefix): - value = user_input[len(set_prefix) :] - return Action(kind="set_premise", value=value) - - if user_input == _CHANGE_PREMISE_BASE: - return Action(kind="change_premise", value="") - change_prefix = f"{_CHANGE_PREMISE_BASE} " - if user_input.startswith(change_prefix): - value = user_input[len(change_prefix) :] - return Action(kind="change_premise", value=value) - - if user_input == "use": - return Action(kind="use_item", item="") - - if user_input.startswith(_USE_PREFIX): - payload = user_input[len(_USE_PREFIX) :] - left, sep, right = payload.partition(" instead of ") - if sep: - if left.strip() != "" and right.strip() != "": - return Action(kind="replace_use", new_item=left, old_item=right) - return Action(kind="replace_use_incomplete") - if payload.strip() == "": - return Action(kind="use_item", item="") - if payload.startswith("instead of ") or payload.endswith(" instead of"): - return Action(kind="replace_use_incomplete") - return Action(kind="use_item", item=payload) - - if user_input == _PROHIBIT_BASE: - return Action(kind="prohibit_item", item="") - - if user_input.startswith(_PROHIBIT_PREFIX): - item = user_input[len(_PROHIBIT_PREFIX) :] - return Action(kind="prohibit_item", item=item) - - return None + return Action(kind="clear_state") def _contains_compound_directive(user_input: str) -> bool: diff --git a/src/context_compiler/grammar.py b/src/context_compiler/grammar.py index 69132fa..1b29e3b 100644 --- a/src/context_compiler/grammar.py +++ b/src/context_compiler/grammar.py @@ -25,14 +25,19 @@ class ValidatedDirective: kind: DirectiveKind +@dataclass(frozen=True, slots=True) +class _ParsedDirective: + text: str + kind: DirectiveKind + operands: MappingProxyType[str, str] + + @dataclass(frozen=True, slots=True) class _DirectiveSpec: kind: DirectiveKind operand_names: tuple[str, ...] - matcher: re.Pattern[str] | None exact_text: str | None renderer: Callable[[MappingProxyType[str, str]], str] - canonical_starts: tuple[tuple[str, bool], ...] _SET_PREMISE_PREFIX = "set premise " @@ -41,13 +46,17 @@ class _DirectiveSpec: _PROHIBIT_PREFIX = "prohibit " _REMOVE_POLICY_PREFIX = "remove policy " _INSTEAD_OF_DELIMITER = " instead of " - -_MATCH_SET_PREMISE = re.compile(r"^set premise (?!to\b)\S(?:.*\S)?$") -_MATCH_CHANGE_PREMISE = re.compile(r"^change premise to \S(?:.*\S)?$") -_MATCH_USE_ITEM = re.compile(r"^use (?!instead of(?:\s|$))(?!.*\sinstead of(?:\s|$))\S(?:.*\S)?$") -_MATCH_PROHIBIT_ITEM = re.compile(r"^prohibit \S(?:.*\S)?$") -_MATCH_REMOVE_POLICY = re.compile(r"^remove policy \S(?:.*\S)?$") -_MATCH_REPLACE_USE = re.compile(r"^use \S(?:.*\S)? instead of \S(?:.*\S)?$") +_ASCII_WHITESPACE = " \t\n\r\x0b\x0c" +_HORIZONTAL_WHITESPACE = " \t" +_KEYWORD_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") +_SET_PREMISE_RE = re.compile(r"(?i)^set[ \t]+premise[ \t]+(?P.+)$") +_CHANGE_PREMISE_RE = re.compile(r"(?i)^change[ \t]+premise[ \t]+to[ \t]+(?P.+)$") +_USE_RE = re.compile(r"(?i)^use[ \t]+(?P.+)$") +_PROHIBIT_RE = re.compile(r"(?i)^prohibit[ \t]+(?P.+)$") +_REMOVE_POLICY_RE = re.compile(r"(?i)^remove[ \t]+policy[ \t]+(?P.+)$") +_REPLACE_RE = re.compile( + r"(?i)^use[ \t]+(?P.*?)[ \t]+instead[ \t]+of[ \t]+(?P.+)$" +) _CANONICAL_DIRECTIVE_STARTS: tuple[tuple[str, bool], ...] = ( (_CHANGE_PREMISE_PREFIX.removesuffix(" "), True), @@ -87,103 +96,140 @@ def _renderer(operands: MappingProxyType[str, str]) -> str: DirectiveKind.SET_PREMISE: _DirectiveSpec( kind=DirectiveKind.SET_PREMISE, operand_names=("value",), - matcher=_MATCH_SET_PREMISE, exact_text=None, renderer=_render_with_prefix(_SET_PREMISE_PREFIX, "value"), - canonical_starts=((_SET_PREMISE_PREFIX.removesuffix(" "), True),), ), DirectiveKind.CHANGE_PREMISE: _DirectiveSpec( kind=DirectiveKind.CHANGE_PREMISE, operand_names=("value",), - matcher=_MATCH_CHANGE_PREMISE, exact_text=None, renderer=_render_with_prefix(_CHANGE_PREMISE_PREFIX, "value"), - canonical_starts=((_CHANGE_PREMISE_PREFIX.removesuffix(" "), True),), ), DirectiveKind.USE_ITEM: _DirectiveSpec( kind=DirectiveKind.USE_ITEM, operand_names=("item",), - matcher=_MATCH_USE_ITEM, exact_text=None, renderer=_render_with_prefix(_USE_PREFIX, "item"), - canonical_starts=(("use", True),), ), DirectiveKind.PROHIBIT_ITEM: _DirectiveSpec( kind=DirectiveKind.PROHIBIT_ITEM, operand_names=("item",), - matcher=_MATCH_PROHIBIT_ITEM, exact_text=None, renderer=_render_with_prefix(_PROHIBIT_PREFIX, "item"), - canonical_starts=((_PROHIBIT_PREFIX.removesuffix(" "), True),), ), DirectiveKind.REMOVE_POLICY: _DirectiveSpec( kind=DirectiveKind.REMOVE_POLICY, operand_names=("item",), - matcher=_MATCH_REMOVE_POLICY, exact_text=None, renderer=_render_with_prefix(_REMOVE_POLICY_PREFIX, "item"), - canonical_starts=((_REMOVE_POLICY_PREFIX.removesuffix(" "), True),), ), DirectiveKind.REPLACE_USE: _DirectiveSpec( kind=DirectiveKind.REPLACE_USE, operand_names=("new_item", "old_item"), - matcher=_MATCH_REPLACE_USE, exact_text=None, renderer=_render_replace_use, - canonical_starts=(("use", True),), ), DirectiveKind.CLEAR_PREMISE: _DirectiveSpec( kind=DirectiveKind.CLEAR_PREMISE, operand_names=(), - matcher=None, exact_text="clear premise", renderer=_render_exact("clear premise"), - canonical_starts=(("clear premise", False),), ), DirectiveKind.RESET_POLICIES: _DirectiveSpec( kind=DirectiveKind.RESET_POLICIES, operand_names=(), - matcher=None, exact_text="reset policies", renderer=_render_exact("reset policies"), - canonical_starts=(("reset policies", False),), ), DirectiveKind.CLEAR_STATE: _DirectiveSpec( kind=DirectiveKind.CLEAR_STATE, operand_names=(), - matcher=None, exact_text="clear state", renderer=_render_exact("clear state"), - canonical_starts=(("clear state", False),), ), } ) +def _trim_ascii_whitespace(text: str) -> str: + return text.strip(_ASCII_WHITESPACE) + + +def _collapse_horizontal_whitespace(text: str) -> str: + parts = text.replace("\t", " ").split(" ") + return " ".join(part for part in parts if part != "") + + +def _normalized_for_matching(text: str) -> str: + return _collapse_horizontal_whitespace(_trim_ascii_whitespace(text)).casefold() + + +def _operand_has_content(value: str) -> bool: + return _trim_ascii_whitespace(value) != "" + + +def _operand_starts_with_token(value: str, token: str) -> bool: + normalized = _normalized_for_matching(value) + return normalized == token or normalized.startswith(f"{token} ") + + def _match_canonical_directive_start(text: str, start: int) -> int | None: if start < 0 or start >= len(text): return None - if start > 0 and text[start - 1].isalpha(): + if start > 0 and text[start - 1] in _KEYWORD_CHARS: return None for token, require_space_or_end in _CANONICAL_DIRECTIVE_STARTS: - if not text.startswith(token, start): - continue - end = start + len(token) - if end == len(text): - return end - next_char = text[end] - if require_space_or_end: - if next_char == " ": - return end - continue - if not next_char.isalpha(): + end = _match_directive_token(text, start, token, require_space_or_end=require_space_or_end) + if end is not None: return end return None +def _match_directive_token( + text: str, + start: int, + token: str, + *, + require_space_or_end: bool, +) -> int | None: + index = start + token_index = 0 + + while token_index < len(token): + if index >= len(text): + return None + + token_char = token[token_index] + if token_char == " ": + if text[index] not in _HORIZONTAL_WHITESPACE: + return None + while index < len(text) and text[index] in _HORIZONTAL_WHITESPACE: + index += 1 + token_index += 1 + continue + + if text[index].casefold() != token_char: + return None + index += 1 + token_index += 1 + + if index == len(text): + return index + + next_char = text[index] + if require_space_or_end: + if next_char in _HORIZONTAL_WHITESPACE: + return index + return None + + if next_char in _KEYWORD_CHARS: + return None + return index + + def _contains_multiple_canonical_directives(text: str) -> bool: first_start = _match_canonical_directive_start(text, 0) if first_start is None: @@ -197,24 +243,137 @@ def _contains_multiple_canonical_directives(text: str) -> bool: return False -def validate_directive(text: str) -> ValidatedDirective | None: - if text == "": +def _parse_replace_use(trimmed_text: str) -> _ParsedDirective | None: + match = _REPLACE_RE.fullmatch(trimmed_text) + if match is None: return None - if _contains_multiple_canonical_directives(text): + new_item = match.group("new_item") + old_item = match.group("old_item") + if not _operand_has_content(new_item) or not _operand_has_content(old_item): return None + if _INSTEAD_OF_DELIMITER in _normalized_for_matching( + new_item + ) or _INSTEAD_OF_DELIMITER in _normalized_for_matching(old_item): + return None + normalized_payload = _normalized_for_matching(trimmed_text) + if normalized_payload.count(_INSTEAD_OF_DELIMITER) != 1: + return None + return _ParsedDirective( + text=trimmed_text, + kind=DirectiveKind.REPLACE_USE, + operands=MappingProxyType({"new_item": new_item, "old_item": old_item}), + ) - for kind, spec in _DIRECTIVE_SPECS.items(): - if spec.exact_text is not None: - if text == spec.exact_text: - return ValidatedDirective(text=text, kind=kind) - continue - assert spec.matcher is not None - if spec.matcher.fullmatch(text): - return ValidatedDirective(text=text, kind=kind) + +def _parse_directive(text: str) -> _ParsedDirective | None: + trimmed_text = _trim_ascii_whitespace(text) + if trimmed_text == "": + return None + if _contains_multiple_canonical_directives(trimmed_text): + return None + + normalized = _normalized_for_matching(trimmed_text) + + if normalized == "clear premise": + return _ParsedDirective( + text=text, kind=DirectiveKind.CLEAR_PREMISE, operands=MappingProxyType({}) + ) + if normalized == "reset policies": + return _ParsedDirective( + text=text, + kind=DirectiveKind.RESET_POLICIES, + operands=MappingProxyType({}), + ) + if normalized == "clear state": + return _ParsedDirective( + text=text, kind=DirectiveKind.CLEAR_STATE, operands=MappingProxyType({}) + ) + + if normalized.startswith("set premise "): + match = _SET_PREMISE_RE.fullmatch(trimmed_text) + if match is None: + return None + value = match.group("value") + if not _operand_has_content(value) or _operand_starts_with_token(value, "to"): + return None + return _ParsedDirective( + text=text, + kind=DirectiveKind.SET_PREMISE, + operands=MappingProxyType({"value": value}), + ) + + if normalized.startswith("change premise to "): + match = _CHANGE_PREMISE_RE.fullmatch(trimmed_text) + if match is None: + return None + value = match.group("value") + if not _operand_has_content(value): + return None + return _ParsedDirective( + text=text, + kind=DirectiveKind.CHANGE_PREMISE, + operands=MappingProxyType({"value": value}), + ) + + replacement = _parse_replace_use(trimmed_text) + if replacement is not None: + return replacement + + if normalized.startswith("use "): + match = _USE_RE.fullmatch(trimmed_text) + if match is None: + return None + item = match.group("item") + normalized_item = _normalized_for_matching(item) + if ( + not _operand_has_content(item) + or normalized_item.startswith("instead of ") + or normalized_item.endswith(" instead of") + or _INSTEAD_OF_DELIMITER in normalized_item + ): + return None + return _ParsedDirective( + text=text, + kind=DirectiveKind.USE_ITEM, + operands=MappingProxyType({"item": item}), + ) + + if normalized.startswith("prohibit "): + match = _PROHIBIT_RE.fullmatch(trimmed_text) + if match is None: + return None + item = match.group("item") + if not _operand_has_content(item): + return None + return _ParsedDirective( + text=text, + kind=DirectiveKind.PROHIBIT_ITEM, + operands=MappingProxyType({"item": item}), + ) + + if normalized.startswith("remove policy "): + match = _REMOVE_POLICY_RE.fullmatch(trimmed_text) + if match is None: + return None + item = match.group("item") + if not _operand_has_content(item): + return None + return _ParsedDirective( + text=text, + kind=DirectiveKind.REMOVE_POLICY, + operands=MappingProxyType({"item": item}), + ) return None +def validate_directive(text: str) -> ValidatedDirective | None: + parsed = _parse_directive(text) + if parsed is None: + return None + return ValidatedDirective(text=parsed.text, kind=parsed.kind) + + def is_canonical_directive(text: str) -> bool: return validate_directive(text) is not None diff --git a/tests/fixtures/conformance/grammar/004_validate_change_premise.json b/tests/fixtures/conformance/grammar/004_validate_change_premise.json new file mode 100644 index 0000000..e30d6e7 --- /dev/null +++ b/tests/fixtures/conformance/grammar/004_validate_change_premise.json @@ -0,0 +1,14 @@ +{ + "id": "grammar_validate_change_premise", + "kind": "grammar", + "action": { + "fn": "validate_directive", + "text": "change premise to concise replies" + }, + "expected": { + "validated": { + "text": "change premise to concise replies", + "kind": "change_premise" + } + } +} diff --git a/tests/fixtures/conformance/grammar/005_validate_clear_state.json b/tests/fixtures/conformance/grammar/005_validate_clear_state.json new file mode 100644 index 0000000..84a2508 --- /dev/null +++ b/tests/fixtures/conformance/grammar/005_validate_clear_state.json @@ -0,0 +1,14 @@ +{ + "id": "grammar_validate_clear_state", + "kind": "grammar", + "action": { + "fn": "validate_directive", + "text": "clear state" + }, + "expected": { + "validated": { + "text": "clear state", + "kind": "clear_state" + } + } +} diff --git a/tests/fixtures/conformance/grammar/006_validate_keyword_case_normalization_use.json b/tests/fixtures/conformance/grammar/006_validate_keyword_case_normalization_use.json new file mode 100644 index 0000000..1062373 --- /dev/null +++ b/tests/fixtures/conformance/grammar/006_validate_keyword_case_normalization_use.json @@ -0,0 +1,14 @@ +{ + "id": "grammar_validate_keyword_case_normalization_use", + "kind": "grammar", + "action": { + "fn": "validate_directive", + "text": "Use Docker" + }, + "expected": { + "validated": { + "text": "Use Docker", + "kind": "use_item" + } + } +} diff --git a/tests/fixtures/conformance/grammar/007_validate_boundary_whitespace_trim_use.json b/tests/fixtures/conformance/grammar/007_validate_boundary_whitespace_trim_use.json new file mode 100644 index 0000000..eb821f7 --- /dev/null +++ b/tests/fixtures/conformance/grammar/007_validate_boundary_whitespace_trim_use.json @@ -0,0 +1,14 @@ +{ + "id": "grammar_validate_boundary_whitespace_trim_use", + "kind": "grammar", + "action": { + "fn": "validate_directive", + "text": " use docker " + }, + "expected": { + "validated": { + "text": " use docker ", + "kind": "use_item" + } + } +} diff --git a/tests/fixtures/conformance/grammar/008_validate_tab_separator_use.json b/tests/fixtures/conformance/grammar/008_validate_tab_separator_use.json new file mode 100644 index 0000000..633fd7d --- /dev/null +++ b/tests/fixtures/conformance/grammar/008_validate_tab_separator_use.json @@ -0,0 +1,14 @@ +{ + "id": "grammar_validate_tab_separator_use", + "kind": "grammar", + "action": { + "fn": "validate_directive", + "text": "use\tdocker" + }, + "expected": { + "validated": { + "text": "use\tdocker", + "kind": "use_item" + } + } +} diff --git a/tests/fixtures/conformance/grammar/009_validate_invalid_set_premise_to_rejected.json b/tests/fixtures/conformance/grammar/009_validate_invalid_set_premise_to_rejected.json new file mode 100644 index 0000000..09153fe --- /dev/null +++ b/tests/fixtures/conformance/grammar/009_validate_invalid_set_premise_to_rejected.json @@ -0,0 +1,11 @@ +{ + "id": "grammar_validate_invalid_set_premise_to_rejected", + "kind": "grammar", + "action": { + "fn": "validate_directive", + "text": "set premise to concise" + }, + "expected": { + "validated": null + } +} diff --git a/tests/fixtures/conformance/grammar/010_validate_invalid_missing_use_operand_rejected.json b/tests/fixtures/conformance/grammar/010_validate_invalid_missing_use_operand_rejected.json new file mode 100644 index 0000000..4c5a505 --- /dev/null +++ b/tests/fixtures/conformance/grammar/010_validate_invalid_missing_use_operand_rejected.json @@ -0,0 +1,11 @@ +{ + "id": "grammar_validate_invalid_missing_use_operand_rejected", + "kind": "grammar", + "action": { + "fn": "validate_directive", + "text": "use" + }, + "expected": { + "validated": null + } +} diff --git a/tests/fixtures/conformance/grammar/011_validate_invalid_replacement_missing_new_rejected.json b/tests/fixtures/conformance/grammar/011_validate_invalid_replacement_missing_new_rejected.json new file mode 100644 index 0000000..e3c3e10 --- /dev/null +++ b/tests/fixtures/conformance/grammar/011_validate_invalid_replacement_missing_new_rejected.json @@ -0,0 +1,11 @@ +{ + "id": "grammar_validate_invalid_replacement_missing_new_rejected", + "kind": "grammar", + "action": { + "fn": "validate_directive", + "text": "use instead of docker" + }, + "expected": { + "validated": null + } +} diff --git a/tests/fixtures/conformance/grammar/012_validate_invalid_replacement_missing_old_rejected.json b/tests/fixtures/conformance/grammar/012_validate_invalid_replacement_missing_old_rejected.json new file mode 100644 index 0000000..dcff464 --- /dev/null +++ b/tests/fixtures/conformance/grammar/012_validate_invalid_replacement_missing_old_rejected.json @@ -0,0 +1,11 @@ +{ + "id": "grammar_validate_invalid_replacement_missing_old_rejected", + "kind": "grammar", + "action": { + "fn": "validate_directive", + "text": "use podman instead of" + }, + "expected": { + "validated": null + } +} diff --git a/tests/fixtures/conformance/grammar/013_validate_invalid_compound_use_and_prohibit_rejected.json b/tests/fixtures/conformance/grammar/013_validate_invalid_compound_use_and_prohibit_rejected.json new file mode 100644 index 0000000..80de798 --- /dev/null +++ b/tests/fixtures/conformance/grammar/013_validate_invalid_compound_use_and_prohibit_rejected.json @@ -0,0 +1,11 @@ +{ + "id": "grammar_validate_invalid_compound_use_and_prohibit_rejected", + "kind": "grammar", + "action": { + "fn": "validate_directive", + "text": "use docker and prohibit peanuts" + }, + "expected": { + "validated": null + } +} diff --git a/tests/fixtures/conformance/grammar/014_validate_invalid_quoted_compound_rejected.json b/tests/fixtures/conformance/grammar/014_validate_invalid_quoted_compound_rejected.json new file mode 100644 index 0000000..1f0f2a0 --- /dev/null +++ b/tests/fixtures/conformance/grammar/014_validate_invalid_quoted_compound_rejected.json @@ -0,0 +1,11 @@ +{ + "id": "grammar_validate_invalid_quoted_compound_rejected", + "kind": "grammar", + "action": { + "fn": "validate_directive", + "text": "use \"docker and prohibit peanuts\"" + }, + "expected": { + "validated": null + } +} diff --git a/tests/fixtures/conformance/step/005_exact_prefix_passthrough_leading_space.json b/tests/fixtures/conformance/step/005_exact_prefix_passthrough_leading_space.json index aa37604..a28aba8 100644 --- a/tests/fixtures/conformance/step/005_exact_prefix_passthrough_leading_space.json +++ b/tests/fixtures/conformance/step/005_exact_prefix_passthrough_leading_space.json @@ -9,12 +9,16 @@ "input": " set premise concise", "expected": { "decision": { - "kind": "passthrough", + "kind": "update", "prompt_to_user": null, - "state": null + "state": { + "premise": "concise", + "policies": {}, + "version": 2 + } }, "state": { - "premise": null, + "premise": "concise", "policies": {}, "version": 2 } diff --git a/tests/fixtures/conformance/step/006_near_miss_set_premise_to.json b/tests/fixtures/conformance/step/006_near_miss_set_premise_to.json index b62c77a..ed0d243 100644 --- a/tests/fixtures/conformance/step/006_near_miss_set_premise_to.json +++ b/tests/fixtures/conformance/step/006_near_miss_set_premise_to.json @@ -9,16 +9,12 @@ "input": "set premise to concise", "expected": { "decision": { - "kind": "update", + "kind": "passthrough", "prompt_to_user": null, - "state": { - "premise": "to concise", - "policies": {}, - "version": 2 - } + "state": null }, "state": { - "premise": "to concise", + "premise": null, "policies": {}, "version": 2 } diff --git a/tests/fixtures/conformance/step/020_compound_use_and_prohibit_clarify.json b/tests/fixtures/conformance/step/020_compound_use_and_prohibit_clarify.json index 183d5f7..726502c 100644 --- a/tests/fixtures/conformance/step/020_compound_use_and_prohibit_clarify.json +++ b/tests/fixtures/conformance/step/020_compound_use_and_prohibit_clarify.json @@ -9,8 +9,8 @@ "input": "use docker and prohibit peanuts", "expected": { "decision": { - "kind": "clarify", - "prompt_to_user": "Multiple directives are not supported in one input.\nSubmit each directive separately.", + "kind": "passthrough", + "prompt_to_user": null, "state": null }, "state": { diff --git a/tests/fixtures/conformance/step/021_compound_set_premise_and_use_clarify.json b/tests/fixtures/conformance/step/021_compound_set_premise_and_use_clarify.json index 8079bb5..b5e2bbc 100644 --- a/tests/fixtures/conformance/step/021_compound_set_premise_and_use_clarify.json +++ b/tests/fixtures/conformance/step/021_compound_set_premise_and_use_clarify.json @@ -9,8 +9,8 @@ "input": "set premise vegetarian and use docker", "expected": { "decision": { - "kind": "clarify", - "prompt_to_user": "Multiple directives are not supported in one input.\nSubmit each directive separately.", + "kind": "passthrough", + "prompt_to_user": null, "state": null }, "state": { diff --git a/tests/fixtures/conformance/step/022_compound_clear_state_then_set_premise_clarify.json b/tests/fixtures/conformance/step/022_compound_clear_state_then_set_premise_clarify.json index 9d747ab..1aeab1c 100644 --- a/tests/fixtures/conformance/step/022_compound_clear_state_then_set_premise_clarify.json +++ b/tests/fixtures/conformance/step/022_compound_clear_state_then_set_premise_clarify.json @@ -11,8 +11,8 @@ "input": "clear state then set premise project", "expected": { "decision": { - "kind": "clarify", - "prompt_to_user": "Multiple directives are not supported in one input.\nSubmit each directive separately.", + "kind": "passthrough", + "prompt_to_user": null, "state": null }, "state": { diff --git a/tests/fixtures/conformance/step/023_compound_remove_policy_and_prohibit_clarify.json b/tests/fixtures/conformance/step/023_compound_remove_policy_and_prohibit_clarify.json index 3892579..cc49568 100644 --- a/tests/fixtures/conformance/step/023_compound_remove_policy_and_prohibit_clarify.json +++ b/tests/fixtures/conformance/step/023_compound_remove_policy_and_prohibit_clarify.json @@ -11,8 +11,8 @@ "input": "remove policy docker and prohibit peanuts", "expected": { "decision": { - "kind": "clarify", - "prompt_to_user": "Multiple directives are not supported in one input.\nSubmit each directive separately.", + "kind": "passthrough", + "prompt_to_user": null, "state": null }, "state": { diff --git a/tests/fixtures/conformance/step/024_compound_use_or_prohibit_clarify.json b/tests/fixtures/conformance/step/024_compound_use_or_prohibit_clarify.json index f6bd4c3..4d81c27 100644 --- a/tests/fixtures/conformance/step/024_compound_use_or_prohibit_clarify.json +++ b/tests/fixtures/conformance/step/024_compound_use_or_prohibit_clarify.json @@ -9,8 +9,8 @@ "input": "use docker or prohibit peanuts", "expected": { "decision": { - "kind": "clarify", - "prompt_to_user": "Multiple directives are not supported in one input.\nSubmit each directive separately.", + "kind": "passthrough", + "prompt_to_user": null, "state": null }, "state": { diff --git a/tests/fixtures/conformance/step/025_compound_use_xor_prohibit_clarify.json b/tests/fixtures/conformance/step/025_compound_use_xor_prohibit_clarify.json index 3d40125..bbec077 100644 --- a/tests/fixtures/conformance/step/025_compound_use_xor_prohibit_clarify.json +++ b/tests/fixtures/conformance/step/025_compound_use_xor_prohibit_clarify.json @@ -9,8 +9,8 @@ "input": "use docker xor prohibit peanuts", "expected": { "decision": { - "kind": "clarify", - "prompt_to_user": "Multiple directives are not supported in one input.\nSubmit each directive separately.", + "kind": "passthrough", + "prompt_to_user": null, "state": null }, "state": { diff --git a/tests/fixtures/conformance/step/026_compound_use_punctuation_prohibit_clarify.json b/tests/fixtures/conformance/step/026_compound_use_punctuation_prohibit_clarify.json index fd73dfd..74e5028 100644 --- a/tests/fixtures/conformance/step/026_compound_use_punctuation_prohibit_clarify.json +++ b/tests/fixtures/conformance/step/026_compound_use_punctuation_prohibit_clarify.json @@ -9,8 +9,8 @@ "input": "use docker. prohibit peanuts", "expected": { "decision": { - "kind": "clarify", - "prompt_to_user": "Multiple directives are not supported in one input.\nSubmit each directive separately.", + "kind": "passthrough", + "prompt_to_user": null, "state": null }, "state": { diff --git a/tests/fixtures/conformance/step/028_quoted_payload_compound_clarify.json b/tests/fixtures/conformance/step/028_quoted_payload_compound_clarify.json index 618c44c..8a534c4 100644 --- a/tests/fixtures/conformance/step/028_quoted_payload_compound_clarify.json +++ b/tests/fixtures/conformance/step/028_quoted_payload_compound_clarify.json @@ -9,8 +9,8 @@ "input": "use \"docker and prohibit peanuts\"", "expected": { "decision": { - "kind": "clarify", - "prompt_to_user": "Multiple directives are not supported in one input.\nSubmit each directive separately.", + "kind": "passthrough", + "prompt_to_user": null, "state": null }, "state": { diff --git a/tests/fixtures/conformance/step/029_change_premise_update.json b/tests/fixtures/conformance/step/029_change_premise_update.json new file mode 100644 index 0000000..81f3280 --- /dev/null +++ b/tests/fixtures/conformance/step/029_change_premise_update.json @@ -0,0 +1,26 @@ +{ + "id": "step_change_premise_update", + "kind": "step", + "initial_state": { + "premise": "verbose replies", + "policies": {}, + "version": 2 + }, + "input": "change premise to concise replies", + "expected": { + "decision": { + "kind": "update", + "prompt_to_user": null, + "state": { + "premise": "concise replies", + "policies": {}, + "version": 2 + } + }, + "state": { + "premise": "concise replies", + "policies": {}, + "version": 2 + } + } +} diff --git a/tests/fixtures/conformance/step/030_prohibit_item_update.json b/tests/fixtures/conformance/step/030_prohibit_item_update.json new file mode 100644 index 0000000..725111e --- /dev/null +++ b/tests/fixtures/conformance/step/030_prohibit_item_update.json @@ -0,0 +1,30 @@ +{ + "id": "step_prohibit_item_update", + "kind": "step", + "initial_state": { + "premise": null, + "policies": {}, + "version": 2 + }, + "input": "prohibit peanuts", + "expected": { + "decision": { + "kind": "update", + "prompt_to_user": null, + "state": { + "premise": null, + "policies": { + "peanuts": "prohibit" + }, + "version": 2 + } + }, + "state": { + "premise": null, + "policies": { + "peanuts": "prohibit" + }, + "version": 2 + } + } +} diff --git a/tests/fixtures/conformance/step/031_remove_policy_present_update.json b/tests/fixtures/conformance/step/031_remove_policy_present_update.json new file mode 100644 index 0000000..225ca75 --- /dev/null +++ b/tests/fixtures/conformance/step/031_remove_policy_present_update.json @@ -0,0 +1,28 @@ +{ + "id": "step_remove_policy_present_update", + "kind": "step", + "initial_state": { + "premise": null, + "policies": { + "docker": "use" + }, + "version": 2 + }, + "input": "remove policy docker", + "expected": { + "decision": { + "kind": "update", + "prompt_to_user": null, + "state": { + "premise": null, + "policies": {}, + "version": 2 + } + }, + "state": { + "premise": null, + "policies": {}, + "version": 2 + } + } +} diff --git a/tests/fixtures/conformance/step/032_replace_use_update.json b/tests/fixtures/conformance/step/032_replace_use_update.json new file mode 100644 index 0000000..2c6aeaa --- /dev/null +++ b/tests/fixtures/conformance/step/032_replace_use_update.json @@ -0,0 +1,32 @@ +{ + "id": "step_replace_use_update", + "kind": "step", + "initial_state": { + "premise": null, + "policies": { + "docker": "use" + }, + "version": 2 + }, + "input": "use podman instead of docker", + "expected": { + "decision": { + "kind": "update", + "prompt_to_user": null, + "state": { + "premise": null, + "policies": { + "podman": "use" + }, + "version": 2 + } + }, + "state": { + "premise": null, + "policies": { + "podman": "use" + }, + "version": 2 + } + } +} diff --git a/tests/fixtures/conformance/step/033_keyword_case_normalization_use_update.json b/tests/fixtures/conformance/step/033_keyword_case_normalization_use_update.json new file mode 100644 index 0000000..7e99ec0 --- /dev/null +++ b/tests/fixtures/conformance/step/033_keyword_case_normalization_use_update.json @@ -0,0 +1,30 @@ +{ + "id": "step_keyword_case_normalization_use_update", + "kind": "step", + "initial_state": { + "premise": null, + "policies": {}, + "version": 2 + }, + "input": "Use Docker", + "expected": { + "decision": { + "kind": "update", + "prompt_to_user": null, + "state": { + "premise": null, + "policies": { + "docker": "use" + }, + "version": 2 + } + }, + "state": { + "premise": null, + "policies": { + "docker": "use" + }, + "version": 2 + } + } +} diff --git a/tests/fixtures/conformance/step/034_boundary_whitespace_trim_use_update.json b/tests/fixtures/conformance/step/034_boundary_whitespace_trim_use_update.json new file mode 100644 index 0000000..83ac24d --- /dev/null +++ b/tests/fixtures/conformance/step/034_boundary_whitespace_trim_use_update.json @@ -0,0 +1,30 @@ +{ + "id": "step_boundary_whitespace_trim_use_update", + "kind": "step", + "initial_state": { + "premise": null, + "policies": {}, + "version": 2 + }, + "input": " use docker ", + "expected": { + "decision": { + "kind": "update", + "prompt_to_user": null, + "state": { + "premise": null, + "policies": { + "docker": "use" + }, + "version": 2 + } + }, + "state": { + "premise": null, + "policies": { + "docker": "use" + }, + "version": 2 + } + } +} diff --git a/tests/fixtures/conformance/step/035_tab_separator_use_update.json b/tests/fixtures/conformance/step/035_tab_separator_use_update.json new file mode 100644 index 0000000..57f5a8f --- /dev/null +++ b/tests/fixtures/conformance/step/035_tab_separator_use_update.json @@ -0,0 +1,30 @@ +{ + "id": "step_tab_separator_use_update", + "kind": "step", + "initial_state": { + "premise": null, + "policies": {}, + "version": 2 + }, + "input": "use\tdocker", + "expected": { + "decision": { + "kind": "update", + "prompt_to_user": null, + "state": { + "premise": null, + "policies": { + "docker": "use" + }, + "version": 2 + } + }, + "state": { + "premise": null, + "policies": { + "docker": "use" + }, + "version": 2 + } + } +} diff --git a/tests/fixtures/conformance/step/036_set_premise_existing_clarify.json b/tests/fixtures/conformance/step/036_set_premise_existing_clarify.json new file mode 100644 index 0000000..5a3395d --- /dev/null +++ b/tests/fixtures/conformance/step/036_set_premise_existing_clarify.json @@ -0,0 +1,22 @@ +{ + "id": "step_set_premise_existing_clarify", + "kind": "step", + "initial_state": { + "premise": "existing premise", + "policies": {}, + "version": 2 + }, + "input": "set premise concise replies", + "expected": { + "decision": { + "kind": "clarify", + "prompt_to_user": null, + "state": null + }, + "state": { + "premise": "existing premise", + "policies": {}, + "version": 2 + } + } +} diff --git a/tests/fixtures/conformance/step/037_change_premise_missing_clarify.json b/tests/fixtures/conformance/step/037_change_premise_missing_clarify.json new file mode 100644 index 0000000..29c4d1c --- /dev/null +++ b/tests/fixtures/conformance/step/037_change_premise_missing_clarify.json @@ -0,0 +1,22 @@ +{ + "id": "step_change_premise_missing_clarify", + "kind": "step", + "initial_state": { + "premise": null, + "policies": {}, + "version": 2 + }, + "input": "change premise to concise replies", + "expected": { + "decision": { + "kind": "clarify", + "prompt_to_user": null, + "state": null + }, + "state": { + "premise": null, + "policies": {}, + "version": 2 + } + } +} diff --git a/tests/fixtures/conformance/step/038_policy_identity_apostrophe_variant_clarify.json b/tests/fixtures/conformance/step/038_policy_identity_apostrophe_variant_clarify.json new file mode 100644 index 0000000..2b508d7 --- /dev/null +++ b/tests/fixtures/conformance/step/038_policy_identity_apostrophe_variant_clarify.json @@ -0,0 +1,27 @@ +{ + "id": "step_policy_identity_apostrophe_variant_clarify", + "kind": "step", + "initial_state": { + "premise": null, + "policies": {}, + "version": 2 + }, + "prelude": [ + "use don't" + ], + "input": "prohibit don’t", + "expected": { + "decision": { + "kind": "clarify", + "prompt_to_user": null, + "state": null + }, + "state": { + "premise": null, + "policies": { + "don't": "use" + }, + "version": 2 + } + } +} diff --git a/tests/fixtures/conformance/step/039_policy_identity_case_remove_policy_update.json b/tests/fixtures/conformance/step/039_policy_identity_case_remove_policy_update.json new file mode 100644 index 0000000..3a08571 --- /dev/null +++ b/tests/fixtures/conformance/step/039_policy_identity_case_remove_policy_update.json @@ -0,0 +1,28 @@ +{ + "id": "step_policy_identity_case_remove_policy_update", + "kind": "step", + "initial_state": { + "premise": null, + "policies": { + "docker": "use" + }, + "version": 2 + }, + "input": "remove policy Docker", + "expected": { + "decision": { + "kind": "update", + "prompt_to_user": null, + "state": { + "premise": null, + "policies": {}, + "version": 2 + } + }, + "state": { + "premise": null, + "policies": {}, + "version": 2 + } + } +} diff --git a/tests/fixtures/conformance/step/040_policy_identity_internal_whitespace_remove_policy_update.json b/tests/fixtures/conformance/step/040_policy_identity_internal_whitespace_remove_policy_update.json new file mode 100644 index 0000000..fdc0afa --- /dev/null +++ b/tests/fixtures/conformance/step/040_policy_identity_internal_whitespace_remove_policy_update.json @@ -0,0 +1,28 @@ +{ + "id": "step_policy_identity_internal_whitespace_remove_policy_update", + "kind": "step", + "initial_state": { + "premise": null, + "policies": { + "docker desktop": "use" + }, + "version": 2 + }, + "input": "remove policy docker desktop", + "expected": { + "decision": { + "kind": "update", + "prompt_to_user": null, + "state": { + "premise": null, + "policies": {}, + "version": 2 + } + }, + "state": { + "premise": null, + "policies": {}, + "version": 2 + } + } +} diff --git a/tests/fixtures/spec-targets/directive-grammar/041_policy_identity_no_article_removal_distinct_update.json b/tests/fixtures/spec-targets/directive-grammar/041_policy_identity_no_article_removal_distinct_update.json new file mode 100644 index 0000000..8a67a16 --- /dev/null +++ b/tests/fixtures/spec-targets/directive-grammar/041_policy_identity_no_article_removal_distinct_update.json @@ -0,0 +1,35 @@ +{ + "id": "step_policy_identity_no_article_removal_distinct_update", + "kind": "step", + "initial_state": { + "premise": null, + "policies": {}, + "version": 2 + }, + "prelude": [ + "use docker" + ], + "input": "prohibit the docker", + "expected": { + "decision": { + "kind": "update", + "prompt_to_user": null, + "state": { + "premise": null, + "policies": { + "docker": "use", + "the docker": "prohibit" + }, + "version": 2 + } + }, + "state": { + "premise": null, + "policies": { + "docker": "use", + "the docker": "prohibit" + }, + "version": 2 + } + } +} diff --git a/tests/fixtures/spec-targets/directive-grammar/042_policy_identity_no_dont_rewrite_distinct_update.json b/tests/fixtures/spec-targets/directive-grammar/042_policy_identity_no_dont_rewrite_distinct_update.json new file mode 100644 index 0000000..5da2fb5 --- /dev/null +++ b/tests/fixtures/spec-targets/directive-grammar/042_policy_identity_no_dont_rewrite_distinct_update.json @@ -0,0 +1,35 @@ +{ + "id": "step_policy_identity_no_dont_rewrite_distinct_update", + "kind": "step", + "initial_state": { + "premise": null, + "policies": {}, + "version": 2 + }, + "prelude": [ + "use don't" + ], + "input": "prohibit dont", + "expected": { + "decision": { + "kind": "update", + "prompt_to_user": null, + "state": { + "premise": null, + "policies": { + "don't": "use", + "dont": "prohibit" + }, + "version": 2 + } + }, + "state": { + "premise": null, + "policies": { + "don't": "use", + "dont": "prohibit" + }, + "version": 2 + } + } +} diff --git a/tests/test_04_grammar_edge_cases.py b/tests/test_04_grammar_edge_cases.py index ee1eef4..3bbb928 100644 --- a/tests/test_04_grammar_edge_cases.py +++ b/tests/test_04_grammar_edge_cases.py @@ -1,13 +1,17 @@ from context_compiler import create_engine -def test_parser_requires_exact_prefix_without_leading_space() -> None: +def test_parser_trims_leading_space_for_canonical_directive() -> None: engine = create_engine() decision = engine.step(" set premise concise") - assert decision["kind"] == "passthrough" - assert engine.state == {"premise": None, "policies": {}, "version": 2} + assert decision == { + "kind": "update", + "state": {"premise": "concise", "policies": {}, "version": 2}, + "prompt_to_user": None, + } + assert engine.state == {"premise": "concise", "policies": {}, "version": 2} def test_parser_does_not_accept_conversational_aliases() -> None: @@ -29,31 +33,16 @@ def test_parser_does_not_accept_conversational_aliases() -> None: assert engine.state == {"premise": None, "policies": {}, "version": 2} -def test_empty_policy_payloads_and_incomplete_replacement_clarify() -> None: +def test_empty_policy_payloads_and_incomplete_replacement_remain_passthrough() -> None: engine = create_engine() before = engine.state - use_empty = "Policy item cannot be empty.\nUse 'use ' with a non-empty value." - prohibit_empty = "Policy item cannot be empty.\nUse 'prohibit ' with a non-empty value." - replacement_incomplete = ( - "Replacement requires both new and old items.\n" - "Use 'use instead of ' with non-empty values." - ) - for text in ["use", "use ", "use "]: - assert engine.step(text) == { - "kind": "clarify", - "state": None, - "prompt_to_user": use_empty, - } + assert engine.step(text) == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state == before for text in ["prohibit", "prohibit ", "prohibit "]: - assert engine.step(text) == { - "kind": "clarify", - "state": None, - "prompt_to_user": prohibit_empty, - } + assert engine.step(text) == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state == before for text in [ @@ -63,61 +52,48 @@ def test_empty_policy_payloads_and_incomplete_replacement_clarify() -> None: "use instead of y", "use instead of y", ]: - assert engine.step(text) == { - "kind": "clarify", - "state": None, - "prompt_to_user": replacement_incomplete, - } + assert engine.step(text) == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state == before - assert engine.step("remove policy\tdocker")["kind"] == "passthrough" + assert engine.step("remove policy\tdocker")["kind"] == "update" assert engine.state == before -def test_exact_match_near_misses_are_passthrough() -> None: +def test_lexical_normalization_and_non_directive_near_misses() -> None: engine = create_engine() - inputs = [ - "clear premise ", - "reset policies ", - "clear state ", - "remove policy\tdocker", - "Use docker", - "don't Use docker", - "use\tdocker", - "don't use", - ] - for text in inputs: - assert engine.step(text)["kind"] == "passthrough" + assert engine.step("clear premise ")["kind"] == "update" + assert engine.step("reset policies ")["kind"] == "update" + assert engine.step("clear state ")["kind"] == "update" + assert engine.step("remove policy\tdocker")["kind"] == "update" + assert engine.step("Use docker")["kind"] == "update" + assert engine.step("use\tdocker")["kind"] == "update" + assert engine.step("don't Use docker")["kind"] == "passthrough" + assert engine.step("don't use")["kind"] == "passthrough" - assert engine.state == {"premise": None, "policies": {}, "version": 2} + assert engine.state == {"premise": None, "policies": {"docker": "use"}, "version": 2} -def test_premise_to_variant_near_misses_do_not_trigger_repair() -> None: +def test_premise_to_variant_near_misses_remain_passthrough() -> None: engine = create_engine() before = engine.state set_variant = engine.step("set premise to concise") change_variant = engine.step("change premise concise") - assert set_variant == { - "kind": "update", - "state": {"premise": "to concise", "policies": {}, "version": 2}, - "prompt_to_user": None, - } + assert set_variant == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert change_variant == {"kind": "passthrough", "state": None, "prompt_to_user": None} - assert before == {"premise": None, "policies": {}, "version": 2} + assert before == engine.state -def test_remove_policy_missing_or_whitespace_payload_clarifies() -> None: +def test_remove_policy_missing_or_whitespace_payload_remains_passthrough() -> None: engine = create_engine() before = engine.state first = engine.step("remove policy") second = engine.step("remove policy ") - expected = "Policy item cannot be empty.\nUse 'remove policy ' with a non-empty value." - assert first == {"kind": "clarify", "state": None, "prompt_to_user": expected} - assert second == {"kind": "clarify", "state": None, "prompt_to_user": expected} + assert first == {"kind": "passthrough", "state": None, "prompt_to_user": None} + assert second == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state == before diff --git a/tests/test_compound_directive_properties.py b/tests/test_compound_directive_properties.py index 274faa5..88c6c7d 100644 --- a/tests/test_compound_directive_properties.py +++ b/tests/test_compound_directive_properties.py @@ -5,10 +5,6 @@ from context_compiler import create_engine -COMPOUND_DIRECTIVE_PROMPT = ( - "Multiple directives are not supported in one input.\nSubmit each directive separately." -) - CANONICAL_SECOND_DIRECTIVES = [ "set premise concise", "change premise to concise", @@ -36,17 +32,13 @@ LETTER_CHARS = string.ascii_lowercase -def _assert_compound_rejection(user_input: str) -> None: +def _assert_compound_passthrough(user_input: str) -> None: engine = create_engine() before = engine.state decision = engine.step(user_input) - assert decision == { - "kind": "clarify", - "state": None, - "prompt_to_user": COMPOUND_DIRECTIVE_PROMPT, - } + assert decision == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state == before assert engine.has_pending_clarification() is False @@ -57,7 +49,7 @@ def _assert_compound_rejection(user_input: str) -> None: second=st.sampled_from(CANONICAL_SECOND_DIRECTIVES), ) def test_compound_separator_robustness(separator: str, second: str) -> None: - _assert_compound_rejection(f"use docker{separator}{second}") + _assert_compound_passthrough(f"use docker{separator}{second}") @settings(max_examples=50) @@ -74,7 +66,7 @@ def test_compound_arbitrary_intervening_text(chunks: list[str], second: str) -> lowered = intervening.lower() assume(all(token not in lowered for token in CANONICAL_STARTS)) - _assert_compound_rejection(f"use docker {intervening} {second}") + _assert_compound_passthrough(f"use docker {intervening} {second}") @settings(max_examples=50) @@ -91,7 +83,7 @@ def test_embedded_canonical_tokens_do_not_trigger_compound_detection( decision = engine.step(f"use docker {prefix}{token}{suffix}") - assert decision["prompt_to_user"] != COMPOUND_DIRECTIVE_PROMPT + assert decision["prompt_to_user"] is not None or decision["kind"] != "clarify" assert decision["kind"] == "update" assert engine.state != before assert engine.has_pending_clarification() is False @@ -135,9 +127,8 @@ def test_case_mutated_second_directive_does_not_trigger_compound_detection( decision = engine.step(f"use docker {second_start}") - assert decision["prompt_to_user"] != COMPOUND_DIRECTIVE_PROMPT - assert decision["kind"] == "update" - assert engine.state != before + assert decision == {"kind": "passthrough", "state": None, "prompt_to_user": None} + assert engine.state == before assert engine.has_pending_clarification() is False @@ -155,7 +146,7 @@ def test_case_mutated_second_directive_does_not_trigger_compound_detection( def test_quotes_do_not_create_protected_region_after_first_directive( quote: str, payload: str, closing: str, second: str ) -> None: - _assert_compound_rejection(f"use {quote}{payload}{closing} {second}") + _assert_compound_passthrough(f"use {quote}{payload}{closing} {second}") @settings(max_examples=20) diff --git a/tests/test_engine.py b/tests/test_engine.py index 9e6f414..0ddf7c6 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -3,7 +3,15 @@ import pytest from context_compiler import create_engine, get_policy_items, get_premise_value -from context_compiler.engine import DecisionKind, Engine, _normalize_confirmation +from context_compiler.engine import ( + Action, + DecisionKind, + Engine, + _contains_compound_directive, + _match_canonical_directive_start, + _normalize_confirmation, + _parse_directive, +) pytestmark = pytest.mark.contract @@ -19,6 +27,101 @@ def test_decision_kind_strenum_behavior() -> None: assert DecisionKind(kind.value) is kind +def test_parse_directive_delegates_canonical_kinds_to_existing_actions() -> None: + assert _parse_directive("set premise concise replies") == Action( + kind="set_premise", value="concise replies" + ) + assert _parse_directive("change premise to concise replies") == Action( + kind="change_premise", value="concise replies" + ) + assert _parse_directive("use docker") == Action(kind="use_item", item="docker") + assert _parse_directive("prohibit peanuts") == Action(kind="prohibit_item", item="peanuts") + assert _parse_directive("remove policy docker") == Action( + kind="remove_policy_item", item="docker" + ) + assert _parse_directive("use podman instead of docker") == Action( + kind="replace_use", new_item="podman", old_item="docker" + ) + assert _parse_directive("clear premise") == Action(kind="clear_premise") + assert _parse_directive("reset policies") == Action(kind="reset_policies") + assert _parse_directive("clear state") == Action(kind="clear_state") + + +def test_parse_directive_returns_none_for_invalid_syntax_and_passthrough_inputs() -> None: + assert _parse_directive("set premise") is None + assert _parse_directive("change premise to") is None + assert _parse_directive("use") is None + assert _parse_directive("prohibit") is None + assert _parse_directive("remove policy") is None + assert _parse_directive("use instead of docker") is None + assert _parse_directive("use docker and prohibit peanuts") is None + assert _parse_directive("hello there") is None + + +def test_pre_mutation_clarify_legacy_invalid_action_branches_remain_stable() -> None: + engine = create_engine() + + assert engine._pre_mutation_clarify(Action(kind="compound_directive_invalid")) == { + "kind": "clarify", + "state": None, + "prompt_to_user": COMPOUND_DIRECTIVE_PROMPT, + } + assert engine._pre_mutation_clarify(Action(kind="set_premise", value="")) == { + "kind": "clarify", + "state": None, + "prompt_to_user": ( + "Premise value cannot be empty.\nUse 'set premise ' with a non-empty value." + ), + } + assert engine._pre_mutation_clarify(Action(kind="change_premise", value="")) == { + "kind": "clarify", + "state": None, + "prompt_to_user": ( + "Premise value cannot be empty.\n" + "Use 'change premise to ' with a non-empty value." + ), + } + assert engine._pre_mutation_clarify(Action(kind="remove_policy_item", item="")) == { + "kind": "clarify", + "state": None, + "prompt_to_user": ( + "Policy item cannot be empty.\nUse 'remove policy ' with a non-empty value." + ), + } + assert engine._pre_mutation_clarify(Action(kind="use_item", item="")) == { + "kind": "clarify", + "state": None, + "prompt_to_user": "Policy item cannot be empty.\nUse 'use ' with a non-empty value.", + } + assert engine._pre_mutation_clarify(Action(kind="prohibit_item", item="")) == { + "kind": "clarify", + "state": None, + "prompt_to_user": ( + "Policy item cannot be empty.\nUse 'prohibit ' with a non-empty value." + ), + } + assert engine._pre_mutation_clarify(Action(kind="replace_use_incomplete")) == { + "kind": "clarify", + "state": None, + "prompt_to_user": ( + "Replacement requires both new and old items.\n" + "Use 'use instead of ' with non-empty values." + ), + } + + +def test_legacy_compound_detection_helpers_remain_unchanged() -> None: + assert _contains_compound_directive("use docker and prohibit peanuts") is True + assert _contains_compound_directive("hello there") is False + assert _contains_compound_directive("use docker") is False + assert _match_canonical_directive_start("use docker", -1) is None + assert _match_canonical_directive_start("use docker", len("use docker")) is None + assert _match_canonical_directive_start("abuse docker", 1) is None + assert _match_canonical_directive_start("use", 0) == len("use") + assert _match_canonical_directive_start("use docker", 0) == len("use") + assert _match_canonical_directive_start("clear premise!", 0) == len("clear premise") + + def test_initial_state_and_helpers() -> None: engine = create_engine() assert engine.state == {"premise": None, "policies": {}, "version": 2} @@ -686,7 +789,6 @@ def test_non_matching_input_is_passthrough() -> None: "set X", "no use docker", "don't use docker", - " prohibit docker", ]: decision = engine.step(text) assert decision == {"kind": "passthrough", "state": None, "prompt_to_user": None} @@ -694,15 +796,15 @@ def test_non_matching_input_is_passthrough() -> None: assert engine.state == before -def test_admin_command_near_misses_are_passthrough() -> None: +def test_lexical_normalization_accepts_canonical_directives() -> None: engine = create_engine() - before = engine.state - for text in ["clear premise ", " reset policies", "clear state\t", " remove policy docker"]: - decision = engine.step(text) - assert decision == {"kind": "passthrough", "state": None, "prompt_to_user": None} - - assert engine.state == before + assert engine.step("clear premise ")["kind"] == "update" + assert engine.step(" reset policies")["kind"] == "update" + assert engine.step("clear state\t")["kind"] == "update" + assert engine.step("Use docker")["kind"] == "update" + assert engine.step("use\tdocker")["kind"] == "update" + assert engine.step(" prohibit docker")["kind"] == "clarify" def test_clear_premise_is_idempotent_update_when_already_null() -> None: @@ -740,57 +842,37 @@ def test_set_premise_lifecycle_rules() -> None: assert engine.state == before -def test_set_premise_empty_payload_clarifies_without_mutation() -> None: +def test_set_premise_empty_payload_remains_passthrough() -> None: engine = create_engine() before = engine.state d1 = engine.step("set premise") - assert d1 == { - "kind": "clarify", - "state": None, - "prompt_to_user": ( - "Premise value cannot be empty.\nUse 'set premise ' with a non-empty value." - ), - } + assert d1 == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state == before -def test_set_premise_whitespace_payload_clarifies_without_mutation() -> None: +def test_set_premise_whitespace_payload_remains_passthrough() -> None: engine = create_engine() before = engine.state d1 = engine.step("set premise ") - assert d1 == { - "kind": "clarify", - "state": None, - "prompt_to_user": ( - "Premise value cannot be empty.\nUse 'set premise ' with a non-empty value." - ), - } + assert d1 == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state == before -def test_set_premise_to_variant_is_treated_as_literal_canonical_set() -> None: +def test_set_premise_to_variant_remains_passthrough() -> None: engine = create_engine() decision = engine.step("set premise to concise replies") - assert decision == { - "kind": "update", - "state": {"premise": "to concise replies", "policies": {}, "version": 2}, - "prompt_to_user": None, - } - assert engine.state == {"premise": "to concise replies", "policies": {}, "version": 2} + assert decision == {"kind": "passthrough", "state": None, "prompt_to_user": None} + assert engine.state == {"premise": None, "policies": {}, "version": 2} -def test_set_premise_to_with_whitespace_payload_falls_through_to_literal_set_behavior() -> None: +def test_set_premise_to_with_whitespace_payload_remains_passthrough() -> None: engine = create_engine() decision = engine.step("set premise to ") - assert decision == { - "kind": "update", - "state": {"premise": "to", "policies": {}, "version": 2}, - "prompt_to_user": None, - } - assert engine.state == {"premise": "to", "policies": {}, "version": 2} + assert decision == {"kind": "passthrough", "state": None, "prompt_to_user": None} + assert engine.state == {"premise": None, "policies": {}, "version": 2} def test_change_premise_requires_existing_premise() -> None: @@ -810,24 +892,17 @@ def test_change_premise_requires_existing_premise() -> None: assert engine.state["premise"] == "second" -def test_change_premise_to_empty_payload_clarifies_without_mutation() -> None: +def test_change_premise_to_empty_payload_remains_passthrough() -> None: engine = create_engine() engine.step("set premise baseline") before = engine.state d1 = engine.step("change premise to") - assert d1 == { - "kind": "clarify", - "state": None, - "prompt_to_user": ( - "Premise value cannot be empty.\n" - "Use 'change premise to ' with a non-empty value." - ), - } + assert d1 == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state == before -def test_change_premise_to_without_space_payload_remains_passthrough_before_empty_clarify() -> None: +def test_change_premise_to_without_space_payload_and_empty_variant_remain_passthrough() -> None: engine = create_engine() engine.step("set premise baseline") before = engine.state @@ -836,31 +911,17 @@ def test_change_premise_to_without_space_payload_remains_passthrough_before_empt assert near_miss == {"kind": "passthrough", "state": None, "prompt_to_user": None} decision = engine.step("change premise to") - assert decision == { - "kind": "clarify", - "state": None, - "prompt_to_user": ( - "Premise value cannot be empty.\n" - "Use 'change premise to ' with a non-empty value." - ), - } + assert decision == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state == before -def test_change_premise_to_whitespace_payload_clarifies_without_mutation() -> None: +def test_change_premise_to_whitespace_payload_remains_passthrough() -> None: engine = create_engine() engine.step("set premise baseline") before = engine.state d1 = engine.step("change premise to ") - assert d1 == { - "kind": "clarify", - "state": None, - "prompt_to_user": ( - "Premise value cannot be empty.\n" - "Use 'change premise to ' with a non-empty value." - ), - } + assert d1 == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state == before @@ -939,43 +1000,29 @@ def test_policy_directives_and_idempotent_update() -> None: assert engine2.state["policies"] == {"docker": "prohibit"} -def test_use_empty_payload_clarifies_without_mutation() -> None: +def test_use_empty_payload_remains_passthrough() -> None: engine = create_engine() before = engine.state - expected = "Policy item cannot be empty.\nUse 'use ' with a non-empty value." for text in ["use", "use ", "use "]: decision = engine.step(text) - assert decision == { - "kind": "clarify", - "state": None, - "prompt_to_user": expected, - } + assert decision == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state == before -def test_prohibit_empty_payload_clarifies_without_mutation() -> None: +def test_prohibit_empty_payload_remains_passthrough() -> None: engine = create_engine() before = engine.state - expected = "Policy item cannot be empty.\nUse 'prohibit ' with a non-empty value." for text in ["prohibit", "prohibit ", "prohibit "]: decision = engine.step(text) - assert decision == { - "kind": "clarify", - "state": None, - "prompt_to_user": expected, - } + assert decision == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state == before -def test_replace_use_incomplete_payload_clarifies_without_mutation() -> None: +def test_replace_use_incomplete_payload_remains_passthrough() -> None: engine = create_engine() before = engine.state - expected = ( - "Replacement requires both new and old items.\n" - "Use 'use instead of ' with non-empty values." - ) for text in [ "use x instead of", @@ -985,11 +1032,7 @@ def test_replace_use_incomplete_payload_clarifies_without_mutation() -> None: "use instead of y", ]: decision = engine.step(text) - assert decision == { - "kind": "clarify", - "state": None, - "prompt_to_user": expected, - } + assert decision == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state == before @@ -1036,35 +1079,23 @@ def test_remove_policy_missing_item_is_idempotent_update() -> None: assert engine.state == before -def test_remove_policy_empty_payload_clarifies_without_mutation() -> None: +def test_remove_policy_empty_payload_remains_passthrough() -> None: engine = create_engine() before = engine.state decision = engine.step("remove policy") - assert decision == { - "kind": "clarify", - "state": None, - "prompt_to_user": ( - "Policy item cannot be empty.\nUse 'remove policy ' with a non-empty value." - ), - } + assert decision == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state == before -def test_remove_policy_whitespace_payload_clarifies_without_mutation() -> None: +def test_remove_policy_whitespace_payload_remains_passthrough() -> None: engine = create_engine() before = engine.state decision = engine.step("remove policy ") - assert decision == { - "kind": "clarify", - "state": None, - "prompt_to_user": ( - "Policy item cannot be empty.\nUse 'remove policy ' with a non-empty value." - ), - } + assert decision == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state == before @@ -1504,7 +1535,7 @@ def test_remove_policy_uses_normalized_item_matching() -> None: ), ], ) -def test_compound_directives_clarify_without_mutation( +def test_compound_directives_remain_passthrough_without_mutation( user_input: str, initial_state: dict[str, object] ) -> None: engine = create_engine(state=initial_state) @@ -1512,11 +1543,7 @@ def test_compound_directives_clarify_without_mutation( decision = engine.step(user_input) - assert decision == { - "kind": "clarify", - "state": None, - "prompt_to_user": COMPOUND_DIRECTIVE_PROMPT, - } + assert decision == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state == before assert engine.has_pending_clarification() is False @@ -1672,7 +1699,7 @@ def test_all_canonical_directive_starts_remain_single_directive_when_valid( ) -def test_pending_confirmation_takes_precedence_over_compound_detection() -> None: +def test_compound_passthrough_after_prior_replacement_clarify() -> None: engine = create_engine() first = engine.step("use kubectl instead of docker") assert first == { @@ -1686,11 +1713,7 @@ def test_pending_confirmation_takes_precedence_over_compound_detection() -> None decision = engine.step("use docker and prohibit peanuts") - assert decision == { - "kind": "clarify", - "state": None, - "prompt_to_user": COMPOUND_DIRECTIVE_PROMPT, - } + assert decision == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state == {"premise": None, "policies": {}, "version": 2} assert engine.has_pending_clarification() is False diff --git a/tests/test_grammar.py b/tests/test_grammar.py index ca57944..47d40ad 100644 --- a/tests/test_grammar.py +++ b/tests/test_grammar.py @@ -76,8 +76,6 @@ def test_validate_directive_accepts_each_canonical_family( [ "", "hello there", - " set premise concise", - "Use docker", "use", "prohibit", "remove policy", @@ -96,6 +94,21 @@ def test_validate_directive_rejects_non_canonical_inputs(text: str) -> None: assert is_canonical_directive(text) is False +@pytest.mark.parametrize( + ("text", "expected_kind"), + [ + (" set premise concise", DirectiveKind.SET_PREMISE), + ("Use docker", DirectiveKind.USE_ITEM), + ("use\tdocker", DirectiveKind.USE_ITEM), + ], +) +def test_validate_directive_accepts_lexically_normalized_canonical_input( + text: str, expected_kind: DirectiveKind +) -> None: + validated = validate_directive(text) + assert validated == ValidatedDirective(text=text, kind=expected_kind) + + @pytest.mark.parametrize( ("kind", "operands", "expected"), [ @@ -215,3 +228,105 @@ def test_validate_directive_rejects_near_miss_without_required_delimiter() -> No def test_render_directive_rejects_non_string_operands() -> None: with pytest.raises(ValueError, match="must be a string"): render_directive(DirectiveKind.SET_PREMISE, value=123) # type: ignore[arg-type] + + +def test_internal_match_directive_token_rejects_truncated_and_non_whitespace_separator() -> None: + assert ( + grammar_module._match_directive_token( + "use", + 0, + "use ", + require_space_or_end=True, + ) + is None + ) + assert ( + grammar_module._match_directive_token( + "set-premise concise", + 0, + "set premise", + require_space_or_end=True, + ) + is None + ) + + +def test_parse_replace_use_rejects_blank_new_item() -> None: + assert grammar_module._parse_replace_use("use \t instead of docker") is None + + +def test_parse_replace_use_rejects_embedded_delimiter_in_old_item() -> None: + assert ( + grammar_module._parse_replace_use("use podman instead of docker instead of nerdctl") is None + ) + + +def test_parse_replace_use_rejects_non_canonical_normalized_delimiter_count( + monkeypatch: pytest.MonkeyPatch, +) -> None: + original = grammar_module._normalized_for_matching + + def _patched(value: str) -> str: + if value == "use podman instead of docker": + return "use podman rather than docker" + return original(value) + + monkeypatch.setattr(grammar_module, "_normalized_for_matching", _patched) + + assert grammar_module._parse_replace_use("use podman instead of docker") is None + + +class _FakeMatch: + def __init__(self, groups: dict[str, str]) -> None: + self._groups = groups + + def group(self, name: str) -> str: + return self._groups[name] + + +class _FakePattern: + def __init__(self, match: _FakeMatch | None) -> None: + self._match = match + + def fullmatch(self, text: str) -> _FakeMatch | None: + del text + return self._match + + +@pytest.mark.parametrize( + ("pattern_name", "text"), + [ + ("_SET_PREMISE_RE", "set premise concise"), + ("_CHANGE_PREMISE_RE", "change premise to concise"), + ("_USE_RE", "use docker"), + ("_PROHIBIT_RE", "prohibit docker"), + ("_REMOVE_POLICY_RE", "remove policy docker"), + ], +) +def test_validate_directive_defensively_rejects_when_branch_regex_match_is_missing( + monkeypatch: pytest.MonkeyPatch, + pattern_name: str, + text: str, +) -> None: + monkeypatch.setattr(grammar_module, pattern_name, _FakePattern(None)) + + assert validate_directive(text) is None + + +@pytest.mark.parametrize( + ("pattern_name", "text", "groups"), + [ + ("_CHANGE_PREMISE_RE", "change premise to concise", {"value": " \t "}), + ("_PROHIBIT_RE", "prohibit docker", {"item": " \t "}), + ("_REMOVE_POLICY_RE", "remove policy docker", {"item": " \t "}), + ], +) +def test_validate_directive_defensively_rejects_whitespace_only_operands_after_match( + monkeypatch: pytest.MonkeyPatch, + pattern_name: str, + text: str, + groups: dict[str, str], +) -> None: + monkeypatch.setattr(grammar_module, pattern_name, _FakePattern(_FakeMatch(groups))) + + assert validate_directive(text) is None diff --git a/tests/test_properties.py b/tests/test_properties.py index a42d639..59939e0 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -6,6 +6,7 @@ from context_compiler import create_engine from context_compiler.engine import _CANONICAL_DIRECTIVE_STARTS, State +from context_compiler.grammar import validate_directive def _run_sequence(inputs: list[str]) -> State: @@ -56,6 +57,7 @@ def test_idempotent_use_item_is_update_and_stable_state(item: str) -> None: assume(not item.endswith(" instead of")) assume(_normalize_item_like_engine(item) != "") assume(not _contains_canonical_start_fragment(item)) + assume(validate_directive(f"use {item}") is not None) engine = create_engine() d1 = engine.step(f"use {item}") d2 = engine.step(f"use {item}") @@ -91,6 +93,7 @@ def test_use_item_with_empty_normalized_payload_clarifies_without_mutation( def test_idempotent_prohibit_item_is_update_and_stable_state(item: str) -> None: assume(_normalize_item_like_engine(item) != "") assume(not _contains_canonical_start_fragment(item)) + assume(validate_directive(f"prohibit {item}") is not None) engine = create_engine() d1 = engine.step(f"prohibit {item}") d2 = engine.step(f"prohibit {item}") @@ -148,6 +151,8 @@ def test_passthrough_sequence_preserves_state_and_decision_kind(inputs: list[str @given(st.text(min_size=1, max_size=30)) def test_contradiction_use_after_prohibit_always_clarifies(item: str) -> None: assume(not _contains_canonical_start_fragment(item)) + assume(validate_directive(f"prohibit {item}") is not None) + assume(validate_directive(f"use {item}") is not None) engine = create_engine() engine.step(f"prohibit {item}") before = engine.state @@ -163,6 +168,8 @@ def test_contradiction_prohibit_after_use_always_clarifies(item: str) -> None: assume(not item.startswith("instead of ")) assume(not item.endswith(" instead of")) assume(not _contains_canonical_start_fragment(item)) + assume(validate_directive(f"use {item}") is not None) + assume(validate_directive(f"prohibit {item}") is not None) engine = create_engine() engine.step(f"use {item}") before = engine.state diff --git a/tests/test_repl.py b/tests/test_repl.py index dea5f08..c290841 100644 --- a/tests/test_repl.py +++ b/tests/test_repl.py @@ -1035,36 +1035,18 @@ def test_repl_invalid_directive_near_misses_remain_passthrough() -> None: assert lines == ["passthrough", "passthrough", "passthrough"] -def test_repl_empty_policy_payloads_and_incomplete_replacement_render_errors() -> None: +def test_repl_empty_policy_payloads_and_incomplete_replacement_remain_passthrough() -> None: lines = _run_non_interactive_lines( "use\nprohibit \nuse x instead of\nuse instead of y\nquit\n" ) - assert _contains_subsequence( - lines, - [ - "error: Policy item cannot be empty.", - "Use 'use ' with a non-empty value.", - ], - ) - assert _contains_subsequence( - lines, - [ - "error: Policy item cannot be empty.", - "Use 'prohibit ' with a non-empty value.", - ], - ) - assert lines.count("error: Replacement requires both new and old items.") == 2 - assert lines.count("Use 'use instead of ' with non-empty values.") == 2 + assert lines == ["passthrough", "passthrough", "passthrough", "passthrough"] -def test_repl_premise_to_variant_near_misses_do_not_render_repair_suggestions() -> None: +def test_repl_premise_to_variant_near_misses_remain_passthrough() -> None: lines = _run_non_interactive_lines( "set premise to concise replies\nchange premise concise replies\nquit\n" ) - assert _contains_subsequence( - lines, ["updated", "premise: to concise replies", "policies: (none)"] - ) - assert lines[-1] == "passthrough" + assert lines == ["passthrough", "passthrough"] def test_repl_non_interactive_remove_policy_flow() -> None: @@ -1100,26 +1082,14 @@ def test_repl_change_premise_without_existing_premise_renders_exact_error() -> N ) -def test_repl_set_premise_empty_payload_renders_exact_error() -> None: +def test_repl_set_premise_empty_payload_remains_passthrough() -> None: lines = _run_non_interactive_lines("set premise\nquit\n") - assert _contains_subsequence( - lines, - [ - "error: Premise value cannot be empty.", - "Use 'set premise ' with a non-empty value.", - ], - ) + assert lines == ["passthrough"] -def test_repl_change_premise_empty_payload_renders_exact_error() -> None: +def test_repl_change_premise_empty_payload_remains_passthrough() -> None: lines = _run_non_interactive_lines("change premise to\nquit\n") - assert _contains_subsequence( - lines, - [ - "error: Premise value cannot be empty.", - "Use 'change premise to ' with a non-empty value.", - ], - ) + assert lines == ["passthrough"] def test_repl_use_item_when_prohibited_renders_exact_error() -> None: @@ -1210,15 +1180,13 @@ def test_repl_prohibited_replacement_followup_tokens_remain_passthrough() -> Non def test_repl_premise_lifecycle_outputs_expected_state_shape() -> None: lines = _run_non_interactive_lines( - "set premise Use concise answers\n" - "set premise Use verbose answers\n" - "change premise to Use verbose answers\n" + "set premise concise answers\n" + "set premise verbose answers\n" + "change premise to verbose answers\n" "quit\n" ) - assert _contains_subsequence( - lines, ["updated", "premise: Use concise answers", "policies: (none)"] - ) + assert _contains_subsequence(lines, ["updated", "premise: concise answers", "policies: (none)"]) assert _contains_subsequence( lines, [ @@ -1226,9 +1194,7 @@ def test_repl_premise_lifecycle_outputs_expected_state_shape() -> None: "Use 'change premise to ' to modify it.", ], ) - assert _contains_subsequence( - lines, ["updated", "premise: Use verbose answers", "policies: (none)"] - ) + assert _contains_subsequence(lines, ["updated", "premise: verbose answers", "policies: (none)"]) def test_repl_interactive_renders_updated_state_blocks_for_multiple_operations() -> None: