From 51bb65e4c17da049c5befd4b9720e5dd72ce4c79 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 17 Jul 2026 00:42:37 -0400 Subject: [PATCH 1/2] feat: add public directive grammar foundation --- docs/api-reference.md | 25 ++ src/context_compiler/grammar.py | 263 ++++++++++++++++++ .../conformance/api/public-grammar-v1.json | 105 +++++++ .../grammar/001_validate_set_premise.json | 14 + .../002_validate_compound_rejected.json | 11 + .../grammar/003_render_replace_use.json | 16 ++ tests/test_fixtures.py | 31 +++ tests/test_grammar.py | 202 ++++++++++++++ tests/test_grammar_api_contract_fixture.py | 92 ++++++ 9 files changed, 759 insertions(+) create mode 100644 src/context_compiler/grammar.py create mode 100644 tests/fixtures/conformance/api/public-grammar-v1.json create mode 100644 tests/fixtures/conformance/grammar/001_validate_set_premise.json create mode 100644 tests/fixtures/conformance/grammar/002_validate_compound_rejected.json create mode 100644 tests/fixtures/conformance/grammar/003_render_replace_use.json create mode 100644 tests/test_grammar.py create mode 100644 tests/test_grammar_api_contract_fixture.py diff --git a/docs/api-reference.md b/docs/api-reference.md index d2cd313..702b92b 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -55,6 +55,31 @@ Important grammar contract: - quote characters do not create protected literal regions inside recognized directive payloads +### `context_compiler.grammar` + +Canonical grammar helpers are available from the `context_compiler.grammar` +submodule. + +Public grammar surface: + +- `DirectiveKind` +- `ValidatedDirective` +- `validate_directive(text)` +- `is_canonical_directive(text)` +- `render_directive(kind, /, **operands)` + +Use this surface for exact canonical validation or canonical directive string +construction only. + +Boundary notes: + +- no public parser is exposed +- validation returns `None` for any non-canonical input, including near misses, + compounds, and ordinary prose +- rendering is syntax-only and performs no state interpretation +- `engine.step(...)` remains the authority for clarification, state + transitions, pending confirmation, and mutation behavior + ### `engine.state` Read the current authoritative in-memory state snapshot. diff --git a/src/context_compiler/grammar.py b/src/context_compiler/grammar.py new file mode 100644 index 0000000..69132fa --- /dev/null +++ b/src/context_compiler/grammar.py @@ -0,0 +1,263 @@ +"""Immutable canonical grammar helpers for Context Compiler directives.""" + +import re +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum +from types import MappingProxyType + + +class DirectiveKind(StrEnum): + SET_PREMISE = "set_premise" + CHANGE_PREMISE = "change_premise" + USE_ITEM = "use_item" + PROHIBIT_ITEM = "prohibit_item" + REMOVE_POLICY = "remove_policy" + REPLACE_USE = "replace_use" + CLEAR_PREMISE = "clear_premise" + RESET_POLICIES = "reset_policies" + CLEAR_STATE = "clear_state" + + +@dataclass(frozen=True, slots=True) +class ValidatedDirective: + text: str + kind: DirectiveKind + + +@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 " +_CHANGE_PREMISE_PREFIX = "change premise to " +_USE_PREFIX = "use " +_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)?$") + +_CANONICAL_DIRECTIVE_STARTS: tuple[tuple[str, bool], ...] = ( + (_CHANGE_PREMISE_PREFIX.removesuffix(" "), True), + (_SET_PREMISE_PREFIX.removesuffix(" "), True), + (_REMOVE_POLICY_PREFIX.removesuffix(" "), True), + ("reset policies", False), + ("clear premise", False), + ("clear state", False), + (_PROHIBIT_PREFIX.removesuffix(" "), True), + ("use", True), +) + + +def _render_with_prefix( + prefix: str, operand_name: str +) -> Callable[[MappingProxyType[str, str]], str]: + def _renderer(operands: MappingProxyType[str, str]) -> str: + return f"{prefix}{operands[operand_name]}" + + return _renderer + + +def _render_replace_use(operands: MappingProxyType[str, str]) -> str: + return f"{_USE_PREFIX}{operands['new_item']}{_INSTEAD_OF_DELIMITER}{operands['old_item']}" + + +def _render_exact(text: str) -> Callable[[MappingProxyType[str, str]], str]: + def _renderer(operands: MappingProxyType[str, str]) -> str: + assert not operands + return text + + return _renderer + + +_DIRECTIVE_SPECS = MappingProxyType( + { + 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 _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(): + 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(): + return end + + return None + + +def _contains_multiple_canonical_directives(text: str) -> bool: + first_start = _match_canonical_directive_start(text, 0) + if first_start is None: + return False + + for index in range(first_start, len(text)): + next_start = _match_canonical_directive_start(text, index) + if next_start is not None: + return True + + return False + + +def validate_directive(text: str) -> ValidatedDirective | None: + if text == "": + return None + if _contains_multiple_canonical_directives(text): + return None + + 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) + + return None + + +def is_canonical_directive(text: str) -> bool: + return validate_directive(text) is not None + + +def render_directive(kind: DirectiveKind, /, **operands: str) -> str: + try: + 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 + + expected_names = set(spec.operand_names) + actual_names = set(operands) + unexpected_names = actual_names - expected_names + missing_names = expected_names - actual_names + if missing_names: + missing = ", ".join(sorted(missing_names)) + raise ValueError(f"Missing required operands for {kind.value}: {missing}") + if unexpected_names: + unexpected = ", ".join(sorted(unexpected_names)) + raise ValueError(f"Unexpected operands for {kind.value}: {unexpected}") + + normalized_operands: dict[str, str] = {} + for name in spec.operand_names: + raw_value = operands[name] + if not isinstance(raw_value, str): + raise ValueError(f"Operand {name!r} for {kind.value} must be a string.") + if raw_value.strip() == "": + raise ValueError(f"Operand {name!r} for {kind.value} cannot be empty.") + normalized_operands[name] = raw_value + + operand_view = MappingProxyType(normalized_operands) + rendered = spec.renderer(operand_view) + validated = validate_directive(rendered) + if validated is None or validated.kind is not normalized_kind: + raise ValueError(f"Operands do not produce a canonical {normalized_kind.value} directive.") + return rendered + + +__all__ = [ + "DirectiveKind", + "ValidatedDirective", + "is_canonical_directive", + "render_directive", + "validate_directive", +] diff --git a/tests/fixtures/conformance/api/public-grammar-v1.json b/tests/fixtures/conformance/api/public-grammar-v1.json new file mode 100644 index 0000000..8dabc8c --- /dev/null +++ b/tests/fixtures/conformance/api/public-grammar-v1.json @@ -0,0 +1,105 @@ +{ + "id": "public-grammar-v1", + "kind": "api-contract", + "module": "context_compiler.grammar", + "exports": { + "names": [ + "DirectiveKind", + "ValidatedDirective", + "is_canonical_directive", + "render_directive", + "validate_directive" + ], + "members": { + "DirectiveKind": { + "kind": "class" + }, + "ValidatedDirective": { + "kind": "class" + }, + "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 + } + } + ] + }, + "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" + } + } + ] + }, + "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", + "directive_kind": "use_item" + } + }, + { + "kwargs": { + "text": "please use docker" + }, + "return_shape": { + "type": "null" + } + } + ] + } + } + } +} diff --git a/tests/fixtures/conformance/grammar/001_validate_set_premise.json b/tests/fixtures/conformance/grammar/001_validate_set_premise.json new file mode 100644 index 0000000..e7573c8 --- /dev/null +++ b/tests/fixtures/conformance/grammar/001_validate_set_premise.json @@ -0,0 +1,14 @@ +{ + "id": "grammar_validate_set_premise", + "kind": "grammar", + "action": { + "fn": "validate_directive", + "text": "set premise concise replies" + }, + "expected": { + "validated": { + "text": "set premise concise replies", + "kind": "set_premise" + } + } +} diff --git a/tests/fixtures/conformance/grammar/002_validate_compound_rejected.json b/tests/fixtures/conformance/grammar/002_validate_compound_rejected.json new file mode 100644 index 0000000..4bac6db --- /dev/null +++ b/tests/fixtures/conformance/grammar/002_validate_compound_rejected.json @@ -0,0 +1,11 @@ +{ + "id": "grammar_validate_compound_rejected", + "kind": "grammar", + "action": { + "fn": "validate_directive", + "text": "use docker and prohibit peanuts" + }, + "expected": { + "validated": null + } +} diff --git a/tests/fixtures/conformance/grammar/003_render_replace_use.json b/tests/fixtures/conformance/grammar/003_render_replace_use.json new file mode 100644 index 0000000..43b908d --- /dev/null +++ b/tests/fixtures/conformance/grammar/003_render_replace_use.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_render_replace_use", + "kind": "grammar", + "action": { + "fn": "render_directive", + "kind": "replace_use", + "operands": { + "new_item": "podman", + "old_item": "docker" + } + }, + "expected": { + "text": "use podman instead of docker", + "validated_kind": "replace_use" + } +} diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index 24daa74..1c3266e 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -5,6 +5,7 @@ from context_compiler import create_engine from context_compiler.controller import preview, state_diff, step +from context_compiler.grammar import DirectiveKind, render_directive, validate_directive _STEP_FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures" / "conformance" / "step" _STATE_JSON_FIXTURES_DIR = ( @@ -16,6 +17,7 @@ _CONTROLLER_FIXTURES_DIR = ( Path(__file__).resolve().parent / "fixtures" / "conformance" / "controller" ) +_GRAMMAR_FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures" / "conformance" / "grammar" def _json_files(dir_path: Path) -> list[Path]: @@ -198,3 +200,32 @@ def test_controller_fixtures() -> None: assert fn == "state_diff", fixture_id diff = state_diff(action["before"], action["after"]) assert diff == expected["diff"], fixture_id + + +def test_grammar_fixtures() -> None: + for path in _json_files(_GRAMMAR_FIXTURES_DIR): + fixture = _load(path) + fixture_id = fixture["id"] + + assert fixture["kind"] == "grammar", fixture_id + action = fixture["action"] + expected = fixture["expected"] + fn = action["fn"] + + if fn == "validate_directive": + validated = validate_directive(action["text"]) + expected_validated = expected["validated"] + if expected_validated is None: + assert validated is None, fixture_id + else: + assert validated is not None, fixture_id + assert validated.text == expected_validated["text"], fixture_id + assert validated.kind.value == expected_validated["kind"], fixture_id + continue + + assert fn == "render_directive", fixture_id + rendered = render_directive(DirectiveKind(action["kind"]), **action["operands"]) + assert rendered == expected["text"], fixture_id + validated = validate_directive(rendered) + assert validated is not None, fixture_id + assert validated.kind.value == expected["validated_kind"], fixture_id diff --git a/tests/test_grammar.py b/tests/test_grammar.py new file mode 100644 index 0000000..bd2bb04 --- /dev/null +++ b/tests/test_grammar.py @@ -0,0 +1,202 @@ +from dataclasses import FrozenInstanceError +from types import MappingProxyType + +import pytest + +import context_compiler.grammar as grammar_module +from context_compiler.grammar import ( + DirectiveKind, + ValidatedDirective, + is_canonical_directive, + render_directive, + validate_directive, +) + + +def test_directive_kind_members_and_values() -> None: + assert [member.name for member in DirectiveKind] == [ + "SET_PREMISE", + "CHANGE_PREMISE", + "USE_ITEM", + "PROHIBIT_ITEM", + "REMOVE_POLICY", + "REPLACE_USE", + "CLEAR_PREMISE", + "RESET_POLICIES", + "CLEAR_STATE", + ] + assert [member.value for member in DirectiveKind] == [ + "set_premise", + "change_premise", + "use_item", + "prohibit_item", + "remove_policy", + "replace_use", + "clear_premise", + "reset_policies", + "clear_state", + ] + assert DirectiveKind("set_premise") is DirectiveKind.SET_PREMISE + + +def test_validated_directive_is_frozen_and_slotted() -> None: + validated = ValidatedDirective( + text="set premise concise replies", + kind=DirectiveKind.SET_PREMISE, + ) + assert validated.__slots__ == ("text", "kind") + with pytest.raises(FrozenInstanceError): + validated.text = "change premise to concise replies" # type: ignore[misc] + + +@pytest.mark.parametrize( + ("text", "expected_kind"), + [ + ("set premise concise replies", DirectiveKind.SET_PREMISE), + ("change premise to formal tone", DirectiveKind.CHANGE_PREMISE), + ("use docker", DirectiveKind.USE_ITEM), + ("prohibit peanuts", DirectiveKind.PROHIBIT_ITEM), + ("remove policy docker", DirectiveKind.REMOVE_POLICY), + ("use podman instead of docker", DirectiveKind.REPLACE_USE), + ("clear premise", DirectiveKind.CLEAR_PREMISE), + ("reset policies", DirectiveKind.RESET_POLICIES), + ("clear state", DirectiveKind.CLEAR_STATE), + ], +) +def test_validate_directive_accepts_each_canonical_family( + text: str, expected_kind: DirectiveKind +) -> None: + validated = validate_directive(text) + assert validated == ValidatedDirective(text=text, kind=expected_kind) + assert is_canonical_directive(text) is True + + +@pytest.mark.parametrize( + "text", + [ + "", + "hello there", + " set premise concise", + "Use docker", + "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", + "please use docker", + '"use docker and prohibit peanuts"', + ], +) +def test_validate_directive_rejects_non_canonical_inputs(text: str) -> None: + assert validate_directive(text) is None + assert is_canonical_directive(text) is False + + +@pytest.mark.parametrize( + ("kind", "operands", "expected"), + [ + (DirectiveKind.SET_PREMISE, {"value": "concise replies"}, "set premise concise replies"), + ( + 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.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"), + ], +) +def test_render_directive_outputs_exact_canonical_syntax( + kind: DirectiveKind, operands: dict[str, str], expected: str +) -> None: + rendered = render_directive(kind, **operands) + assert rendered == expected + validated = validate_directive(rendered) + assert validated is not None + assert validated.kind is kind + + +@pytest.mark.parametrize( + ("kind", "operands", "message"), + [ + (DirectiveKind.SET_PREMISE, {}, "Missing required operands"), + (DirectiveKind.REPLACE_USE, {"new_item": "podman"}, "Missing required operands"), + ( + DirectiveKind.CLEAR_STATE, + {"item": "docker"}, + "Unexpected operands", + ), + ( + DirectiveKind.USE_ITEM, + {"value": "docker"}, + "Missing required operands", + ), + ( + DirectiveKind.USE_ITEM, + {"item": "docker", "old_item": "podman"}, + "Unexpected operands", + ), + ( + DirectiveKind.SET_PREMISE, + {"value": ""}, + "cannot be empty", + ), + ( + DirectiveKind.SET_PREMISE, + {"value": " "}, + "cannot be empty", + ), + ( + DirectiveKind.USE_ITEM, + {"item": "docker and prohibit peanuts"}, + "canonical use_item directive", + ), + ( + DirectiveKind.SET_PREMISE, + {"value": "use docker and prohibit peanuts"}, + "canonical set_premise directive", + ), + ( + DirectiveKind.USE_ITEM, + {"item": "docker instead of podman"}, + "canonical use_item directive", + ), + ( + "not_a_directive_kind", # type: ignore[arg-type] + {"item": "docker"}, + "Unsupported directive kind", + ), + ], +) +def test_render_directive_rejects_invalid_operand_combinations( + kind: DirectiveKind | str, operands: dict[str, str], message: str +) -> None: + with pytest.raises(ValueError, match=message): + render_directive(kind, **operands) + + +def test_no_exported_mutable_grammar_registry() -> None: + assert "DIRECTIVE_SPECS" not in grammar_module.__all__ + assert "_DIRECTIVE_SPECS" not in grammar_module.__all__ + + +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] + with pytest.raises(FrozenInstanceError): + spec.kind = DirectiveKind.CHANGE_PREMISE # type: ignore[misc] diff --git a/tests/test_grammar_api_contract_fixture.py b/tests/test_grammar_api_contract_fixture.py new file mode 100644 index 0000000..1c0a8ac --- /dev/null +++ b/tests/test_grammar_api_contract_fixture.py @@ -0,0 +1,92 @@ +import inspect +import json +from pathlib import Path + +import context_compiler.grammar as grammar + +_CONTRACT_PATH = ( + Path(__file__).resolve().parent / "fixtures" / "conformance" / "api" / "public-grammar-v1.json" +) + + +def _load_contract() -> dict[str, object]: + return json.loads(_CONTRACT_PATH.read_text(encoding="utf-8")) + + +def _json_type_matches(value: object, expected: str) -> bool: + return { + "null": value is None, + "string": isinstance(value, str), + "object": isinstance(value, dict), + "array": isinstance(value, list), + "boolean": isinstance(value, bool), + "number": isinstance(value, int | float) and not isinstance(value, bool), + }[expected] + + +def _assert_shape(value: object, shape: dict[str, object]) -> None: + if "kind" in shape and shape["kind"] == "validated_directive": + assert isinstance(value, grammar.ValidatedDirective) + assert value.text == shape["text"] + assert value.kind.value == shape["directive_kind"] + return + + expected_types = shape["type"] + if isinstance(expected_types, str): + expected_types = [expected_types] + assert any(_json_type_matches(value, expected_type) for expected_type in expected_types) + + if "const" in shape: + assert value == shape["const"] + + if isinstance(value, dict): + required_keys = shape.get("required_keys", []) + assert set(required_keys).issubset(value) + properties = shape.get("properties", {}) + for key, property_shape in properties.items(): + if key in value: + _assert_shape(value[key], property_shape) + + +def _assert_signature_matches(obj: object, expected: dict[str, object], label: str) -> None: + signature = inspect.signature(obj) + params = list(signature.parameters.values()) + expected_params = expected["params"] + + assert len(params) == len(expected_params), label + for actual, expected_param in zip(params, expected_params, strict=True): + assert actual.name == expected_param["name"], label + assert actual.kind.name == expected_param["kind"], label + assert (actual.default is not inspect.Signature.empty) is expected_param["has_default"], ( + label + ) + + +def test_public_grammar_contract_matches_surface() -> None: + contract = _load_contract() + + exports = contract["exports"] + expected_names = exports["names"] + members = exports["members"] + + actual_names = sorted(name for name in grammar.__all__) + assert actual_names == sorted(expected_names) + + for name in expected_names: + assert hasattr(grammar, name), name + + for name, member in members.items(): + exported = getattr(grammar, name) + kind = member["kind"] + if kind == "callable": + assert inspect.isroutine(exported), name + _assert_signature_matches(exported, member["signature"], name) + for probe in member.get("shape_probes", []): + result = exported(*probe.get("args", []), **probe.get("kwargs", {})) + _assert_shape(result, probe["return_shape"]) + continue + if kind == "class": + assert inspect.isclass(exported), name + continue + assert kind == "constant", name + assert exported == member["value"], name From c088187cbebf0fb9fa7487816c174f50f67579b9 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 17 Jul 2026 01:05:06 -0400 Subject: [PATCH 2/2] test: add grammar contract coverage workflow --- .github/workflows/ci.yml | 23 +++++++++++++++++++++++ tests/test_grammar.py | 15 +++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b39dc64..3ab97a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,8 @@ jobs: run: uv run ruff format --check . - name: Install mypy demo dependencies + env: + PYO3_USE_ABI3_FORWARD_COMPATIBILITY: "1" run: uv pip install --python .venv/bin/python litellm - name: Mypy @@ -134,3 +136,24 @@ jobs: - name: Contract tests with strict controller coverage gate (100%) run: uv run pytest -m contract --cov=context_compiler.controller --cov-report=term-missing --cov-fail-under=100 + + contract-grammar-coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Python 3.12 + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Install dev dependencies + run: uv sync --extra dev + + - name: Contract tests with strict grammar coverage gate (100%) + run: uv run pytest tests/test_grammar.py tests/test_grammar_api_contract_fixture.py tests/test_fixtures.py::test_grammar_fixtures --cov=context_compiler.grammar --cov-report=term-missing --cov-fail-under=100 diff --git a/tests/test_grammar.py b/tests/test_grammar.py index bd2bb04..ca57944 100644 --- a/tests/test_grammar.py +++ b/tests/test_grammar.py @@ -200,3 +200,18 @@ def test_internal_grammar_specs_use_immutable_mapping() -> None: spec = specs[DirectiveKind.SET_PREMISE] with pytest.raises(FrozenInstanceError): spec.kind = DirectiveKind.CHANGE_PREMISE # type: ignore[misc] + + +def test_internal_canonical_start_match_rejects_out_of_range_positions() -> None: + assert grammar_module._match_canonical_directive_start("use docker", -1) is None + assert grammar_module._match_canonical_directive_start("use docker", len("use docker")) is None + + +def test_validate_directive_rejects_near_miss_without_required_delimiter() -> None: + assert validate_directive("clear statex") is None + assert validate_directive("usex docker") is None + + +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]