diff --git a/docs/DirectiveGrammarSpec.md b/docs/DirectiveGrammarSpec.md index 73969dc..89154b3 100644 --- a/docs/DirectiveGrammarSpec.md +++ b/docs/DirectiveGrammarSpec.md @@ -333,11 +333,10 @@ Rules: - must contain at least one non-whitespace character; - may contain spaces and punctuation; -- is opaque payload: directive keywords, conjunctions, and multiple sentences - inside `VALUE` are not inspected as embedded directives; +- is opaque for semantic interpretation: the grammar does not infer meaning + from ordinary words or sentence content inside `VALUE`; - has no quote-aware or escape-aware subgrammar; -- a separate canonical directive beginning on a new line is still rejected as - a compound attempt under Section 7.5. +- is subject to the compound-attempt rule in Section 7.5. Canonical meaning: @@ -455,23 +454,24 @@ contains more than one attempted directive clause. This includes inputs such as: - `use docker and prohibit peanuts` -- `set premise project deadline is Friday and use docker` is one premise directive; `VALUE` is - opaque and may contain policy words or conjunctions. +- `set premise project deadline is Friday and use docker` +- `set premise deployment target is staging, then use cautious rollout` - `clear state then set premise new project` This rule is lexical and grammar-level. It is not a semantic conflict rule. - -Premise `VALUE` is payload rather than a policy identity. The grammar does not -inspect embedded directive words, conjunctions, or sentence boundaries inside -it. A separate canonical directive beginning on a new line is still treated as -a compound attempt. `ITEM` operands retain the compound-detection behavior -described above. +For any operand-bearing directive, a recognized directive starter at a token +boundary within the operand makes the full input a compound attempt, even when +ordinary whitespace is the only separator. This applies to premise `VALUE` and +policy `ITEM` operands; no `and`, `then`, punctuation, or newline is required. +It does not interpret natural-language meaning. Examples: - no_directive: `"use docker and prohibit peanuts"` +- directive-shaped invalid: `set premise users use SSO` +- directive-shaped invalid: `change premise to users prohibit password authentication` - directive-shaped invalid: `use "docker and prohibit peanuts"` -- canonical directive: `set premise "use docker and prohibit peanuts"` +- directive-shaped invalid: `set premise "use docker and prohibit peanuts"` ## 8. Parsed Meaning and Semantic Boundary @@ -773,7 +773,7 @@ source material for later conformance fixtures. | `use docker and prohibit peanuts` | directive-shaped invalid input | none | compound attempt | | `clear state then set premise project` | directive-shaped invalid input | none | compound attempt | | `use "docker and prohibit peanuts"` | directive-shaped invalid input | none | quotes do not protect embedded directive text | -| `set premise "use docker and prohibit peanuts"` | canonical directive | set premise | premise `VALUE` is opaque payload, including quote characters | +| `set premise "use docker and prohibit peanuts"` | directive-shaped invalid | none | quotes do not protect embedded directive text | ## 12. Invariants diff --git a/src/context_compiler/grammar.py b/src/context_compiler/grammar.py index 73c993c..fd49a94 100644 --- a/src/context_compiler/grammar.py +++ b/src/context_compiler/grammar.py @@ -353,18 +353,6 @@ def _contains_multiple_canonical_directives(text: str) -> bool: return False -def _contains_multiple_premise_directives(text: str) -> bool: - """Report premise compounds without inspecting opaque premise payload text.""" - first_start = _match_canonical_directive_start(text, 0) - if first_start is None: - return False - - for index, character in enumerate(text[first_start:], start=first_start): - if character == "\n" and _match_canonical_directive_start(text, index + 1) is not None: - return True - return False - - def _starts_with_directive_family(text: str) -> bool: for token, require_space_or_end in _DIRECTIVE_FAMILY_STARTS: if ( @@ -473,12 +461,7 @@ def _validate_operand_constraints(kind: DirectiveKind, operands: Mapping[str, st def _validate_rendered_canonical_shape(kind: DirectiveKind, operands: Mapping[str, str]) -> None: rendered = _serialize_canonical_directive(kind, MappingProxyType(dict(operands))) - contains_compound = ( - _contains_multiple_premise_directives(rendered) - if kind in {DirectiveKind.SET_PREMISE, DirectiveKind.CHANGE_PREMISE} - else _contains_multiple_canonical_directives(rendered) - ) - if contains_compound: + if _contains_multiple_canonical_directives(rendered): raise ValueError(f"Operands do not produce a canonical {kind.value} directive.") @@ -497,15 +480,7 @@ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSynta return None if not _starts_with_directive_family(trimmed_text): return None - premise_directive = _match_directive_token( - trimmed_text, 0, _SET_PREMISE_START, require_space_or_end=True - ) or _match_directive_token(trimmed_text, 0, _CHANGE_PREMISE_START, require_space_or_end=True) - contains_compound = ( - _contains_multiple_premise_directives(trimmed_text) - if premise_directive is not None - else _contains_multiple_canonical_directives(trimmed_text) - ) - if contains_compound: + if _contains_multiple_canonical_directives(trimmed_text): return _invalid_directive_syntax(DirectiveSyntaxFailure.COMPOUND_DIRECTIVE) normalized = _normalized_for_matching(trimmed_text) diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_change_premise_compound_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_change_premise_compound_rejected.json new file mode 100644 index 0000000..093baee --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_change_premise_compound_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_change_premise_compound_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "change premise to vegetarian and use docker" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_change_premise_use_compound_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_change_premise_use_compound_rejected.json new file mode 100644 index 0000000..1d07bbc --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_change_premise_use_compound_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_change_premise_use_compound_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "change premise to deployment target is staging, then use cautious rollout" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_change_premise_users_prohibit_password_authentication_compound_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_change_premise_users_prohibit_password_authentication_compound_rejected.json new file mode 100644 index 0000000..d20f2d7 --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_change_premise_users_prohibit_password_authentication_compound_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_change_premise_users_prohibit_password_authentication_compound_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "change premise to users prohibit password authentication" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_opaque_change_premise_conjunction.json b/tests/fixtures/conformance/grammar/grammar_decompose_opaque_change_premise_conjunction.json deleted file mode 100644 index f8d9459..0000000 --- a/tests/fixtures/conformance/grammar/grammar_decompose_opaque_change_premise_conjunction.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": "grammar_decompose_opaque_change_premise_conjunction", - "kind": "grammar", - "action": { - "fn": "decompose_directive", - "text": "change premise to vegetarian and use docker" - }, - "expected": { - "directive": { - "text": "change premise to vegetarian and use docker", - "kind": "change_premise", - "operands": { - "value": "vegetarian and use docker" - } - } - } -} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_conjunction.json b/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_conjunction.json deleted file mode 100644 index c6da6f1..0000000 --- a/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_conjunction.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": "grammar_decompose_opaque_premise_conjunction", - "kind": "grammar", - "action": { - "fn": "decompose_directive", - "text": "set premise vegetarian and use docker" - }, - "expected": { - "directive": { - "text": "set premise vegetarian and use docker", - "kind": "set_premise", - "operands": { - "value": "vegetarian and use docker" - } - } - } -} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_prohibit.json b/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_prohibit.json deleted file mode 100644 index 4a9337a..0000000 --- a/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_prohibit.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": "grammar_decompose_opaque_premise_prohibit", - "kind": "grammar", - "action": { - "fn": "decompose_directive", - "text": "set premise users may prohibit unsafe operations" - }, - "expected": { - "directive": { - "text": "set premise users may prohibit unsafe operations", - "kind": "set_premise", - "operands": { - "value": "users may prohibit unsafe operations" - } - } - } -} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_use.json b/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_use.json deleted file mode 100644 index 27748c3..0000000 --- a/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_use.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": "grammar_decompose_opaque_premise_use", - "kind": "grammar", - "action": { - "fn": "decompose_directive", - "text": "set premise we must use gaap" - }, - "expected": { - "directive": { - "text": "set premise we must use gaap", - "kind": "set_premise", - "operands": { - "value": "we must use gaap" - } - } - } -} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_clear_premise_compound_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_clear_premise_compound_rejected.json new file mode 100644 index 0000000..938af49 --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_clear_premise_compound_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_set_premise_clear_premise_compound_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "set premise clear premise before release" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_clear_state_compound_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_clear_state_compound_rejected.json new file mode 100644 index 0000000..ca8eeae --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_clear_state_compound_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_set_premise_clear_state_compound_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "set premise clear state before starting" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_compound_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_compound_rejected.json new file mode 100644 index 0000000..4d8f291 --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_compound_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_set_premise_compound_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "set premise concise replies and change premise to formal tone" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_prohibit_compound_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_prohibit_compound_rejected.json new file mode 100644 index 0000000..70afeb7 --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_prohibit_compound_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_set_premise_prohibit_compound_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "set premise users may prohibit unsafe operations" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_remove_policy_compound_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_remove_policy_compound_rejected.json new file mode 100644 index 0000000..8966751 --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_remove_policy_compound_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_set_premise_remove_policy_compound_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "set premise cleanup requires remove policy docker" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_reset_policies_compound_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_reset_policies_compound_rejected.json new file mode 100644 index 0000000..557d233 --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_reset_policies_compound_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_set_premise_reset_policies_compound_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "set premise reset policies after migration" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_use_compound_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_use_compound_rejected.json new file mode 100644 index 0000000..7a7b4d6 --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_use_compound_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_set_premise_use_compound_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "set premise we must use gaap" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_users_use_sso_compound_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_users_use_sso_compound_rejected.json new file mode 100644 index 0000000..a4ad59a --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_users_use_sso_compound_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_set_premise_users_use_sso_compound_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "set premise users use SSO" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/step/step_compound_set_premise_and_use_invalid_boundary.json b/tests/fixtures/conformance/step/step_compound_set_premise_and_use_invalid_boundary.json index 73e61ae..89acb74 100644 --- a/tests/fixtures/conformance/step/step_compound_set_premise_and_use_invalid_boundary.json +++ b/tests/fixtures/conformance/step/step_compound_set_premise_and_use_invalid_boundary.json @@ -9,11 +9,10 @@ "input": "set premise vegetarian and use docker", "expected": { "decision": { - "kind": "update", - "changed": true + "kind": "no_directive" }, "state": { - "premise": "vegetarian and use docker", + "premise": null, "policies": {}, "version": 2 } diff --git a/tests/test_compound_directive_properties.py b/tests/test_compound_directive_properties.py index a20f575..669e5d5 100644 --- a/tests/test_compound_directive_properties.py +++ b/tests/test_compound_directive_properties.py @@ -8,7 +8,11 @@ DECISION_NO_DIRECTIVE, Engine, ) -from context_compiler.grammar import InvalidDirectiveSyntax, decompose_directive +from context_compiler.grammar import ( + DirectiveSyntaxFailure, + InvalidDirectiveSyntax, + decompose_directive, +) CANONICAL_SECOND_DIRECTIVES = [ "set premise concise", @@ -33,6 +37,8 @@ "clear state", ] +PREMISE_STARTS = ["set premise", "change premise to"] + SEPARATOR_CHARS = " \t\n\r,.;:!?-/()[]" LETTER_CHARS = string.ascii_lowercase @@ -62,6 +68,23 @@ def test_compound_separator_robustness(separator: str, second: str) -> None: _assert_compound_no_directive(user_input) +@settings(max_examples=50) +@given( + premise_start=st.sampled_from(PREMISE_STARTS), + separator=st.sampled_from([" ", " and ", ", then ", "\n"]), + second=st.sampled_from(CANONICAL_SECOND_DIRECTIVES), +) +def test_premise_payload_rejects_reserved_directive_starters( + premise_start: str, separator: str, second: str +) -> None: + user_input = f"{premise_start} deployment target is staging{separator}{second}" + + assert decompose_directive(user_input) == InvalidDirectiveSyntax( + failure=DirectiveSyntaxFailure.COMPOUND_DIRECTIVE, + ) + _assert_compound_no_directive(user_input) + + @settings(max_examples=50) @given( chunks=st.lists( diff --git a/tests/test_grammar.py b/tests/test_grammar.py index c4cace7..395953d 100644 --- a/tests/test_grammar.py +++ b/tests/test_grammar.py @@ -469,16 +469,30 @@ def test_internal_contains_multiple_canonical_directives_reports_compound_detect assert grammar_module._contains_multiple_canonical_directives(text) is expected -def test_internal_contains_multiple_premise_directives_ignores_non_directive_text() -> None: - assert grammar_module._contains_multiple_premise_directives("hello there") is False - - def test_multiple_premise_directives_are_rejected() -> None: assert decompose_directive("set premise first\nset premise second") == InvalidDirectiveSyntax( failure=DirectiveSyntaxFailure.COMPOUND_DIRECTIVE, ) +@pytest.mark.parametrize( + "text", + [ + "set premise concise replies and change premise to formal tone", + "change premise to deployment target is staging, then use cautious rollout", + "set premise users may prohibit unsafe operations", + "set premise cleanup requires remove policy docker", + "set premise clear premise before release", + "set premise reset policies after migration", + "set premise clear state before starting", + ], +) +def test_premise_directive_starters_are_rejected_as_compound(text: str) -> None: + assert decompose_directive(text) == InvalidDirectiveSyntax( + failure=DirectiveSyntaxFailure.COMPOUND_DIRECTIVE, + ) + + def test_parse_replace_use_rejects_blank_new_item() -> None: assert grammar_module._parse_replace_use("use \t instead of docker") is None