From 254f1f4b86f113540a44804a2ce1e4b6cdf7e5b5 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Wed, 12 Aug 2026 02:15:54 -0400 Subject: [PATCH 1/5] feat: add structured grammar failure details --- src/context_compiler/grammar.py | 148 +++++++++++++++--- tests/_api_contract_harness.py | 25 +++ .../conformance/api/public-grammar-v1.json | 11 ++ .../grammar_decompose_compound_rejected.json | 5 +- ...id_compound_use_and_prohibit_rejected.json | 5 +- ..._invalid_missing_use_operand_rejected.json | 7 +- ...pose_invalid_quoted_compound_rejected.json | 5 +- ...alid_replacement_missing_new_rejected.json | 7 +- ...alid_replacement_missing_old_rejected.json | 7 +- ...mpose_invalid_set_premise_to_rejected.json | 6 +- tests/test_fixtures.py | 12 +- tests/test_grammar.py | 94 +++++++++-- 12 files changed, 292 insertions(+), 40 deletions(-) diff --git a/src/context_compiler/grammar.py b/src/context_compiler/grammar.py index 0804044..919045d 100644 --- a/src/context_compiler/grammar.py +++ b/src/context_compiler/grammar.py @@ -21,6 +21,14 @@ class _DirectiveKind(StrEnum): CLEAR_STATE = "clear_state" +class _DirectiveSyntaxFailure(StrEnum): + """Enumerate minimal grammar failure categories for directive-shaped input.""" + + COMPOUND_DIRECTIVE = "compound_directive" + MISSING_REQUIRED_OPERAND = "missing_required_operand" + MALFORMED_DIRECTIVE = "malformed_directive" + + @dataclass(frozen=True, slots=True) class CanonicalDirective: """Represent one parsed canonical directive and its named operands. @@ -38,6 +46,10 @@ class CanonicalDirective: class InvalidDirectiveSyntax: """Represent directive-shaped input that fails canonical syntax parsing.""" + failure: _DirectiveSyntaxFailure = _DirectiveSyntaxFailure.MALFORMED_DIRECTIVE + directive_kind: _DirectiveKind | None = None + missing_operand: str | None = None + @dataclass(frozen=True, slots=True) class _DirectiveSpec: @@ -310,6 +322,19 @@ def _parse_replace_use(trimmed_text: str) -> CanonicalDirective | None: ) +def _invalid_directive_syntax( + failure: _DirectiveSyntaxFailure, + *, + directive_kind: _DirectiveKind | None = None, + missing_operand: str | None = None, +) -> InvalidDirectiveSyntax: + return InvalidDirectiveSyntax( + failure=failure, + directive_kind=directive_kind, + missing_operand=missing_operand, + ) + + def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSyntax | None: """Parse one canonical directive into its semantic kind and operands. @@ -326,9 +351,7 @@ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSynta if not _starts_with_directive_family(trimmed_text): return None if _contains_multiple_canonical_directives(trimmed_text): - return InvalidDirectiveSyntax() - - invalid_result = InvalidDirectiveSyntax() + return _invalid_directive_syntax(_DirectiveSyntaxFailure.COMPOUND_DIRECTIVE) normalized = _normalized_for_matching(trimmed_text) @@ -347,26 +370,54 @@ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSynta text=text, kind=_DirectiveKind.CLEAR_STATE, operands=MappingProxyType({}) ) + if normalized == "set premise": + return _invalid_directive_syntax( + _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=_DirectiveKind.SET_PREMISE, + missing_operand="value", + ) + if normalized.startswith("set premise "): match = _SET_PREMISE_RE.fullmatch(trimmed_text) if match is None: - return invalid_result + return _invalid_directive_syntax( + _DirectiveSyntaxFailure.MALFORMED_DIRECTIVE, + directive_kind=_DirectiveKind.SET_PREMISE, + ) value = match.group("value") if not _operand_has_content(value) or _operand_starts_with_token(value, "to"): - return invalid_result + return _invalid_directive_syntax( + _DirectiveSyntaxFailure.MALFORMED_DIRECTIVE, + directive_kind=_DirectiveKind.SET_PREMISE, + ) return CanonicalDirective( text=text, kind=_DirectiveKind.SET_PREMISE, operands=MappingProxyType({"value": value}), ) + if normalized == "change premise to": + return _invalid_directive_syntax( + _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=_DirectiveKind.CHANGE_PREMISE, + missing_operand="value", + ) + if normalized.startswith("change premise to "): match = _CHANGE_PREMISE_RE.fullmatch(trimmed_text) if match is None: - return invalid_result + return _invalid_directive_syntax( + _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=_DirectiveKind.CHANGE_PREMISE, + missing_operand="value", + ) value = match.group("value") if not _operand_has_content(value): - return invalid_result + return _invalid_directive_syntax( + _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=_DirectiveKind.CHANGE_PREMISE, + missing_operand="value", + ) return CanonicalDirective( text=text, kind=_DirectiveKind.CHANGE_PREMISE, @@ -377,52 +428,109 @@ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSynta if replacement is not None: return replacement + if normalized == "use": + return _invalid_directive_syntax( + _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=_DirectiveKind.USE_ITEM, + missing_operand="item", + ) + if normalized.startswith("use "): match = _USE_RE.fullmatch(trimmed_text) if match is None: - return invalid_result + return _invalid_directive_syntax( + _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=_DirectiveKind.USE_ITEM, + missing_operand="item", + ) 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 invalid_result + if not _operand_has_content(item): + return _invalid_directive_syntax( + _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=_DirectiveKind.USE_ITEM, + missing_operand="item", + ) + if normalized_item == "instead of" or normalized_item.startswith("instead of "): + return _invalid_directive_syntax( + _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=_DirectiveKind.REPLACE_USE, + missing_operand="new_item", + ) + if normalized_item.endswith(" instead of"): + return _invalid_directive_syntax( + _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=_DirectiveKind.REPLACE_USE, + missing_operand="old_item", + ) + if _INSTEAD_OF_DELIMITER in normalized_item: + return _invalid_directive_syntax( + _DirectiveSyntaxFailure.MALFORMED_DIRECTIVE, + directive_kind=_DirectiveKind.USE_ITEM, + ) return CanonicalDirective( text=text, kind=_DirectiveKind.USE_ITEM, operands=MappingProxyType({"item": item}), ) + if normalized == "prohibit": + return _invalid_directive_syntax( + _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=_DirectiveKind.PROHIBIT_ITEM, + missing_operand="item", + ) + if normalized.startswith("prohibit "): match = _PROHIBIT_RE.fullmatch(trimmed_text) if match is None: - return invalid_result + return _invalid_directive_syntax( + _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=_DirectiveKind.PROHIBIT_ITEM, + missing_operand="item", + ) item = match.group("item") if not _operand_has_content(item): - return invalid_result + return _invalid_directive_syntax( + _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=_DirectiveKind.PROHIBIT_ITEM, + missing_operand="item", + ) return CanonicalDirective( text=text, kind=_DirectiveKind.PROHIBIT_ITEM, operands=MappingProxyType({"item": item}), ) + if normalized == "remove policy": + return _invalid_directive_syntax( + _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=_DirectiveKind.REMOVE_POLICY, + missing_operand="item", + ) + if normalized.startswith("remove policy "): match = _REMOVE_POLICY_RE.fullmatch(trimmed_text) if match is None: - return invalid_result + return _invalid_directive_syntax( + _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=_DirectiveKind.REMOVE_POLICY, + missing_operand="item", + ) item = match.group("item") if not _operand_has_content(item): - return invalid_result + return _invalid_directive_syntax( + _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=_DirectiveKind.REMOVE_POLICY, + missing_operand="item", + ) return CanonicalDirective( text=text, kind=_DirectiveKind.REMOVE_POLICY, operands=MappingProxyType({"item": item}), ) - return invalid_result + return _invalid_directive_syntax(_DirectiveSyntaxFailure.MALFORMED_DIRECTIVE) def _render_directive(kind: _DirectiveKind | str, /, **operands: str) -> str: diff --git a/tests/_api_contract_harness.py b/tests/_api_contract_harness.py index 7557038..5a61dc6 100644 --- a/tests/_api_contract_harness.py +++ b/tests/_api_contract_harness.py @@ -68,6 +68,18 @@ def assert_shape( assert value == grammar.decompose_directive(shape["text"]) return + if "kind" in shape and shape["kind"] == "invalid_directive_syntax": + assert value == grammar.InvalidDirectiveSyntax( + failure=grammar._DirectiveSyntaxFailure(shape["failure"]), + directive_kind=( + None + if shape.get("directive_kind") is None + else grammar._DirectiveKind(shape["directive_kind"]) + ), + missing_operand=shape.get("missing_operand"), + ) + return + expected_types = shape["type"] if isinstance(expected_types, str): expected_types = [expected_types] @@ -375,6 +387,19 @@ def _validate_shape_spec(shape: object, label: str) -> None: for operand_name, operand_value in shape["operands"].items(): _assert_type(operand_value, str, f"{label}.operands[{operand_name!r}]") return + if kind == "invalid_directive_syntax": + _assert_closed_keys( + shape, + {"kind", "failure", "directive_kind", "missing_operand"}, + label, + ) + _require_fields(shape, {"kind", "failure"}, label) + _assert_type(shape["failure"], str, f"{label}.failure") + if "directive_kind" in shape and shape["directive_kind"] is not None: + _assert_type(shape["directive_kind"], str, f"{label}.directive_kind") + if "missing_operand" in shape and shape["missing_operand"] is not None: + _assert_type(shape["missing_operand"], str, f"{label}.missing_operand") + return raise AssertionError(f"{label}.kind has unsupported shape kind {kind!r}") if not has_type: diff --git a/tests/fixtures/conformance/api/public-grammar-v1.json b/tests/fixtures/conformance/api/public-grammar-v1.json index 72a52c0..c9763a7 100644 --- a/tests/fixtures/conformance/api/public-grammar-v1.json +++ b/tests/fixtures/conformance/api/public-grammar-v1.json @@ -47,6 +47,17 @@ "return_shape": { "type": "null" } + }, + { + "kwargs": { + "text": "use docker and prohibit peanuts" + }, + "return_shape": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive", + "directive_kind": null, + "missing_operand": null + } } ] } diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_compound_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_compound_rejected.json index cc77118..8bf3cca 100644 --- a/tests/fixtures/conformance/grammar/grammar_decompose_compound_rejected.json +++ b/tests/fixtures/conformance/grammar/grammar_decompose_compound_rejected.json @@ -6,6 +6,9 @@ "text": "use docker and prohibit peanuts" }, "expected": { - "directive": null + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive" + } } } diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_invalid_compound_use_and_prohibit_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_invalid_compound_use_and_prohibit_rejected.json index bd06aad..38c9a01 100644 --- a/tests/fixtures/conformance/grammar/grammar_decompose_invalid_compound_use_and_prohibit_rejected.json +++ b/tests/fixtures/conformance/grammar/grammar_decompose_invalid_compound_use_and_prohibit_rejected.json @@ -6,6 +6,9 @@ "text": "use docker and prohibit peanuts" }, "expected": { - "directive": null + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive" + } } } diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_invalid_missing_use_operand_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_invalid_missing_use_operand_rejected.json index 7e2390b..b3949ac 100644 --- a/tests/fixtures/conformance/grammar/grammar_decompose_invalid_missing_use_operand_rejected.json +++ b/tests/fixtures/conformance/grammar/grammar_decompose_invalid_missing_use_operand_rejected.json @@ -6,6 +6,11 @@ "text": "use" }, "expected": { - "directive": null + "directive": { + "kind": "invalid_directive_syntax", + "failure": "missing_required_operand", + "directive_kind": "use_item", + "missing_operand": "item" + } } } diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_invalid_quoted_compound_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_invalid_quoted_compound_rejected.json index 5c50618..d981d56 100644 --- a/tests/fixtures/conformance/grammar/grammar_decompose_invalid_quoted_compound_rejected.json +++ b/tests/fixtures/conformance/grammar/grammar_decompose_invalid_quoted_compound_rejected.json @@ -6,6 +6,9 @@ "text": "use \"docker and prohibit peanuts\"" }, "expected": { - "directive": null + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive" + } } } diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_invalid_replacement_missing_new_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_invalid_replacement_missing_new_rejected.json index 86f916b..57d8b73 100644 --- a/tests/fixtures/conformance/grammar/grammar_decompose_invalid_replacement_missing_new_rejected.json +++ b/tests/fixtures/conformance/grammar/grammar_decompose_invalid_replacement_missing_new_rejected.json @@ -6,6 +6,11 @@ "text": "use instead of docker" }, "expected": { - "directive": null + "directive": { + "kind": "invalid_directive_syntax", + "failure": "missing_required_operand", + "directive_kind": "replace_use", + "missing_operand": "new_item" + } } } diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_invalid_replacement_missing_old_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_invalid_replacement_missing_old_rejected.json index 6454cd8..1405a9e 100644 --- a/tests/fixtures/conformance/grammar/grammar_decompose_invalid_replacement_missing_old_rejected.json +++ b/tests/fixtures/conformance/grammar/grammar_decompose_invalid_replacement_missing_old_rejected.json @@ -6,6 +6,11 @@ "text": "use podman instead of" }, "expected": { - "directive": null + "directive": { + "kind": "invalid_directive_syntax", + "failure": "missing_required_operand", + "directive_kind": "replace_use", + "missing_operand": "old_item" + } } } diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_invalid_set_premise_to_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_invalid_set_premise_to_rejected.json index 6ada6f0..5f903f4 100644 --- a/tests/fixtures/conformance/grammar/grammar_decompose_invalid_set_premise_to_rejected.json +++ b/tests/fixtures/conformance/grammar/grammar_decompose_invalid_set_premise_to_rejected.json @@ -6,6 +6,10 @@ "text": "set premise to concise" }, "expected": { - "directive": null + "directive": { + "kind": "invalid_directive_syntax", + "failure": "malformed_directive", + "directive_kind": "set_premise" + } } } diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index 85119bb..390b259 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -238,7 +238,17 @@ def test_grammar_fixtures() -> None: directive = decompose_directive(action["text"]) expected_directive = expected["directive"] if expected_directive is None: - assert not isinstance(directive, CanonicalDirective), fixture_id + assert directive is None, fixture_id + elif expected_directive.get("kind") == "invalid_directive_syntax": + assert isinstance(directive, grammar_module.InvalidDirectiveSyntax), fixture_id + assert directive.failure.value == expected_directive["failure"], fixture_id + expected_kind = expected_directive.get("directive_kind") + assert ( + None if directive.directive_kind is None else directive.directive_kind.value + ) == expected_kind, fixture_id + assert directive.missing_operand == expected_directive.get("missing_operand"), ( + fixture_id + ) else: assert isinstance(directive, CanonicalDirective), fixture_id assert directive.text == expected_directive["text"], fixture_id diff --git a/tests/test_grammar.py b/tests/test_grammar.py index 2f75c66..c9bd3a1 100644 --- a/tests/test_grammar.py +++ b/tests/test_grammar.py @@ -8,6 +8,7 @@ CanonicalDirective, InvalidDirectiveSyntax, _DirectiveKind, + _DirectiveSyntaxFailure, decompose_directive, ) @@ -92,21 +93,79 @@ def test_decompose_directive_returns_none_when_no_directive_is_present(text: str @pytest.mark.parametrize( - "text", + ("text", "expected"), [ - "use", - "prohibit", - "remove policy", - "use x instead of", - "use instead of y", - "set premise to concise", - "change premise concise", - "use docker and prohibit peanuts", - "clear state then set premise project", + ( + "use", + InvalidDirectiveSyntax( + failure=_DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=_DirectiveKind.USE_ITEM, + missing_operand="item", + ), + ), + ( + "prohibit", + InvalidDirectiveSyntax( + failure=_DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=_DirectiveKind.PROHIBIT_ITEM, + missing_operand="item", + ), + ), + ( + "remove policy", + InvalidDirectiveSyntax( + failure=_DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=_DirectiveKind.REMOVE_POLICY, + missing_operand="item", + ), + ), + ( + "use x instead of", + InvalidDirectiveSyntax( + failure=_DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=_DirectiveKind.REPLACE_USE, + missing_operand="old_item", + ), + ), + ( + "use instead of y", + InvalidDirectiveSyntax( + failure=_DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=_DirectiveKind.REPLACE_USE, + missing_operand="new_item", + ), + ), + ( + "set premise to concise", + InvalidDirectiveSyntax( + failure=_DirectiveSyntaxFailure.MALFORMED_DIRECTIVE, + directive_kind=_DirectiveKind.SET_PREMISE, + ), + ), + ( + "change premise concise", + InvalidDirectiveSyntax( + failure=_DirectiveSyntaxFailure.MALFORMED_DIRECTIVE, + ), + ), + ( + "use docker and prohibit peanuts", + InvalidDirectiveSyntax( + failure=_DirectiveSyntaxFailure.COMPOUND_DIRECTIVE, + ), + ), + ( + "clear state then set premise project", + InvalidDirectiveSyntax( + failure=_DirectiveSyntaxFailure.COMPOUND_DIRECTIVE, + ), + ), ], ) -def test_decompose_directive_marks_invalid_directive_syntax(text: str) -> None: - assert isinstance(decompose_directive(text), InvalidDirectiveSyntax) +def test_decompose_directive_marks_invalid_directive_syntax( + text: str, expected: InvalidDirectiveSyntax +) -> None: + assert decompose_directive(text) == expected @pytest.mark.parametrize( @@ -293,6 +352,17 @@ def test_render_directive_rejects_when_decompose_directive_returns_noncanonical_ grammar_module._render_directive(_DirectiveKind.USE_ITEM, item="docker") +def test_invalid_directive_syntax_is_frozen_and_slotted() -> None: + invalid = InvalidDirectiveSyntax( + failure=_DirectiveSyntaxFailure.MALFORMED_DIRECTIVE, + directive_kind=_DirectiveKind.SET_PREMISE, + ) + + assert invalid.__slots__ == ("failure", "directive_kind", "missing_operand") + with pytest.raises(FrozenInstanceError): + invalid.failure = _DirectiveSyntaxFailure.COMPOUND_DIRECTIVE # type: ignore[misc] + + def test_decompose_directive_returns_canonical_operands_for_use_item() -> None: parsed = decompose_directive("use docker") From bcf940c87c92f2bc2e31fbf21ec628bacc3a3f73 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Wed, 12 Aug 2026 02:22:11 -0400 Subject: [PATCH 2/5] refactor: expose grammar classification types --- docs/api-reference.md | 5 + src/context_compiler/engine.py | 38 ++--- src/context_compiler/grammar.py | 152 ++++++++--------- tests/_api_contract_harness.py | 4 +- .../conformance/api/public-grammar-v1.json | 8 + tests/test_engine.py | 42 ++--- tests/test_grammar.py | 153 ++++++++++-------- tests/test_properties.py | 24 +-- tests/test_public_grammar_root_exports.py | 4 +- 9 files changed, 233 insertions(+), 197 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 6bfe1ba..336e749 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -73,6 +73,8 @@ submodule. Public grammar surface: +- `DirectiveKind` +- `DirectiveSyntaxFailure` - `CanonicalDirective` - `InvalidDirectiveSyntax` - `decompose_directive(text)` @@ -89,6 +91,9 @@ Boundary notes: - `decompose_directive(...)` returns `InvalidDirectiveSyntax` for directive-shaped input that is not valid canonical syntax - `decompose_directive(...)` returns `None` when no directive is present +- `CanonicalDirective.kind` uses `DirectiveKind` +- `InvalidDirectiveSyntax.failure` uses `DirectiveSyntaxFailure` +- `InvalidDirectiveSyntax.directive_kind`, when present, uses `DirectiveKind` - `CanonicalDirective.text` preserves the original accepted input text, so caller casing or formatting may remain visible there - `CanonicalDirective.text` is not canonical serialized directive text diff --git a/src/context_compiler/engine.py b/src/context_compiler/engine.py index c326dcc..4581e3f 100644 --- a/src/context_compiler/engine.py +++ b/src/context_compiler/engine.py @@ -19,7 +19,7 @@ STATE_PREMISE, STATE_VERSION, ) -from .grammar import CanonicalDirective, _DirectiveKind, decompose_directive +from .grammar import CanonicalDirective, DirectiveKind, decompose_directive PolicyValue = Literal["use", "prohibit"] @@ -129,10 +129,10 @@ def _pre_mutation_error( ) -> Decision | None: candidate_state = self._state if state is None else state # Single error path: all error outcomes are detected before any mutation. - if directive.kind in {_DirectiveKind.SET_PREMISE, _DirectiveKind.CHANGE_PREMISE}: + if directive.kind in {DirectiveKind.SET_PREMISE, DirectiveKind.CHANGE_PREMISE}: value = directive.operands["value"] if _sanitize_premise_value(value) == "": - if directive.kind is _DirectiveKind.SET_PREMISE: + if directive.kind is DirectiveKind.SET_PREMISE: return _error( "Premise value cannot be empty.\n" "Use 'set premise ' with a non-empty value." @@ -143,7 +143,7 @@ def _pre_mutation_error( ) if ( - directive.kind is _DirectiveKind.REMOVE_POLICY + directive.kind is DirectiveKind.REMOVE_POLICY and _normalize_item(directive.operands["item"]) == "" ): return _error( @@ -151,13 +151,13 @@ def _pre_mutation_error( ) if ( - directive.kind is _DirectiveKind.USE_ITEM + directive.kind is DirectiveKind.USE_ITEM and _normalize_item(directive.operands["item"]) == "" ): return _error("Policy item cannot be empty.\nUse 'use ' with a non-empty value.") if ( - directive.kind is _DirectiveKind.PROHIBIT_ITEM + directive.kind is DirectiveKind.PROHIBIT_ITEM and _normalize_item(directive.operands["item"]) == "" ): return _error( @@ -165,25 +165,25 @@ def _pre_mutation_error( ) if ( - directive.kind is _DirectiveKind.SET_PREMISE + directive.kind is DirectiveKind.SET_PREMISE and candidate_state[STATE_PREMISE] is not None ): return _error("Premise already set.\nUse 'change premise to ' to modify it.") if ( - directive.kind is _DirectiveKind.CHANGE_PREMISE + directive.kind is DirectiveKind.CHANGE_PREMISE and candidate_state[STATE_PREMISE] is None ): return _error("No premise is set.\nUse 'set premise ' to define one.") - if directive.kind is _DirectiveKind.USE_ITEM: + if directive.kind is DirectiveKind.USE_ITEM: item_key = _normalize_item(directive.operands["item"]) if candidate_state[STATE_POLICIES].get(item_key) == POLICY_PROHIBIT: return _error( f'"{item_key}" is currently prohibited.\nRemove or replace it before using it.' ) - if directive.kind is _DirectiveKind.PROHIBIT_ITEM: + if directive.kind is DirectiveKind.PROHIBIT_ITEM: item_key = _normalize_item(directive.operands["item"]) if candidate_state[STATE_POLICIES].get(item_key) == POLICY_USE: return _error( @@ -191,7 +191,7 @@ def _pre_mutation_error( "Remove or replace it before prohibiting it." ) - if directive.kind is _DirectiveKind.REPLACE_USE: + if directive.kind is DirectiveKind.REPLACE_USE: new_item = directive.operands["new_item"] old_item = directive.operands["old_item"] new_key = _normalize_item(new_item) @@ -222,27 +222,27 @@ def _pre_mutation_error( def _apply_directive(self, directive: CanonicalDirective, *, state: _State) -> _State: next_state = deepcopy(state) - if directive.kind is _DirectiveKind.SET_PREMISE: + if directive.kind is DirectiveKind.SET_PREMISE: next_state[STATE_PREMISE] = _sanitize_premise_value(directive.operands["value"]) return next_state - if directive.kind is _DirectiveKind.CHANGE_PREMISE: + if directive.kind is DirectiveKind.CHANGE_PREMISE: next_state[STATE_PREMISE] = _sanitize_premise_value(directive.operands["value"]) return next_state - if directive.kind is _DirectiveKind.USE_ITEM: + if directive.kind is DirectiveKind.USE_ITEM: item_key = _normalize_item(directive.operands["item"]) # Idempotent directives are updates even if state does not change. next_state[STATE_POLICIES][item_key] = POLICY_USE return next_state - if directive.kind is _DirectiveKind.PROHIBIT_ITEM: + if directive.kind is DirectiveKind.PROHIBIT_ITEM: item_key = _normalize_item(directive.operands["item"]) # Idempotent directives are updates even if state does not change. next_state[STATE_POLICIES][item_key] = POLICY_PROHIBIT return next_state - if directive.kind is _DirectiveKind.REPLACE_USE: + if directive.kind is DirectiveKind.REPLACE_USE: self._apply_replacement_explicit( next_state, directive.operands["new_item"], @@ -250,16 +250,16 @@ def _apply_directive(self, directive: CanonicalDirective, *, state: _State) -> _ ) return next_state - if directive.kind is _DirectiveKind.REMOVE_POLICY: + if directive.kind is DirectiveKind.REMOVE_POLICY: item_key = _normalize_item(directive.operands["item"]) next_state[STATE_POLICIES].pop(item_key, None) return next_state - if directive.kind is _DirectiveKind.CLEAR_PREMISE: + if directive.kind is DirectiveKind.CLEAR_PREMISE: next_state[STATE_PREMISE] = None return next_state - if directive.kind is _DirectiveKind.RESET_POLICIES: + if directive.kind is DirectiveKind.RESET_POLICIES: next_state[STATE_POLICIES] = {} return next_state diff --git a/src/context_compiler/grammar.py b/src/context_compiler/grammar.py index 919045d..86fc230 100644 --- a/src/context_compiler/grammar.py +++ b/src/context_compiler/grammar.py @@ -7,7 +7,7 @@ from types import MappingProxyType -class _DirectiveKind(StrEnum): +class DirectiveKind(StrEnum): """Enumerate the supported canonical directive families.""" SET_PREMISE = "set_premise" @@ -21,7 +21,7 @@ class _DirectiveKind(StrEnum): CLEAR_STATE = "clear_state" -class _DirectiveSyntaxFailure(StrEnum): +class DirectiveSyntaxFailure(StrEnum): """Enumerate minimal grammar failure categories for directive-shaped input.""" COMPOUND_DIRECTIVE = "compound_directive" @@ -38,7 +38,7 @@ class CanonicalDirective: """ text: str - kind: _DirectiveKind + kind: DirectiveKind operands: MappingProxyType[str, str] @@ -46,14 +46,14 @@ class CanonicalDirective: class InvalidDirectiveSyntax: """Represent directive-shaped input that fails canonical syntax parsing.""" - failure: _DirectiveSyntaxFailure = _DirectiveSyntaxFailure.MALFORMED_DIRECTIVE - directive_kind: _DirectiveKind | None = None + failure: DirectiveSyntaxFailure = DirectiveSyntaxFailure.MALFORMED_DIRECTIVE + directive_kind: DirectiveKind | None = None missing_operand: str | None = None @dataclass(frozen=True, slots=True) class _DirectiveSpec: - kind: _DirectiveKind + kind: DirectiveKind operand_names: tuple[str, ...] exact_text: str | None renderer: Callable[[MappingProxyType[str, str]], str] @@ -138,56 +138,56 @@ def _renderer(operands: MappingProxyType[str, str]) -> str: _DIRECTIVE_SPECS = MappingProxyType( { - _DirectiveKind.SET_PREMISE: _DirectiveSpec( - kind=_DirectiveKind.SET_PREMISE, + DirectiveKind.SET_PREMISE: _DirectiveSpec( + kind=DirectiveKind.SET_PREMISE, operand_names=("value",), exact_text=None, renderer=_render_with_prefix(_SET_PREMISE_PREFIX, "value"), ), - _DirectiveKind.CHANGE_PREMISE: _DirectiveSpec( - kind=_DirectiveKind.CHANGE_PREMISE, + DirectiveKind.CHANGE_PREMISE: _DirectiveSpec( + kind=DirectiveKind.CHANGE_PREMISE, operand_names=("value",), exact_text=None, renderer=_render_with_prefix(_CHANGE_PREMISE_PREFIX, "value"), ), - _DirectiveKind.USE_ITEM: _DirectiveSpec( - kind=_DirectiveKind.USE_ITEM, + DirectiveKind.USE_ITEM: _DirectiveSpec( + kind=DirectiveKind.USE_ITEM, operand_names=("item",), exact_text=None, renderer=_render_with_prefix(_USE_PREFIX, "item"), ), - _DirectiveKind.PROHIBIT_ITEM: _DirectiveSpec( - kind=_DirectiveKind.PROHIBIT_ITEM, + DirectiveKind.PROHIBIT_ITEM: _DirectiveSpec( + kind=DirectiveKind.PROHIBIT_ITEM, operand_names=("item",), exact_text=None, renderer=_render_with_prefix(_PROHIBIT_PREFIX, "item"), ), - _DirectiveKind.REMOVE_POLICY: _DirectiveSpec( - kind=_DirectiveKind.REMOVE_POLICY, + DirectiveKind.REMOVE_POLICY: _DirectiveSpec( + kind=DirectiveKind.REMOVE_POLICY, operand_names=("item",), exact_text=None, renderer=_render_with_prefix(_REMOVE_POLICY_PREFIX, "item"), ), - _DirectiveKind.REPLACE_USE: _DirectiveSpec( - kind=_DirectiveKind.REPLACE_USE, + DirectiveKind.REPLACE_USE: _DirectiveSpec( + kind=DirectiveKind.REPLACE_USE, operand_names=("new_item", "old_item"), exact_text=None, renderer=_render_replace_use, ), - _DirectiveKind.CLEAR_PREMISE: _DirectiveSpec( - kind=_DirectiveKind.CLEAR_PREMISE, + DirectiveKind.CLEAR_PREMISE: _DirectiveSpec( + kind=DirectiveKind.CLEAR_PREMISE, operand_names=(), exact_text=_CLEAR_PREMISE_TEXT, renderer=_render_exact(_CLEAR_PREMISE_TEXT), ), - _DirectiveKind.RESET_POLICIES: _DirectiveSpec( - kind=_DirectiveKind.RESET_POLICIES, + DirectiveKind.RESET_POLICIES: _DirectiveSpec( + kind=DirectiveKind.RESET_POLICIES, operand_names=(), exact_text=_RESET_POLICIES_TEXT, renderer=_render_exact(_RESET_POLICIES_TEXT), ), - _DirectiveKind.CLEAR_STATE: _DirectiveSpec( - kind=_DirectiveKind.CLEAR_STATE, + DirectiveKind.CLEAR_STATE: _DirectiveSpec( + kind=DirectiveKind.CLEAR_STATE, operand_names=(), exact_text=_CLEAR_STATE_TEXT, renderer=_render_exact(_CLEAR_STATE_TEXT), @@ -317,15 +317,15 @@ def _parse_replace_use(trimmed_text: str) -> CanonicalDirective | None: return None return CanonicalDirective( text=trimmed_text, - kind=_DirectiveKind.REPLACE_USE, + kind=DirectiveKind.REPLACE_USE, operands=MappingProxyType({"new_item": new_item, "old_item": old_item}), ) def _invalid_directive_syntax( - failure: _DirectiveSyntaxFailure, + failure: DirectiveSyntaxFailure, *, - directive_kind: _DirectiveKind | None = None, + directive_kind: DirectiveKind | None = None, missing_operand: str | None = None, ) -> InvalidDirectiveSyntax: return InvalidDirectiveSyntax( @@ -351,29 +351,29 @@ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSynta if not _starts_with_directive_family(trimmed_text): return None if _contains_multiple_canonical_directives(trimmed_text): - return _invalid_directive_syntax(_DirectiveSyntaxFailure.COMPOUND_DIRECTIVE) + return _invalid_directive_syntax(DirectiveSyntaxFailure.COMPOUND_DIRECTIVE) normalized = _normalized_for_matching(trimmed_text) if normalized == _CLEAR_PREMISE_TEXT: return CanonicalDirective( - text=text, kind=_DirectiveKind.CLEAR_PREMISE, operands=MappingProxyType({}) + text=text, kind=DirectiveKind.CLEAR_PREMISE, operands=MappingProxyType({}) ) if normalized == _RESET_POLICIES_TEXT: return CanonicalDirective( text=text, - kind=_DirectiveKind.RESET_POLICIES, + kind=DirectiveKind.RESET_POLICIES, operands=MappingProxyType({}), ) if normalized == _CLEAR_STATE_TEXT: return CanonicalDirective( - text=text, kind=_DirectiveKind.CLEAR_STATE, operands=MappingProxyType({}) + text=text, kind=DirectiveKind.CLEAR_STATE, operands=MappingProxyType({}) ) if normalized == "set premise": return _invalid_directive_syntax( - _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, - directive_kind=_DirectiveKind.SET_PREMISE, + DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=DirectiveKind.SET_PREMISE, missing_operand="value", ) @@ -381,25 +381,25 @@ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSynta match = _SET_PREMISE_RE.fullmatch(trimmed_text) if match is None: return _invalid_directive_syntax( - _DirectiveSyntaxFailure.MALFORMED_DIRECTIVE, - directive_kind=_DirectiveKind.SET_PREMISE, + DirectiveSyntaxFailure.MALFORMED_DIRECTIVE, + directive_kind=DirectiveKind.SET_PREMISE, ) value = match.group("value") if not _operand_has_content(value) or _operand_starts_with_token(value, "to"): return _invalid_directive_syntax( - _DirectiveSyntaxFailure.MALFORMED_DIRECTIVE, - directive_kind=_DirectiveKind.SET_PREMISE, + DirectiveSyntaxFailure.MALFORMED_DIRECTIVE, + directive_kind=DirectiveKind.SET_PREMISE, ) return CanonicalDirective( text=text, - kind=_DirectiveKind.SET_PREMISE, + kind=DirectiveKind.SET_PREMISE, operands=MappingProxyType({"value": value}), ) if normalized == "change premise to": return _invalid_directive_syntax( - _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, - directive_kind=_DirectiveKind.CHANGE_PREMISE, + DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=DirectiveKind.CHANGE_PREMISE, missing_operand="value", ) @@ -407,20 +407,20 @@ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSynta match = _CHANGE_PREMISE_RE.fullmatch(trimmed_text) if match is None: return _invalid_directive_syntax( - _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, - directive_kind=_DirectiveKind.CHANGE_PREMISE, + DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=DirectiveKind.CHANGE_PREMISE, missing_operand="value", ) value = match.group("value") if not _operand_has_content(value): return _invalid_directive_syntax( - _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, - directive_kind=_DirectiveKind.CHANGE_PREMISE, + DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=DirectiveKind.CHANGE_PREMISE, missing_operand="value", ) return CanonicalDirective( text=text, - kind=_DirectiveKind.CHANGE_PREMISE, + kind=DirectiveKind.CHANGE_PREMISE, operands=MappingProxyType({"value": value}), ) @@ -430,8 +430,8 @@ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSynta if normalized == "use": return _invalid_directive_syntax( - _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, - directive_kind=_DirectiveKind.USE_ITEM, + DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=DirectiveKind.USE_ITEM, missing_operand="item", ) @@ -439,45 +439,45 @@ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSynta match = _USE_RE.fullmatch(trimmed_text) if match is None: return _invalid_directive_syntax( - _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, - directive_kind=_DirectiveKind.USE_ITEM, + DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=DirectiveKind.USE_ITEM, missing_operand="item", ) item = match.group("item") normalized_item = _normalized_for_matching(item) if not _operand_has_content(item): return _invalid_directive_syntax( - _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, - directive_kind=_DirectiveKind.USE_ITEM, + DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=DirectiveKind.USE_ITEM, missing_operand="item", ) if normalized_item == "instead of" or normalized_item.startswith("instead of "): return _invalid_directive_syntax( - _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, - directive_kind=_DirectiveKind.REPLACE_USE, + DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=DirectiveKind.REPLACE_USE, missing_operand="new_item", ) if normalized_item.endswith(" instead of"): return _invalid_directive_syntax( - _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, - directive_kind=_DirectiveKind.REPLACE_USE, + DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=DirectiveKind.REPLACE_USE, missing_operand="old_item", ) if _INSTEAD_OF_DELIMITER in normalized_item: return _invalid_directive_syntax( - _DirectiveSyntaxFailure.MALFORMED_DIRECTIVE, - directive_kind=_DirectiveKind.USE_ITEM, + DirectiveSyntaxFailure.MALFORMED_DIRECTIVE, + directive_kind=DirectiveKind.USE_ITEM, ) return CanonicalDirective( text=text, - kind=_DirectiveKind.USE_ITEM, + kind=DirectiveKind.USE_ITEM, operands=MappingProxyType({"item": item}), ) if normalized == "prohibit": return _invalid_directive_syntax( - _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, - directive_kind=_DirectiveKind.PROHIBIT_ITEM, + DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=DirectiveKind.PROHIBIT_ITEM, missing_operand="item", ) @@ -485,27 +485,27 @@ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSynta match = _PROHIBIT_RE.fullmatch(trimmed_text) if match is None: return _invalid_directive_syntax( - _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, - directive_kind=_DirectiveKind.PROHIBIT_ITEM, + DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=DirectiveKind.PROHIBIT_ITEM, missing_operand="item", ) item = match.group("item") if not _operand_has_content(item): return _invalid_directive_syntax( - _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, - directive_kind=_DirectiveKind.PROHIBIT_ITEM, + DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=DirectiveKind.PROHIBIT_ITEM, missing_operand="item", ) return CanonicalDirective( text=text, - kind=_DirectiveKind.PROHIBIT_ITEM, + kind=DirectiveKind.PROHIBIT_ITEM, operands=MappingProxyType({"item": item}), ) if normalized == "remove policy": return _invalid_directive_syntax( - _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, - directive_kind=_DirectiveKind.REMOVE_POLICY, + DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=DirectiveKind.REMOVE_POLICY, missing_operand="item", ) @@ -513,30 +513,30 @@ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSynta match = _REMOVE_POLICY_RE.fullmatch(trimmed_text) if match is None: return _invalid_directive_syntax( - _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, - directive_kind=_DirectiveKind.REMOVE_POLICY, + DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=DirectiveKind.REMOVE_POLICY, missing_operand="item", ) item = match.group("item") if not _operand_has_content(item): return _invalid_directive_syntax( - _DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, - directive_kind=_DirectiveKind.REMOVE_POLICY, + DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=DirectiveKind.REMOVE_POLICY, missing_operand="item", ) return CanonicalDirective( text=text, - kind=_DirectiveKind.REMOVE_POLICY, + kind=DirectiveKind.REMOVE_POLICY, operands=MappingProxyType({"item": item}), ) - return _invalid_directive_syntax(_DirectiveSyntaxFailure.MALFORMED_DIRECTIVE) + return _invalid_directive_syntax(DirectiveSyntaxFailure.MALFORMED_DIRECTIVE) -def _render_directive(kind: _DirectiveKind | str, /, **operands: str) -> str: +def _render_directive(kind: DirectiveKind | str, /, **operands: str) -> str: """Produce canonical directive text from a semantic kind and operands.""" try: - normalized_kind = kind if isinstance(kind, _DirectiveKind) else _DirectiveKind(kind) + normalized_kind = kind if isinstance(kind, DirectiveKind) else DirectiveKind(kind) spec = _DIRECTIVE_SPECS[normalized_kind] except (KeyError, ValueError) as exc: raise ValueError(f"Unsupported directive kind: {kind!r}") from exc @@ -570,6 +570,8 @@ def _render_directive(kind: _DirectiveKind | str, /, **operands: str) -> str: __all__ = [ + "DirectiveKind", + "DirectiveSyntaxFailure", "CanonicalDirective", "InvalidDirectiveSyntax", "decompose_directive", diff --git a/tests/_api_contract_harness.py b/tests/_api_contract_harness.py index 5a61dc6..5c029f5 100644 --- a/tests/_api_contract_harness.py +++ b/tests/_api_contract_harness.py @@ -70,11 +70,11 @@ def assert_shape( if "kind" in shape and shape["kind"] == "invalid_directive_syntax": assert value == grammar.InvalidDirectiveSyntax( - failure=grammar._DirectiveSyntaxFailure(shape["failure"]), + failure=grammar.DirectiveSyntaxFailure(shape["failure"]), directive_kind=( None if shape.get("directive_kind") is None - else grammar._DirectiveKind(shape["directive_kind"]) + else grammar.DirectiveKind(shape["directive_kind"]) ), missing_operand=shape.get("missing_operand"), ) diff --git a/tests/fixtures/conformance/api/public-grammar-v1.json b/tests/fixtures/conformance/api/public-grammar-v1.json index c9763a7..bf8bb0c 100644 --- a/tests/fixtures/conformance/api/public-grammar-v1.json +++ b/tests/fixtures/conformance/api/public-grammar-v1.json @@ -4,11 +4,19 @@ "module": "context_compiler.grammar", "exports": { "names": [ + "DirectiveKind", + "DirectiveSyntaxFailure", "CanonicalDirective", "InvalidDirectiveSyntax", "decompose_directive" ], "members": { + "DirectiveKind": { + "kind": "class" + }, + "DirectiveSyntaxFailure": { + "kind": "class" + }, "CanonicalDirective": { "kind": "class" }, diff --git a/tests/test_engine.py b/tests/test_engine.py index 2f1d7f8..08684e8 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -15,7 +15,7 @@ ) from context_compiler.grammar import ( CanonicalDirective, - _DirectiveKind, + DirectiveKind, decompose_directive, ) @@ -42,7 +42,7 @@ def _import_state(engine: object, payload: dict[str, object]) -> None: def _canonical_directive( text: str, - kind: _DirectiveKind, + kind: DirectiveKind, **operands: str, ) -> CanonicalDirective: return CanonicalDirective(text=text, kind=kind, operands=MappingProxyType(operands)) @@ -54,7 +54,7 @@ def _canonical_directive( ( _canonical_directive( "set premise concise replies", - _DirectiveKind.SET_PREMISE, + DirectiveKind.SET_PREMISE, value="concise replies", ), None, @@ -64,7 +64,7 @@ def _canonical_directive( ( _canonical_directive( "change premise to concise replies", - _DirectiveKind.CHANGE_PREMISE, + DirectiveKind.CHANGE_PREMISE, value="concise replies", ), {"premise": "verbose replies", "policies": {}, "version": 2}, @@ -72,13 +72,13 @@ def _canonical_directive( (("concise replies"), {}), ), ( - _canonical_directive("use docker", _DirectiveKind.USE_ITEM, item="docker"), + _canonical_directive("use docker", DirectiveKind.USE_ITEM, item="docker"), None, {"kind": DECISION_UPDATE, "message": None}, (None, {"docker": "use"}), ), ( - _canonical_directive("prohibit peanuts", _DirectiveKind.PROHIBIT_ITEM, item="peanuts"), + _canonical_directive("prohibit peanuts", DirectiveKind.PROHIBIT_ITEM, item="peanuts"), None, {"kind": DECISION_UPDATE, "message": None}, (None, {"peanuts": "prohibit"}), @@ -86,7 +86,7 @@ def _canonical_directive( ( _canonical_directive( "remove policy docker", - _DirectiveKind.REMOVE_POLICY, + DirectiveKind.REMOVE_POLICY, item="docker", ), {"premise": None, "policies": {"docker": "use"}, "version": 2}, @@ -96,7 +96,7 @@ def _canonical_directive( ( _canonical_directive( "use podman instead of docker", - _DirectiveKind.REPLACE_USE, + DirectiveKind.REPLACE_USE, new_item="podman", old_item="docker", ), @@ -105,19 +105,19 @@ def _canonical_directive( (None, {"podman": "use"}), ), ( - _canonical_directive("clear premise", _DirectiveKind.CLEAR_PREMISE), + _canonical_directive("clear premise", DirectiveKind.CLEAR_PREMISE), {"premise": "concise replies", "policies": {"docker": "use"}, "version": 2}, {"kind": DECISION_UPDATE, "message": None}, (None, {"docker": "use"}), ), ( - _canonical_directive("reset policies", _DirectiveKind.RESET_POLICIES), + _canonical_directive("reset policies", DirectiveKind.RESET_POLICIES), {"premise": "concise replies", "policies": {"docker": "use"}, "version": 2}, {"kind": DECISION_UPDATE, "message": None}, ("concise replies", {}), ), ( - _canonical_directive("clear state", _DirectiveKind.CLEAR_STATE), + _canonical_directive("clear state", DirectiveKind.CLEAR_STATE), {"premise": "concise replies", "policies": {"docker": "use"}, "version": 2}, {"kind": DECISION_UPDATE, "message": None}, (None, {}), @@ -146,7 +146,7 @@ def test_apply_directive_accepts_all_canonical_directive_kinds( ( _canonical_directive( "set premise concise replies", - _DirectiveKind.SET_PREMISE, + DirectiveKind.SET_PREMISE, value="concise replies", ), {"premise": "existing premise", "policies": {}, "version": 2}, @@ -159,7 +159,7 @@ def test_apply_directive_accepts_all_canonical_directive_kinds( ( _canonical_directive( "change premise to concise replies", - _DirectiveKind.CHANGE_PREMISE, + DirectiveKind.CHANGE_PREMISE, value="concise replies", ), None, @@ -170,7 +170,7 @@ def test_apply_directive_accepts_all_canonical_directive_kinds( (None, {}), ), ( - _canonical_directive("use docker", _DirectiveKind.USE_ITEM, item="docker"), + _canonical_directive("use docker", DirectiveKind.USE_ITEM, item="docker"), {"premise": None, "policies": {"docker": "prohibit"}, "version": 2}, { "kind": DECISION_ERROR, @@ -181,7 +181,7 @@ def test_apply_directive_accepts_all_canonical_directive_kinds( (None, {"docker": "prohibit"}), ), ( - _canonical_directive("prohibit docker", _DirectiveKind.PROHIBIT_ITEM, item="docker"), + _canonical_directive("prohibit docker", DirectiveKind.PROHIBIT_ITEM, item="docker"), {"premise": None, "policies": {"docker": "use"}, "version": 2}, { "kind": DECISION_ERROR, @@ -194,7 +194,7 @@ def test_apply_directive_accepts_all_canonical_directive_kinds( ( _canonical_directive( "use docker instead of kubectl", - _DirectiveKind.REPLACE_USE, + DirectiveKind.REPLACE_USE, new_item="docker", old_item="kubectl", ), @@ -276,27 +276,27 @@ def test_pre_mutation_error_empty_operand_branches_remain_stable() -> None: engine = Engine() set_premise = CanonicalDirective( text="set premise ", - kind=_DirectiveKind.SET_PREMISE, + kind=DirectiveKind.SET_PREMISE, operands=MappingProxyType({"value": ""}), ) change_premise = CanonicalDirective( text="change premise to ", - kind=_DirectiveKind.CHANGE_PREMISE, + kind=DirectiveKind.CHANGE_PREMISE, operands=MappingProxyType({"value": ""}), ) remove_policy = CanonicalDirective( text="remove policy ", - kind=_DirectiveKind.REMOVE_POLICY, + kind=DirectiveKind.REMOVE_POLICY, operands=MappingProxyType({"item": ""}), ) use_item = CanonicalDirective( text="use ", - kind=_DirectiveKind.USE_ITEM, + kind=DirectiveKind.USE_ITEM, operands=MappingProxyType({"item": ""}), ) prohibit_item = CanonicalDirective( text="prohibit ", - kind=_DirectiveKind.PROHIBIT_ITEM, + kind=DirectiveKind.PROHIBIT_ITEM, operands=MappingProxyType({"item": ""}), ) diff --git a/tests/test_grammar.py b/tests/test_grammar.py index c9bd3a1..bba611d 100644 --- a/tests/test_grammar.py +++ b/tests/test_grammar.py @@ -6,15 +6,15 @@ import context_compiler.grammar as grammar_module from context_compiler.grammar import ( CanonicalDirective, + DirectiveKind, + DirectiveSyntaxFailure, InvalidDirectiveSyntax, - _DirectiveKind, - _DirectiveSyntaxFailure, decompose_directive, ) def test_directive_kind_members_and_values() -> None: - assert [member.name for member in _DirectiveKind] == [ + assert [member.name for member in DirectiveKind] == [ "SET_PREMISE", "CHANGE_PREMISE", "USE_ITEM", @@ -25,7 +25,7 @@ def test_directive_kind_members_and_values() -> None: "RESET_POLICIES", "CLEAR_STATE", ] - assert [member.value for member in _DirectiveKind] == [ + assert [member.value for member in DirectiveKind] == [ "set_premise", "change_premise", "use_item", @@ -36,40 +36,57 @@ def test_directive_kind_members_and_values() -> None: "reset_policies", "clear_state", ] - assert _DirectiveKind("set_premise") is _DirectiveKind.SET_PREMISE + assert DirectiveKind("set_premise") is DirectiveKind.SET_PREMISE + + +def test_directive_syntax_failure_members_and_values() -> None: + assert [member.name for member in DirectiveSyntaxFailure] == [ + "COMPOUND_DIRECTIVE", + "MISSING_REQUIRED_OPERAND", + "MALFORMED_DIRECTIVE", + ] + assert [member.value for member in DirectiveSyntaxFailure] == [ + "compound_directive", + "missing_required_operand", + "malformed_directive", + ] + assert ( + DirectiveSyntaxFailure("missing_required_operand") + is DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND + ) def test_canonical_directive_is_frozen_and_slotted() -> None: directive = CanonicalDirective( text="use docker", - kind=_DirectiveKind.USE_ITEM, + kind=DirectiveKind.USE_ITEM, operands=MappingProxyType({"item": "docker"}), ) assert directive.__slots__ == ("text", "kind", "operands") with pytest.raises(FrozenInstanceError): - directive.kind = _DirectiveKind.PROHIBIT_ITEM # type: ignore[misc] + directive.kind = DirectiveKind.PROHIBIT_ITEM # type: ignore[misc] @pytest.mark.parametrize( ("text", "expected_kind", "expected_operands"), [ - ("set premise concise replies", _DirectiveKind.SET_PREMISE, {"value": "concise replies"}), - ("change premise to formal tone", _DirectiveKind.CHANGE_PREMISE, {"value": "formal tone"}), - ("use docker", _DirectiveKind.USE_ITEM, {"item": "docker"}), - ("prohibit peanuts", _DirectiveKind.PROHIBIT_ITEM, {"item": "peanuts"}), - ("remove policy docker", _DirectiveKind.REMOVE_POLICY, {"item": "docker"}), + ("set premise concise replies", DirectiveKind.SET_PREMISE, {"value": "concise replies"}), + ("change premise to formal tone", DirectiveKind.CHANGE_PREMISE, {"value": "formal tone"}), + ("use docker", DirectiveKind.USE_ITEM, {"item": "docker"}), + ("prohibit peanuts", DirectiveKind.PROHIBIT_ITEM, {"item": "peanuts"}), + ("remove policy docker", DirectiveKind.REMOVE_POLICY, {"item": "docker"}), ( "use podman instead of docker", - _DirectiveKind.REPLACE_USE, + DirectiveKind.REPLACE_USE, {"new_item": "podman", "old_item": "docker"}, ), - ("clear premise", _DirectiveKind.CLEAR_PREMISE, {}), - ("reset policies", _DirectiveKind.RESET_POLICIES, {}), - ("clear state", _DirectiveKind.CLEAR_STATE, {}), + ("clear premise", DirectiveKind.CLEAR_PREMISE, {}), + ("reset policies", DirectiveKind.RESET_POLICIES, {}), + ("clear state", DirectiveKind.CLEAR_STATE, {}), ], ) def test_decompose_directive_accepts_each_canonical_family( - text: str, expected_kind: _DirectiveKind, expected_operands: dict[str, str] + text: str, expected_kind: DirectiveKind, expected_operands: dict[str, str] ) -> None: decomposed = decompose_directive(text) assert decomposed == CanonicalDirective( @@ -98,66 +115,66 @@ def test_decompose_directive_returns_none_when_no_directive_is_present(text: str ( "use", InvalidDirectiveSyntax( - failure=_DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, - directive_kind=_DirectiveKind.USE_ITEM, + failure=DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=DirectiveKind.USE_ITEM, missing_operand="item", ), ), ( "prohibit", InvalidDirectiveSyntax( - failure=_DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, - directive_kind=_DirectiveKind.PROHIBIT_ITEM, + failure=DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=DirectiveKind.PROHIBIT_ITEM, missing_operand="item", ), ), ( "remove policy", InvalidDirectiveSyntax( - failure=_DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, - directive_kind=_DirectiveKind.REMOVE_POLICY, + failure=DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=DirectiveKind.REMOVE_POLICY, missing_operand="item", ), ), ( "use x instead of", InvalidDirectiveSyntax( - failure=_DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, - directive_kind=_DirectiveKind.REPLACE_USE, + failure=DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=DirectiveKind.REPLACE_USE, missing_operand="old_item", ), ), ( "use instead of y", InvalidDirectiveSyntax( - failure=_DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, - directive_kind=_DirectiveKind.REPLACE_USE, + failure=DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=DirectiveKind.REPLACE_USE, missing_operand="new_item", ), ), ( "set premise to concise", InvalidDirectiveSyntax( - failure=_DirectiveSyntaxFailure.MALFORMED_DIRECTIVE, - directive_kind=_DirectiveKind.SET_PREMISE, + failure=DirectiveSyntaxFailure.MALFORMED_DIRECTIVE, + directive_kind=DirectiveKind.SET_PREMISE, ), ), ( "change premise concise", InvalidDirectiveSyntax( - failure=_DirectiveSyntaxFailure.MALFORMED_DIRECTIVE, + failure=DirectiveSyntaxFailure.MALFORMED_DIRECTIVE, ), ), ( "use docker and prohibit peanuts", InvalidDirectiveSyntax( - failure=_DirectiveSyntaxFailure.COMPOUND_DIRECTIVE, + failure=DirectiveSyntaxFailure.COMPOUND_DIRECTIVE, ), ), ( "clear state then set premise project", InvalidDirectiveSyntax( - failure=_DirectiveSyntaxFailure.COMPOUND_DIRECTIVE, + failure=DirectiveSyntaxFailure.COMPOUND_DIRECTIVE, ), ), ], @@ -189,27 +206,27 @@ def test_decompose_directive_preserves_current_operand_casing_and_whitespace( @pytest.mark.parametrize( ("kind", "operands", "expected"), [ - (_DirectiveKind.SET_PREMISE, {"value": "concise replies"}, "set premise concise replies"), + (DirectiveKind.SET_PREMISE, {"value": "concise replies"}, "set premise concise replies"), ( - _DirectiveKind.CHANGE_PREMISE, + DirectiveKind.CHANGE_PREMISE, {"value": "formal tone"}, "change premise to formal tone", ), - (_DirectiveKind.USE_ITEM, {"item": "docker"}, "use docker"), - (_DirectiveKind.PROHIBIT_ITEM, {"item": "peanuts"}, "prohibit peanuts"), - (_DirectiveKind.REMOVE_POLICY, {"item": "docker"}, "remove policy docker"), + (DirectiveKind.USE_ITEM, {"item": "docker"}, "use docker"), + (DirectiveKind.PROHIBIT_ITEM, {"item": "peanuts"}, "prohibit peanuts"), + (DirectiveKind.REMOVE_POLICY, {"item": "docker"}, "remove policy docker"), ( - _DirectiveKind.REPLACE_USE, + DirectiveKind.REPLACE_USE, {"new_item": "podman", "old_item": "docker"}, "use podman instead of docker", ), - (_DirectiveKind.CLEAR_PREMISE, {}, "clear premise"), - (_DirectiveKind.RESET_POLICIES, {}, "reset policies"), - (_DirectiveKind.CLEAR_STATE, {}, "clear state"), + (DirectiveKind.CLEAR_PREMISE, {}, "clear premise"), + (DirectiveKind.RESET_POLICIES, {}, "reset policies"), + (DirectiveKind.CLEAR_STATE, {}, "clear state"), ], ) def test_render_directive_outputs_exact_canonical_syntax( - kind: _DirectiveKind, operands: dict[str, str], expected: str + kind: DirectiveKind, operands: dict[str, str], expected: str ) -> None: rendered = grammar_module._render_directive(kind, **operands) assert rendered == expected @@ -221,45 +238,45 @@ def test_render_directive_outputs_exact_canonical_syntax( @pytest.mark.parametrize( ("kind", "operands", "message"), [ - (_DirectiveKind.SET_PREMISE, {}, "Missing required operands"), - (_DirectiveKind.REPLACE_USE, {"new_item": "podman"}, "Missing required operands"), + (DirectiveKind.SET_PREMISE, {}, "Missing required operands"), + (DirectiveKind.REPLACE_USE, {"new_item": "podman"}, "Missing required operands"), ( - _DirectiveKind.CLEAR_STATE, + DirectiveKind.CLEAR_STATE, {"item": "docker"}, "Unexpected operands", ), ( - _DirectiveKind.USE_ITEM, + DirectiveKind.USE_ITEM, {"value": "docker"}, "Missing required operands", ), ( - _DirectiveKind.USE_ITEM, + DirectiveKind.USE_ITEM, {"item": "docker", "old_item": "podman"}, "Unexpected operands", ), ( - _DirectiveKind.SET_PREMISE, + DirectiveKind.SET_PREMISE, {"value": ""}, "cannot be empty", ), ( - _DirectiveKind.SET_PREMISE, + DirectiveKind.SET_PREMISE, {"value": " "}, "cannot be empty", ), ( - _DirectiveKind.USE_ITEM, + DirectiveKind.USE_ITEM, {"item": "docker and prohibit peanuts"}, "canonical use_item directive", ), ( - _DirectiveKind.SET_PREMISE, + DirectiveKind.SET_PREMISE, {"value": "use docker and prohibit peanuts"}, "canonical set_premise directive", ), ( - _DirectiveKind.USE_ITEM, + DirectiveKind.USE_ITEM, {"item": "docker instead of podman"}, "canonical use_item directive", ), @@ -271,7 +288,7 @@ def test_render_directive_outputs_exact_canonical_syntax( ], ) def test_render_directive_rejects_invalid_operand_combinations( - kind: _DirectiveKind | str, operands: dict[str, str], message: str + kind: DirectiveKind | str, operands: dict[str, str], message: str ) -> None: with pytest.raises(ValueError, match=message): grammar_module._render_directive(kind, **operands) @@ -286,14 +303,16 @@ def test_internal_grammar_specs_use_immutable_mapping() -> None: specs = grammar_module._DIRECTIVE_SPECS assert isinstance(specs, MappingProxyType) with pytest.raises(TypeError): - specs[_DirectiveKind.SET_PREMISE] = object() # type: ignore[index] - spec = specs[_DirectiveKind.SET_PREMISE] + specs[DirectiveKind.SET_PREMISE] = object() # type: ignore[index] + spec = specs[DirectiveKind.SET_PREMISE] with pytest.raises(FrozenInstanceError): - spec.kind = _DirectiveKind.CHANGE_PREMISE # type: ignore[misc] + spec.kind = DirectiveKind.CHANGE_PREMISE # type: ignore[misc] def test_public_grammar_all_includes_semantic_surface() -> None: assert grammar_module.__all__ == [ + "DirectiveKind", + "DirectiveSyntaxFailure", "CanonicalDirective", "InvalidDirectiveSyntax", "decompose_directive", @@ -307,7 +326,7 @@ def test_decompose_directive_rejects_near_miss_without_required_delimiter() -> N def test_render_directive_rejects_non_string_operands() -> None: with pytest.raises(ValueError, match="must be a string"): - grammar_module._render_directive(_DirectiveKind.SET_PREMISE, value=123) # type: ignore[arg-type] + grammar_module._render_directive(DirectiveKind.SET_PREMISE, value=123) # type: ignore[arg-type] def test_render_directive_uses_decompose_directive_as_authoritative_round_trip( @@ -318,12 +337,12 @@ def test_render_directive_uses_decompose_directive_as_authoritative_round_trip( "decompose_directive", lambda _: CanonicalDirective( text="use docker", - kind=_DirectiveKind.USE_ITEM, + kind=DirectiveKind.USE_ITEM, operands=MappingProxyType({"item": "docker"}), ), ) - assert grammar_module._render_directive(_DirectiveKind.USE_ITEM, item="docker") == "use docker" + assert grammar_module._render_directive(DirectiveKind.USE_ITEM, item="docker") == "use docker" def test_render_directive_rejects_when_decompose_directive_disagrees_with_rendered_kind( @@ -334,13 +353,13 @@ def test_render_directive_rejects_when_decompose_directive_disagrees_with_render "decompose_directive", lambda _: CanonicalDirective( text="use docker", - kind=_DirectiveKind.PROHIBIT_ITEM, + kind=DirectiveKind.PROHIBIT_ITEM, operands=MappingProxyType({"item": "docker"}), ), ) with pytest.raises(ValueError, match="canonical use_item directive"): - grammar_module._render_directive(_DirectiveKind.USE_ITEM, item="docker") + grammar_module._render_directive(DirectiveKind.USE_ITEM, item="docker") def test_render_directive_rejects_when_decompose_directive_returns_noncanonical_result( @@ -349,18 +368,18 @@ def test_render_directive_rejects_when_decompose_directive_returns_noncanonical_ monkeypatch.setattr(grammar_module, "decompose_directive", lambda _: InvalidDirectiveSyntax()) with pytest.raises(ValueError, match="canonical use_item directive"): - grammar_module._render_directive(_DirectiveKind.USE_ITEM, item="docker") + grammar_module._render_directive(DirectiveKind.USE_ITEM, item="docker") def test_invalid_directive_syntax_is_frozen_and_slotted() -> None: invalid = InvalidDirectiveSyntax( - failure=_DirectiveSyntaxFailure.MALFORMED_DIRECTIVE, - directive_kind=_DirectiveKind.SET_PREMISE, + failure=DirectiveSyntaxFailure.MALFORMED_DIRECTIVE, + directive_kind=DirectiveKind.SET_PREMISE, ) assert invalid.__slots__ == ("failure", "directive_kind", "missing_operand") with pytest.raises(FrozenInstanceError): - invalid.failure = _DirectiveSyntaxFailure.COMPOUND_DIRECTIVE # type: ignore[misc] + invalid.failure = DirectiveSyntaxFailure.COMPOUND_DIRECTIVE # type: ignore[misc] def test_decompose_directive_returns_canonical_operands_for_use_item() -> None: @@ -368,7 +387,7 @@ def test_decompose_directive_returns_canonical_operands_for_use_item() -> None: assert parsed is not None assert parsed.text == "use docker" - assert parsed.kind is _DirectiveKind.USE_ITEM + assert parsed.kind is DirectiveKind.USE_ITEM assert parsed.operands == {"item": "docker"} @@ -377,7 +396,7 @@ def test_decompose_directive_returns_text_kind_and_operands_without_projection_l assert decomposed is not None assert decomposed.text == "use docker" - assert decomposed.kind is _DirectiveKind.USE_ITEM + assert decomposed.kind is DirectiveKind.USE_ITEM assert decomposed.operands == {"item": "docker"} diff --git a/tests/test_properties.py b/tests/test_properties.py index c6e92d3..c3cd063 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -15,7 +15,7 @@ ) from context_compiler.grammar import ( CanonicalDirective, - _DirectiveKind, + DirectiveKind, decompose_directive, ) @@ -203,19 +203,19 @@ def _payload_has_stable_export_import_cycle(payload: dict[str, object]) -> bool: GRAMMAR_RENDER_CASES = st.one_of( CANONICAL_GRAMMAR_PREMISE_TEXT.map( - lambda value: {"kind": _DirectiveKind.SET_PREMISE, "operands": {"value": value}} + lambda value: {"kind": DirectiveKind.SET_PREMISE, "operands": {"value": value}} ), CANONICAL_GRAMMAR_PREMISE_TEXT.map( - lambda value: {"kind": _DirectiveKind.CHANGE_PREMISE, "operands": {"value": value}} + lambda value: {"kind": DirectiveKind.CHANGE_PREMISE, "operands": {"value": value}} ), CANONICAL_GRAMMAR_ITEM_TEXT.map( - lambda item: {"kind": _DirectiveKind.USE_ITEM, "operands": {"item": item}} + lambda item: {"kind": DirectiveKind.USE_ITEM, "operands": {"item": item}} ), CANONICAL_GRAMMAR_ITEM_TEXT.map( - lambda item: {"kind": _DirectiveKind.PROHIBIT_ITEM, "operands": {"item": item}} + lambda item: {"kind": DirectiveKind.PROHIBIT_ITEM, "operands": {"item": item}} ), CANONICAL_GRAMMAR_ITEM_TEXT.map( - lambda item: {"kind": _DirectiveKind.REMOVE_POLICY, "operands": {"item": item}} + lambda item: {"kind": DirectiveKind.REMOVE_POLICY, "operands": {"item": item}} ), st.tuples(CANONICAL_GRAMMAR_ITEM_TEXT, CANONICAL_GRAMMAR_ITEM_TEXT) .filter( @@ -223,15 +223,15 @@ def _payload_has_stable_export_import_cycle(payload: dict[str, object]) -> bool: ) .map( lambda pair: { - "kind": _DirectiveKind.REPLACE_USE, + "kind": DirectiveKind.REPLACE_USE, "operands": {"new_item": pair[0], "old_item": pair[1]}, } ), st.sampled_from( [ - {"kind": _DirectiveKind.CLEAR_PREMISE, "operands": {}}, - {"kind": _DirectiveKind.RESET_POLICIES, "operands": {}}, - {"kind": _DirectiveKind.CLEAR_STATE, "operands": {}}, + {"kind": DirectiveKind.CLEAR_PREMISE, "operands": {}}, + {"kind": DirectiveKind.RESET_POLICIES, "operands": {}}, + {"kind": DirectiveKind.CLEAR_STATE, "operands": {}}, ] ), ) @@ -244,12 +244,12 @@ def test_determinism_same_input_sequence_same_state(inputs: list[str]) -> None: @given(GRAMMAR_RENDER_CASES) def test_grammar_helper_render_decompose_round_trip_is_stable( - case: dict[str, _DirectiveKind | dict[str, str]], + case: dict[str, DirectiveKind | dict[str, str]], ) -> None: kind = case["kind"] operands = case["operands"] - assert isinstance(kind, _DirectiveKind) + assert isinstance(kind, DirectiveKind) assert isinstance(operands, dict) rendered = grammar_module._render_directive(kind, **operands) diff --git a/tests/test_public_grammar_root_exports.py b/tests/test_public_grammar_root_exports.py index 64596ee..5d9236d 100644 --- a/tests/test_public_grammar_root_exports.py +++ b/tests/test_public_grammar_root_exports.py @@ -5,6 +5,7 @@ def test_root_does_not_export_public_grammar_surface() -> None: for name in ( "DirectiveKind", + "DirectiveSyntaxFailure", "CanonicalDirective", "InvalidDirectiveSyntax", "decompose_directive", @@ -14,10 +15,11 @@ def test_root_does_not_export_public_grammar_surface() -> None: def test_grammar_submodule_preserves_public_grammar_surface() -> None: + assert grammar_module.DirectiveKind is not None + assert grammar_module.DirectiveSyntaxFailure is not None assert grammar_module.CanonicalDirective is not None assert grammar_module.InvalidDirectiveSyntax is not None assert grammar_module.decompose_directive is not None - assert not hasattr(grammar_module, "DirectiveKind") assert not hasattr(grammar_module, "render_directive") assert not hasattr(grammar_module, "contains_multiple_canonical_directives") assert not hasattr(grammar_module, "match_canonical_directive_start") From 4c7138d125ea18c2546baf8be2f623fb0cb00d19 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Wed, 12 Aug 2026 02:39:46 -0400 Subject: [PATCH 3/5] refactor: remove redundant engine syntax validation --- src/context_compiler/engine.py | 35 --------------------- tests/test_engine.py | 57 ---------------------------------- 2 files changed, 92 deletions(-) diff --git a/src/context_compiler/engine.py b/src/context_compiler/engine.py index 4581e3f..d8ccf97 100644 --- a/src/context_compiler/engine.py +++ b/src/context_compiler/engine.py @@ -129,41 +129,6 @@ def _pre_mutation_error( ) -> Decision | None: candidate_state = self._state if state is None else state # Single error path: all error outcomes are detected before any mutation. - if directive.kind in {DirectiveKind.SET_PREMISE, DirectiveKind.CHANGE_PREMISE}: - value = directive.operands["value"] - if _sanitize_premise_value(value) == "": - if directive.kind is DirectiveKind.SET_PREMISE: - return _error( - "Premise value cannot be empty.\n" - "Use 'set premise ' with a non-empty value." - ) - return _error( - "Premise value cannot be empty.\n" - "Use 'change premise to ' with a non-empty value." - ) - - if ( - directive.kind is DirectiveKind.REMOVE_POLICY - and _normalize_item(directive.operands["item"]) == "" - ): - return _error( - "Policy item cannot be empty.\nUse 'remove policy ' with a non-empty value." - ) - - if ( - directive.kind is DirectiveKind.USE_ITEM - and _normalize_item(directive.operands["item"]) == "" - ): - return _error("Policy item cannot be empty.\nUse 'use ' with a non-empty value.") - - if ( - directive.kind is DirectiveKind.PROHIBIT_ITEM - and _normalize_item(directive.operands["item"]) == "" - ): - return _error( - "Policy item cannot be empty.\nUse 'prohibit ' with a non-empty value." - ) - if ( directive.kind is DirectiveKind.SET_PREMISE and candidate_state[STATE_PREMISE] is not None diff --git a/tests/test_engine.py b/tests/test_engine.py index 08684e8..4a7fbcc 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -272,63 +272,6 @@ def test_decompose_directive_rejects_invalid_and_nondirective_inputs() -> None: assert not isinstance(decompose_directive("hello there"), CanonicalDirective) -def test_pre_mutation_error_empty_operand_branches_remain_stable() -> None: - engine = Engine() - set_premise = CanonicalDirective( - text="set premise ", - kind=DirectiveKind.SET_PREMISE, - operands=MappingProxyType({"value": ""}), - ) - change_premise = CanonicalDirective( - text="change premise to ", - kind=DirectiveKind.CHANGE_PREMISE, - operands=MappingProxyType({"value": ""}), - ) - remove_policy = CanonicalDirective( - text="remove policy ", - kind=DirectiveKind.REMOVE_POLICY, - operands=MappingProxyType({"item": ""}), - ) - use_item = CanonicalDirective( - text="use ", - kind=DirectiveKind.USE_ITEM, - operands=MappingProxyType({"item": ""}), - ) - prohibit_item = CanonicalDirective( - text="prohibit ", - kind=DirectiveKind.PROHIBIT_ITEM, - operands=MappingProxyType({"item": ""}), - ) - - assert engine._pre_mutation_error(set_premise) == { # noqa: SLF001 - "kind": "error", - "message": ( - "Premise value cannot be empty.\nUse 'set premise ' with a non-empty value." - ), - } - assert engine._pre_mutation_error(change_premise) == { # noqa: SLF001 - "kind": "error", - "message": ( - "Premise value cannot be empty.\n" - "Use 'change premise to ' with a non-empty value." - ), - } - assert engine._pre_mutation_error(remove_policy) == { # noqa: SLF001 - "kind": "error", - "message": ( - "Policy item cannot be empty.\nUse 'remove policy ' with a non-empty value." - ), - } - assert engine._pre_mutation_error(use_item) == { # noqa: SLF001 - "kind": "error", - "message": "Policy item cannot be empty.\nUse 'use ' with a non-empty value.", - } - assert engine._pre_mutation_error(prohibit_item) == { # noqa: SLF001 - "kind": "error", - "message": ("Policy item cannot be empty.\nUse 'prohibit ' with a non-empty value."), - } - - def test_initial_state_and_engine_properties() -> None: engine = Engine() _assert_observations(engine, premise=None, policies={}) From 880667e1292218d98813d2f05356e791042661df Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Wed, 12 Aug 2026 02:42:55 -0400 Subject: [PATCH 4/5] chore: bump version to 0.9.0dev9 --- docs/api-reference.md | 2 ++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 336e749..07d6857 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -94,6 +94,8 @@ Boundary notes: - `CanonicalDirective.kind` uses `DirectiveKind` - `InvalidDirectiveSyntax.failure` uses `DirectiveSyntaxFailure` - `InvalidDirectiveSyntax.directive_kind`, when present, uses `DirectiveKind` +- `InvalidDirectiveSyntax.missing_operand`, when present, names the missing + grammar operand without introducing user-facing message text - `CanonicalDirective.text` preserves the original accepted input text, so caller casing or formatting may remain visible there - `CanonicalDirective.text` is not canonical serialized directive text diff --git a/pyproject.toml b/pyproject.toml index 8e662a4..15d7a36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "context-compiler" -version = "0.9.0dev8" +version = "0.9.0dev9" description = "Deterministic conversational state engine for LLM applications." readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 97fc9b6..296a722 100644 --- a/uv.lock +++ b/uv.lock @@ -296,7 +296,7 @@ wheels = [ [[package]] name = "context-compiler" -version = "0.9.0.dev8" +version = "0.9.0.dev9" source = { editable = "." } [package.optional-dependencies] From 50a37bda037cccb66c63222469332a298180f2b1 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Wed, 12 Aug 2026 02:46:36 -0400 Subject: [PATCH 5/5] test: cover directive syntax failure branches --- tests/test_grammar.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_grammar.py b/tests/test_grammar.py index bba611d..012e636 100644 --- a/tests/test_grammar.py +++ b/tests/test_grammar.py @@ -112,6 +112,22 @@ def test_decompose_directive_returns_none_when_no_directive_is_present(text: str @pytest.mark.parametrize( ("text", "expected"), [ + ( + "set premise", + InvalidDirectiveSyntax( + failure=DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=DirectiveKind.SET_PREMISE, + missing_operand="value", + ), + ), + ( + "change premise to", + InvalidDirectiveSyntax( + failure=DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND, + directive_kind=DirectiveKind.CHANGE_PREMISE, + missing_operand="value", + ), + ), ( "use", InvalidDirectiveSyntax( @@ -466,6 +482,15 @@ def test_parse_replace_use_rejects_embedded_delimiter_in_old_item() -> None: ) +def test_decompose_directive_marks_use_with_embedded_replacement_delimiter_as_malformed() -> None: + assert decompose_directive("use podman instead of docker instead of nerdctl") == ( + InvalidDirectiveSyntax( + failure=DirectiveSyntaxFailure.MALFORMED_DIRECTIVE, + directive_kind=DirectiveKind.USE_ITEM, + ) + ) + + def test_parse_replace_use_rejects_non_canonical_normalized_delimiter_count( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -521,6 +546,7 @@ def test_decompose_directive_defensively_rejects_when_branch_regex_match_is_miss @pytest.mark.parametrize( ("pattern_name", "text", "groups"), [ + ("_USE_RE", "use docker", {"item": " \t "}), ("_CHANGE_PREMISE_RE", "change premise to concise", {"value": " \t "}), ("_PROHIBIT_RE", "prohibit docker", {"item": " \t "}), ("_REMOVE_POLICY_RE", "remove policy docker", {"item": " \t "}),