diff --git a/src/context_compiler/__init__.py b/src/context_compiler/__init__.py index 12521f5..cdc06ca 100644 --- a/src/context_compiler/__init__.py +++ b/src/context_compiler/__init__.py @@ -36,6 +36,7 @@ get_policy_items, get_premise_value, ) +from .grammar import DirectiveKind, is_canonical_directive, render_directive, validate_directive __version__ = version("context-compiler") @@ -44,6 +45,7 @@ "DECISION_CLARIFY", "DECISION_PASSTHROUGH", "DECISION_UPDATE", + "DirectiveKind", "Engine", "POLICY_PROHIBIT", "POLICY_USE", @@ -61,11 +63,14 @@ "get_policy_items", "get_step_decision", "get_step_state", + "is_canonical_directive", "is_clarify", "is_passthrough", "is_update", "preview", "preview_would_mutate", + "render_directive", "state_diff", "step", + "validate_directive", ] diff --git a/src/context_compiler/engine.py b/src/context_compiler/engine.py index e99c69f..2d950c6 100644 --- a/src/context_compiler/engine.py +++ b/src/context_compiler/engine.py @@ -16,8 +16,7 @@ STATE_PREMISE, STATE_VERSION, ) -from .grammar import DirectiveKind -from .grammar import _parse_directive as _parse_canonical_directive +from .grammar import DirectiveKind, _parse_canonical_directive PolicyValue = Literal["use", "prohibit"] @@ -75,26 +74,6 @@ class Action: _COMPOUND_DIRECTIVE_PROMPT = ( "Multiple directives are not supported in one input.\nSubmit each directive separately." ) -_CLEAR_PREMISE = "clear premise" -_RESET_POLICIES = "reset policies" -_CLEAR_STATE = "clear state" -_REMOVE_POLICY_BASE = "remove policy" -_CHANGE_PREMISE_PREFIX = "change premise " -_CHANGE_PREMISE_BASE = "change premise to" -_SET_PREMISE_BASE = "set premise" -_USE_PREFIX = "use " -_PROHIBIT_BASE = "prohibit" -_PROHIBIT_PREFIX = "prohibit " -_CANONICAL_DIRECTIVE_STARTS: tuple[tuple[str, bool], ...] = ( - (_CHANGE_PREMISE_BASE, True), - (_SET_PREMISE_BASE, True), - (_REMOVE_POLICY_BASE, True), - (_RESET_POLICIES, False), - (_CLEAR_PREMISE, False), - (_CLEAR_STATE, False), - (_PROHIBIT_BASE, True), - ("use", True), -) def create_engine(state: State | None = None) -> "Engine": @@ -303,6 +282,8 @@ def _apply_replacement_explicit(self, new_item: str, old_item: str) -> None: def _parse_directive(user_input: str) -> Action | None: + # Engine semantics intentionally depend on grammar's internal parsed form. + # This keeps syntax authority in grammar without making the parser a public API. parsed = _parse_canonical_directive(user_input) if parsed is None: return None @@ -330,56 +311,6 @@ def _parse_directive(user_input: str) -> Action | None: return Action(kind="clear_state") -def _contains_compound_directive(user_input: str) -> bool: - first_start = _match_canonical_directive_start(user_input, 0) - if first_start is None: - return False - - for index in range(first_start, len(user_input)): - next_start = _match_canonical_directive_start(user_input, index) - if next_start is not None: - return True - - return False - - -def _match_canonical_directive_start(user_input: str, start: int) -> int | None: - if start < 0 or start >= len(user_input): - return None - - if start > 0 and user_input[start - 1].isalpha(): - return None - - for token, require_space_or_end in _CANONICAL_DIRECTIVE_STARTS: - if _matches_directive_token( - user_input, start, token, require_space_or_end=require_space_or_end - ): - return start + len(token) - - return None - - -def _matches_directive_token( - user_input: str, - start: int, - token: str, - *, - require_space_or_end: bool = False, -) -> bool: - if not user_input.startswith(token, start): - return False - - end = start + len(token) - if end == len(user_input): - return True - - next_char = user_input[end] - if require_space_or_end: - return next_char == " " - - return not next_char.isalpha() - - def _initial_state() -> State: return { STATE_PREMISE: None, diff --git a/src/context_compiler/grammar.py b/src/context_compiler/grammar.py index 1b29e3b..f24a6e3 100644 --- a/src/context_compiler/grammar.py +++ b/src/context_compiler/grammar.py @@ -26,7 +26,7 @@ class ValidatedDirective: @dataclass(frozen=True, slots=True) -class _ParsedDirective: +class _GrammarParsedDirective: text: str kind: DirectiveKind operands: MappingProxyType[str, str] @@ -243,7 +243,7 @@ def _contains_multiple_canonical_directives(text: str) -> bool: return False -def _parse_replace_use(trimmed_text: str) -> _ParsedDirective | None: +def _parse_replace_use(trimmed_text: str) -> _GrammarParsedDirective | None: match = _REPLACE_RE.fullmatch(trimmed_text) if match is None: return None @@ -258,14 +258,14 @@ def _parse_replace_use(trimmed_text: str) -> _ParsedDirective | None: normalized_payload = _normalized_for_matching(trimmed_text) if normalized_payload.count(_INSTEAD_OF_DELIMITER) != 1: return None - return _ParsedDirective( + return _GrammarParsedDirective( text=trimmed_text, kind=DirectiveKind.REPLACE_USE, operands=MappingProxyType({"new_item": new_item, "old_item": old_item}), ) -def _parse_directive(text: str) -> _ParsedDirective | None: +def _parse_directive(text: str) -> _GrammarParsedDirective | None: trimmed_text = _trim_ascii_whitespace(text) if trimmed_text == "": return None @@ -275,17 +275,17 @@ def _parse_directive(text: str) -> _ParsedDirective | None: normalized = _normalized_for_matching(trimmed_text) if normalized == "clear premise": - return _ParsedDirective( + return _GrammarParsedDirective( text=text, kind=DirectiveKind.CLEAR_PREMISE, operands=MappingProxyType({}) ) if normalized == "reset policies": - return _ParsedDirective( + return _GrammarParsedDirective( text=text, kind=DirectiveKind.RESET_POLICIES, operands=MappingProxyType({}), ) if normalized == "clear state": - return _ParsedDirective( + return _GrammarParsedDirective( text=text, kind=DirectiveKind.CLEAR_STATE, operands=MappingProxyType({}) ) @@ -296,7 +296,7 @@ def _parse_directive(text: str) -> _ParsedDirective | None: value = match.group("value") if not _operand_has_content(value) or _operand_starts_with_token(value, "to"): return None - return _ParsedDirective( + return _GrammarParsedDirective( text=text, kind=DirectiveKind.SET_PREMISE, operands=MappingProxyType({"value": value}), @@ -309,7 +309,7 @@ def _parse_directive(text: str) -> _ParsedDirective | None: value = match.group("value") if not _operand_has_content(value): return None - return _ParsedDirective( + return _GrammarParsedDirective( text=text, kind=DirectiveKind.CHANGE_PREMISE, operands=MappingProxyType({"value": value}), @@ -332,7 +332,7 @@ def _parse_directive(text: str) -> _ParsedDirective | None: or _INSTEAD_OF_DELIMITER in normalized_item ): return None - return _ParsedDirective( + return _GrammarParsedDirective( text=text, kind=DirectiveKind.USE_ITEM, operands=MappingProxyType({"item": item}), @@ -345,7 +345,7 @@ def _parse_directive(text: str) -> _ParsedDirective | None: item = match.group("item") if not _operand_has_content(item): return None - return _ParsedDirective( + return _GrammarParsedDirective( text=text, kind=DirectiveKind.PROHIBIT_ITEM, operands=MappingProxyType({"item": item}), @@ -358,7 +358,7 @@ def _parse_directive(text: str) -> _ParsedDirective | None: item = match.group("item") if not _operand_has_content(item): return None - return _ParsedDirective( + return _GrammarParsedDirective( text=text, kind=DirectiveKind.REMOVE_POLICY, operands=MappingProxyType({"item": item}), @@ -374,6 +374,18 @@ def validate_directive(text: str) -> ValidatedDirective | None: return ValidatedDirective(text=parsed.text, kind=parsed.kind) +def _parse_canonical_directive(text: str) -> _GrammarParsedDirective | None: + return _parse_directive(text) + + +def match_canonical_directive_start(text: str, start: int) -> int | None: + return _match_canonical_directive_start(text, start) + + +def contains_multiple_canonical_directives(text: str) -> bool: + return _contains_multiple_canonical_directives(text) + + def is_canonical_directive(text: str) -> bool: return validate_directive(text) is not None diff --git a/tests/fixtures/conformance/api/public-api-v1.json b/tests/fixtures/conformance/api/public-api-v1.json index ed609cd..e59e72b 100644 --- a/tests/fixtures/conformance/api/public-api-v1.json +++ b/tests/fixtures/conformance/api/public-api-v1.json @@ -18,6 +18,7 @@ "DECISION_CLARIFY", "DECISION_PASSTHROUGH", "DECISION_UPDATE", + "DirectiveKind", "Engine", "POLICY_PROHIBIT", "POLICY_USE", @@ -35,13 +36,16 @@ "get_policy_items", "get_step_decision", "get_step_state", + "is_canonical_directive", "is_clarify", "is_passthrough", "is_update", "preview", "preview_would_mutate", + "render_directive", "state_diff", - "step" + "step", + "validate_directive" ], "members": { "Decision": { @@ -59,6 +63,9 @@ "kind": "constant", "value": "update" }, + "DirectiveKind": { + "kind": "class" + }, "Engine": { "kind": "class" }, @@ -283,6 +290,29 @@ ] } }, + "is_canonical_directive": { + "kind": "callable", + "signature": { + "params": [ + { + "name": "text", + "kind": "POSITIONAL_OR_KEYWORD", + "has_default": false + } + ] + }, + "shape_probes": [ + { + "kwargs": { + "text": "clear state" + }, + "return_shape": { + "type": "boolean", + "const": true + } + } + ] + }, "is_clarify": { "kind": "callable", "signature": { @@ -433,6 +463,34 @@ ] } }, + "render_directive": { + "kind": "callable", + "signature": { + "params": [ + { + "name": "kind", + "kind": "POSITIONAL_ONLY", + "has_default": false + }, + { + "name": "operands", + "kind": "VAR_KEYWORD", + "has_default": false + } + ] + }, + "shape_probes": [ + { + "args": [ + "clear_state" + ], + "return_shape": { + "type": "string", + "const": "clear state" + } + } + ] + }, "state_diff": { "kind": "callable", "signature": { @@ -527,6 +585,37 @@ } } ] + }, + "validate_directive": { + "kind": "callable", + "signature": { + "params": [ + { + "name": "text", + "kind": "POSITIONAL_OR_KEYWORD", + "has_default": false + } + ] + }, + "shape_probes": [ + { + "kwargs": { + "text": "use docker" + }, + "return_shape": { + "kind": "validated_directive", + "text": "use docker" + } + }, + { + "kwargs": { + "text": "please use docker" + }, + "return_shape": { + "type": "null" + } + } + ] } } }, diff --git a/tests/test_api_contract_fixture.py b/tests/test_api_contract_fixture.py index 7101a70..60a6eb2 100644 --- a/tests/test_api_contract_fixture.py +++ b/tests/test_api_contract_fixture.py @@ -42,6 +42,9 @@ def _assert_shape(value: object, shape: dict[str, object], contract: dict[str, o actual_members = sorted(name for name in dir(value) if not name.startswith("_")) assert actual_members == sorted(expected_members.keys()) return + if "kind" in shape and shape["kind"] == "validated_directive": + assert value == context_compiler.validate_directive(shape["text"]) + return expected_types = shape["type"] if isinstance(expected_types, str): @@ -112,10 +115,11 @@ def test_api_contract_fixture_matches_python_public_surface() -> None: if "signature" in export_contract: _assert_signature_matches(exported, export_contract["signature"], name) for probe in export_contract.get("shape_probes", []): + args = [_resolve_probe_value(value) for value in probe.get("args", [])] kwargs = { key: _resolve_probe_value(value) for key, value in probe.get("kwargs", {}).items() } - result = exported(**kwargs) + result = exported(*args, **kwargs) _assert_shape(result, probe["return_shape"], contract) engine = context_compiler.create_engine() diff --git a/tests/test_engine.py b/tests/test_engine.py index 3a8a3c4..8ae540d 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -7,11 +7,13 @@ Action, DecisionKind, Engine, - _contains_compound_directive, - _match_canonical_directive_start, _normalize_confirmation, _parse_directive, ) +from context_compiler.grammar import ( + contains_multiple_canonical_directives, + match_canonical_directive_start, +) pytestmark = pytest.mark.contract @@ -110,16 +112,16 @@ def test_pre_mutation_clarify_legacy_invalid_action_branches_remain_stable() -> } -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_grammar_owned_compound_detection_helpers_remain_unchanged() -> None: + assert contains_multiple_canonical_directives("use docker and prohibit peanuts") is True + assert contains_multiple_canonical_directives("hello there") is False + assert contains_multiple_canonical_directives("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: diff --git a/tests/test_properties.py b/tests/test_properties.py index ace3633..86715a4 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -8,8 +8,13 @@ from context_compiler import create_engine from context_compiler.controller import preview, state_diff -from context_compiler.engine import _CANONICAL_DIRECTIVE_STARTS, State -from context_compiler.grammar import DirectiveKind, render_directive, validate_directive +from context_compiler.engine import State +from context_compiler.grammar import ( + DirectiveKind, + match_canonical_directive_start, + render_directive, + validate_directive, +) def _run_sequence(inputs: list[str]) -> State: @@ -31,20 +36,8 @@ def _normalize_item_like_engine(value: str) -> str: def _contains_canonical_start_fragment(value: str) -> bool: for start in range(len(value)): - if start > 0 and value[start - 1].isalpha(): - continue - for token, require_space_or_end in _CANONICAL_DIRECTIVE_STARTS: - if not value.startswith(token, start): - continue - end = start + len(token) - if end == len(value): - return True - next_char = value[end] - if require_space_or_end: - if next_char == " ": - return True - elif not next_char.isalpha(): - return True + if match_canonical_directive_start(value, start) is not None: + return True return False diff --git a/tests/test_public_grammar_root_exports.py b/tests/test_public_grammar_root_exports.py new file mode 100644 index 0000000..263a2e8 --- /dev/null +++ b/tests/test_public_grammar_root_exports.py @@ -0,0 +1,26 @@ +import context_compiler +import context_compiler.grammar as grammar_module + + +def test_root_reexports_read_only_grammar_contract() -> None: + assert context_compiler.DirectiveKind is grammar_module.DirectiveKind + assert context_compiler.validate_directive is grammar_module.validate_directive + assert context_compiler.render_directive is grammar_module.render_directive + assert context_compiler.is_canonical_directive is grammar_module.is_canonical_directive + + +def test_root_does_not_export_private_grammar_implementation() -> None: + for name in ( + "_DIRECTIVE_SPECS", + "_SET_PREMISE_RE", + "_CHANGE_PREMISE_RE", + "_USE_RE", + "_PROHIBIT_RE", + "_REMOVE_POLICY_RE", + "_REPLACE_RE", + "_match_directive_token", + "_match_canonical_directive_start", + "_parse_directive", + ): + assert name not in context_compiler.__all__ + assert not hasattr(context_compiler, name)