From 482d8640a031eb2fc21b50847bcb99c9fe2df259 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Mon, 3 Aug 2026 16:24:33 -0400 Subject: [PATCH] refactor: expand public grammar API --- src/context_compiler/grammar.py | 61 ++++++++++++---- .../conformance/api/public-grammar-v1.json | 72 +++++++++++++++++++ tests/test_grammar.py | 18 ++++- tests/test_grammar_api_contract_fixture.py | 9 +++ 4 files changed, 145 insertions(+), 15 deletions(-) diff --git a/src/context_compiler/grammar.py b/src/context_compiler/grammar.py index 833477b..5e33c0f 100644 --- a/src/context_compiler/grammar.py +++ b/src/context_compiler/grammar.py @@ -173,7 +173,14 @@ def _operand_starts_with_token(value: str, token: str) -> bool: return normalized == token or normalized.startswith(f"{token} ") -def _match_canonical_directive_start(text: str, start: int) -> int | None: +def match_canonical_directive_start(text: str, start: int) -> int | None: + """Locate a canonical directive prefix at a given character position. + + This classifies whether canonical directive syntax begins at ``start`` and, + when it does, returns the index immediately after the directive keyword + prefix. It does not parse operands, validate the full directive payload, or + evaluate multi-directive state semantics. + """ if start < 0 or start >= len(text): return None @@ -230,13 +237,20 @@ def _match_directive_token( return index -def _contains_multiple_canonical_directives(text: str) -> bool: - first_start = _match_canonical_directive_start(text, 0) +def contains_multiple_canonical_directives(text: str) -> bool: + """Report whether text contains more than one canonical directive start. + + This detects compound directive text by looking for multiple canonical + directive prefixes in the same input. It does not parse directive operands, + repair malformed text, or determine whether a whole string should be + accepted as a single directive. + """ + 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) + next_start = match_canonical_directive_start(text, index) if next_start is not None: return True @@ -266,10 +280,17 @@ def _parse_replace_use(trimmed_text: str) -> CanonicalDirective | None: def decompose_directive(text: str) -> CanonicalDirective | None: + """Parse one canonical directive into its semantic kind and operands. + + This determines whether ``text`` is a single canonical directive and, when + it is, returns the directive kind plus canonical operand names with the + original operand text preserved. It does not repair input, infer intent, or + evaluate directive effects against compiler state. + """ trimmed_text = _trim_ascii_whitespace(text) if trimmed_text == "": return None - if _contains_multiple_canonical_directives(trimmed_text): + if contains_multiple_canonical_directives(trimmed_text): return None normalized = _normalized_for_matching(trimmed_text) @@ -368,25 +389,37 @@ def decompose_directive(text: str) -> CanonicalDirective | None: def validate_directive(text: str) -> ValidatedDirective | None: + """Classify whether text is a canonical directive. + + This determines whether ``text`` belongs to one canonical directive family + and returns only the normalized semantic kind needed for classification. It + does not expose operands, render directives, repair malformed text, or + evaluate any state transition. + """ parsed = decompose_directive(text) if parsed is None: return None return ValidatedDirective(text=parsed.text, kind=parsed.kind) -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 whether text is exactly one canonical directive. + + This provides boolean directive classification for callers that only need a + yes-or-no answer. It does not parse operands, explain rejection reasons, or + perform validation beyond canonical grammar recognition. + """ return validate_directive(text) is not None def render_directive(kind: DirectiveKind, /, **operands: str) -> str: + """Render canonical directive text from a semantic kind and operands. + + This determines the exact canonical spelling for an existing grammar + capability and rejects operand combinations that would not round-trip as the + requested directive kind. It does not infer missing operands, parse user + input, or extend the grammar with new behaviors. + """ try: normalized_kind = kind if isinstance(kind, DirectiveKind) else DirectiveKind(kind) spec = _DIRECTIVE_SPECS[normalized_kind] @@ -425,8 +458,10 @@ def render_directive(kind: DirectiveKind, /, **operands: str) -> str: "CanonicalDirective", "DirectiveKind", "ValidatedDirective", + "contains_multiple_canonical_directives", "decompose_directive", "is_canonical_directive", + "match_canonical_directive_start", "render_directive", "validate_directive", ] diff --git a/tests/fixtures/conformance/api/public-grammar-v1.json b/tests/fixtures/conformance/api/public-grammar-v1.json index 8ce1897..169bee3 100644 --- a/tests/fixtures/conformance/api/public-grammar-v1.json +++ b/tests/fixtures/conformance/api/public-grammar-v1.json @@ -7,8 +7,10 @@ "CanonicalDirective", "DirectiveKind", "ValidatedDirective", + "contains_multiple_canonical_directives", "decompose_directive", "is_canonical_directive", + "match_canonical_directive_start", "render_directive", "validate_directive" ], @@ -22,6 +24,38 @@ "ValidatedDirective": { "kind": "class" }, + "contains_multiple_canonical_directives": { + "kind": "callable", + "signature": { + "params": [ + { + "name": "text", + "kind": "POSITIONAL_OR_KEYWORD", + "has_default": false + } + ] + }, + "shape_probes": [ + { + "kwargs": { + "text": "use docker and prohibit peanuts" + }, + "return_shape": { + "type": "boolean", + "const": true + } + }, + { + "kwargs": { + "text": "use docker" + }, + "return_shape": { + "type": "boolean", + "const": false + } + } + ] + }, "decompose_directive": { "kind": "callable", "signature": { @@ -80,6 +114,44 @@ } ] }, + "match_canonical_directive_start": { + "kind": "callable", + "signature": { + "params": [ + { + "name": "text", + "kind": "POSITIONAL_OR_KEYWORD", + "has_default": false + }, + { + "name": "start", + "kind": "POSITIONAL_OR_KEYWORD", + "has_default": false + } + ] + }, + "shape_probes": [ + { + "kwargs": { + "text": "please use docker", + "start": 7 + }, + "return_shape": { + "type": "number", + "const": 10 + } + }, + { + "kwargs": { + "text": "abuse docker", + "start": 1 + }, + "return_shape": { + "type": "null" + } + } + ] + }, "render_directive": { "kind": "callable", "signature": { diff --git a/tests/test_grammar.py b/tests/test_grammar.py index 16886a0..ae77411 100644 --- a/tests/test_grammar.py +++ b/tests/test_grammar.py @@ -279,8 +279,22 @@ def test_internal_grammar_specs_use_immutable_mapping() -> None: 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 + assert match_canonical_directive_start("use docker", -1) is None + assert match_canonical_directive_start("use docker", len("use docker")) is None + + +def test_public_grammar_all_includes_semantic_surface() -> None: + assert grammar_module.__all__ == [ + "CanonicalDirective", + "DirectiveKind", + "ValidatedDirective", + "contains_multiple_canonical_directives", + "decompose_directive", + "is_canonical_directive", + "match_canonical_directive_start", + "render_directive", + "validate_directive", + ] def test_validate_directive_rejects_near_miss_without_required_delimiter() -> None: diff --git a/tests/test_grammar_api_contract_fixture.py b/tests/test_grammar_api_contract_fixture.py index 31ad8ab..571eb5c 100644 --- a/tests/test_grammar_api_contract_fixture.py +++ b/tests/test_grammar_api_contract_fixture.py @@ -43,3 +43,12 @@ def test_public_grammar_contract_matches_surface() -> None: for probe in member.get("shape_probes", []): result = exported(*probe.get("args", []), **probe.get("kwargs", {})) assert_shape(result, probe["return_shape"]) + + +def test_public_grammar_contract_has_unique_entries() -> None: + contract = _load_contract() + export_names = contract["exports"]["names"] + members = contract["exports"]["members"] + + assert len(export_names) == len(set(export_names)) + assert set(members) == set(export_names)