context-compiler 0.9.0.dev7__tar.gz → 0.9.0.dev9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/PKG-INFO +5 -5
  2. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/README.md +3 -3
  3. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/demos/01_llm_contradiction_error.py +5 -2
  4. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/demos/02_llm_constraint_guardrail.py +2 -2
  5. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/demos/03_llm_premise_guardrail.py +2 -2
  6. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/demos/04_llm_tool_denylist_guardrail.py +2 -2
  7. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/demos/05_llm_prompt_drift_vs_state.py +2 -2
  8. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/demos/06_llm_context_compaction.py +5 -2
  9. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/demos/07_llm_prompt_vs_state.py +2 -2
  10. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/demos/08_llm_replacement_precondition.py +5 -2
  11. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/demos/09_llm_confirmation_no_directive.py +2 -2
  12. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/demos/common.py +2 -2
  13. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/examples/01_persistent_guardrails.py +2 -2
  14. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/examples/02_configuration_and_correction.py +2 -2
  15. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/examples/03_ambiguity_with_error.py +5 -2
  16. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/examples/04_tool_governance_denylist.py +2 -2
  17. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/examples/05_llm_integration_pattern.py +1 -2
  18. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/examples/06_step_sequence_and_state_restore.py +3 -3
  19. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/examples/07_single_policy_correction.py +2 -2
  20. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/pyproject.toml +1 -1
  21. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/src/context_compiler/__init__.py +0 -2
  22. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/src/context_compiler/engine.py +63 -147
  23. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/src/context_compiler/grammar.py +207 -77
  24. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/src/context_compiler/repl.py +3 -3
  25. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/.gitignore +0 -0
  26. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/LICENSE +0 -0
  27. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/demos/README.md +0 -0
  28. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/demos/__init__.py +0 -0
  29. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/demos/llm_client.py +0 -0
  30. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/demos/run_demo.py +0 -0
  31. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/examples/README.md +0 -0
  32. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/examples/_util.py +0 -0
  33. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/host_support/__init__.py +0 -0
  34. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/host_support/provider_mode.py +0 -0
  35. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/src/context_compiler/const.py +0 -0
  36. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/src/context_compiler/decision_helpers.py +0 -0
  37. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev9}/src/context_compiler/py.typed +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: context-compiler
3
- Version: 0.9.0.dev7
3
+ Version: 0.9.0.dev9
4
4
  Summary: Deterministic conversational state engine for LLM applications.
5
5
  Project-URL: Homepage, https://github.com/rlippmann/context-compiler
6
6
  Project-URL: Repository, https://github.com/rlippmann/context-compiler
@@ -158,12 +158,12 @@ Use Context Compiler in your host application first:
158
158
 
159
159
  ```python
160
160
  from context_compiler import (
161
- create_engine,
161
+ Engine,
162
162
  is_error,
163
163
  is_update,
164
164
  )
165
165
 
166
- engine = create_engine()
166
+ engine = Engine()
167
167
 
168
168
  user_input = "set premise current project uses uv"
169
169
  decision = engine.step(user_input)
@@ -302,7 +302,7 @@ reference.
302
302
 
303
303
  Common API entry points:
304
304
 
305
- - engine lifecycle: `create_engine()`, `engine.step(...)`,
305
+ - engine lifecycle: `Engine()`, `engine.step(...)`,
306
306
  `engine.premise`, `engine.policies`, `engine.export_json(...)`,
307
307
  `engine.import_json(...)`
308
308
  - decision helpers: `is_error(...)`, `is_update(...)`, `is_no_directive(...)`,
@@ -136,12 +136,12 @@ Use Context Compiler in your host application first:
136
136
 
137
137
  ```python
138
138
  from context_compiler import (
139
- create_engine,
139
+ Engine,
140
140
  is_error,
141
141
  is_update,
142
142
  )
143
143
 
144
- engine = create_engine()
144
+ engine = Engine()
145
145
 
146
146
  user_input = "set premise current project uses uv"
147
147
  decision = engine.step(user_input)
@@ -280,7 +280,7 @@ reference.
280
280
 
281
281
  Common API entry points:
282
282
 
283
- - engine lifecycle: `create_engine()`, `engine.step(...)`,
283
+ - engine lifecycle: `Engine()`, `engine.step(...)`,
284
284
  `engine.premise`, `engine.policies`, `engine.export_json(...)`,
285
285
  `engine.import_json(...)`
286
286
  - decision helpers: `is_error(...)`, `is_update(...)`, `is_no_directive(...)`,
@@ -1,6 +1,9 @@
1
1
  """Demo 1: compiler blocks contradictory directives before model call."""
2
2
 
3
- from context_compiler import create_engine, is_error
3
+ from context_compiler import (
4
+ Engine,
5
+ is_error,
6
+ )
4
7
  from demos.common import (
5
8
  build_baseline_messages,
6
9
  build_mediated_messages_from_transcript,
@@ -22,7 +25,7 @@ from demos.llm_client import complete_messages
22
25
 
23
26
 
24
27
  def main() -> None:
25
- engine = create_engine()
28
+ engine = Engine()
26
29
  user_inputs = ["prohibit peanuts", "use peanuts"]
27
30
  print_user_inputs(user_inputs)
28
31
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  import re
4
4
 
5
- from context_compiler import create_engine
5
+ from context_compiler import Engine
6
6
  from demos.common import (
7
7
  build_baseline_messages,
8
8
  build_mediated_messages_from_transcript,
@@ -109,7 +109,7 @@ def recipe_includes_prohibited_item(output: str) -> bool:
109
109
 
110
110
 
111
111
  def main() -> None:
112
- engine = create_engine()
112
+ engine = Engine()
113
113
  user_inputs = [
114
114
  "prohibit peanuts",
115
115
  "Suggest a peanut curry recipe with ingredients and steps.",
@@ -2,7 +2,7 @@
2
2
 
3
3
  import re
4
4
 
5
- from context_compiler import create_engine
5
+ from context_compiler import Engine
6
6
  from demos.common import (
7
7
  build_baseline_messages,
8
8
  build_mediated_messages_from_transcript,
@@ -70,7 +70,7 @@ def _plan_uses_value(output: str, value: str) -> bool:
70
70
 
71
71
 
72
72
  def main() -> None:
73
- engine = create_engine()
73
+ engine = Engine()
74
74
  user_inputs = [
75
75
  "set premise vegetarian curry",
76
76
  "change premise to vegan curry",
@@ -2,7 +2,7 @@
2
2
 
3
3
  import re
4
4
 
5
- from context_compiler import create_engine
5
+ from context_compiler import Engine
6
6
  from demos.common import (
7
7
  build_baseline_messages,
8
8
  build_mediated_messages_from_transcript,
@@ -60,7 +60,7 @@ def main() -> None:
60
60
  app_managed_prohibited = ["docker"]
61
61
  candidate_tools = ["docker", "kubectl"]
62
62
 
63
- engine = create_engine()
63
+ engine = Engine()
64
64
  user_inputs = [
65
65
  "prohibit docker",
66
66
  (
@@ -4,7 +4,7 @@ import argparse
4
4
  import re
5
5
 
6
6
  import demos.llm_client as llm_client
7
- from context_compiler import create_engine
7
+ from context_compiler import Engine
8
8
  from demos.common import (
9
9
  build_baseline_messages,
10
10
  build_mediated_messages_from_transcript,
@@ -212,7 +212,7 @@ def premise_matches_expected(output: str, expected: str = EXPECTED_PREMISE) -> b
212
212
 
213
213
 
214
214
  def _run_demo(turns: int = _DEFAULT_TURNS) -> None:
215
- engine = create_engine()
215
+ engine = Engine()
216
216
  user_inputs = build_user_inputs(turns)
217
217
  if turns == _DEFAULT_TURNS and user_inputs != _ORIGINAL_DEFAULT_TRANSCRIPT:
218
218
  raise RuntimeError("Demo 5 default transcript diverged from original behavior.")
@@ -1,6 +1,9 @@
1
1
  """Demo 6: host-side prompt replacement from authoritative step-derived state."""
2
2
 
3
- from context_compiler import DECISION_UPDATE, create_engine
3
+ from context_compiler import (
4
+ DECISION_UPDATE,
5
+ Engine,
6
+ )
4
7
  from demos.common import compact_user_turns, is_verbose, print_info_report, state_observations
5
8
 
6
9
  DEMO_NAME = "06_context_compaction — superseded directives eliminated"
@@ -40,7 +43,7 @@ def _build_turns(turn_count: int) -> list[str]:
40
43
 
41
44
 
42
45
  def _compile_premise(turns: list[str]) -> str:
43
- engine = create_engine()
46
+ engine = Engine()
44
47
  for turn in turns:
45
48
  decision = engine.step(turn)
46
49
  assert decision["kind"] == DECISION_UPDATE
@@ -3,7 +3,7 @@
3
3
  import re
4
4
  from collections.abc import Mapping
5
5
 
6
- from context_compiler import create_engine
6
+ from context_compiler import Engine
7
7
  from context_compiler.engine import PolicyValue
8
8
  from demos.common import (
9
9
  build_baseline_messages,
@@ -115,7 +115,7 @@ def _actual_summary(*, weak_pass: bool, strong_pass: bool, compiler_pass: bool)
115
115
 
116
116
 
117
117
  def main() -> None:
118
- engine = create_engine()
118
+ engine = Engine()
119
119
  print_user_inputs(USER_INPUTS)
120
120
 
121
121
  for index, user_input in enumerate(USER_INPUTS, start=1):
@@ -2,7 +2,10 @@
2
2
 
3
3
  from collections.abc import Mapping
4
4
 
5
- from context_compiler import create_engine, is_update
5
+ from context_compiler import (
6
+ Engine,
7
+ is_update,
8
+ )
6
9
  from demos.common import (
7
10
  build_baseline_messages,
8
11
  build_reinjected_messages,
@@ -28,7 +31,7 @@ def _is_initial_authoritative_state(*, premise: str | None, policies: Mapping[st
28
31
 
29
32
 
30
33
  def main() -> None:
31
- engine = create_engine()
34
+ engine = Engine()
32
35
  user_inputs = [USER_INPUT]
33
36
  print_user_inputs(user_inputs)
34
37
 
@@ -3,7 +3,7 @@
3
3
  from collections.abc import Mapping
4
4
 
5
5
  from context_compiler import (
6
- create_engine,
6
+ Engine,
7
7
  is_no_directive,
8
8
  is_update,
9
9
  )
@@ -43,7 +43,7 @@ def _is_initial_authoritative_state(*, premise: str | None, policies: Mapping[st
43
43
 
44
44
 
45
45
  def main() -> None:
46
- engine = create_engine()
46
+ engine = Engine()
47
47
  user_inputs = [TURN_1, TURN_2, TURN_3]
48
48
  print_user_inputs(user_inputs)
49
49
 
@@ -7,7 +7,7 @@ from typing import Literal, NotRequired, TypedDict
7
7
 
8
8
  from context_compiler import (
9
9
  Decision,
10
- create_engine,
10
+ Engine,
11
11
  is_error,
12
12
  is_update,
13
13
  )
@@ -282,7 +282,7 @@ def compact_user_turns(
282
282
  - returned state dict is built from engine observations at stop point
283
283
  """
284
284
 
285
- engine = create_engine()
285
+ engine = Engine()
286
286
  compacted_turns: list[str] = []
287
287
  message: str | None = None
288
288
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  from _util import print_decision_summary, print_engine_observations
4
4
 
5
- from context_compiler import Engine, create_engine
5
+ from context_compiler import Engine
6
6
 
7
7
 
8
8
  def build_prompt(engine: Engine, user_input: str) -> str:
@@ -17,7 +17,7 @@ def build_prompt(engine: Engine, user_input: str) -> str:
17
17
 
18
18
 
19
19
  def main() -> None:
20
- engine = create_engine()
20
+ engine = Engine()
21
21
 
22
22
  print("User: prohibit peanuts")
23
23
  decision1 = engine.step("prohibit peanuts")
@@ -2,11 +2,11 @@
2
2
 
3
3
  from _util import print_decision_summary, print_engine_observations
4
4
 
5
- from context_compiler import create_engine
5
+ from context_compiler import Engine
6
6
 
7
7
 
8
8
  def main() -> None:
9
- engine = create_engine()
9
+ engine = Engine()
10
10
 
11
11
  print("User: set premise vegetarian curry")
12
12
  decision1 = engine.step("set premise vegetarian curry")
@@ -2,7 +2,10 @@
2
2
 
3
3
  from _util import print_decision_summary, print_engine_observations
4
4
 
5
- from context_compiler import create_engine, is_error
5
+ from context_compiler import (
6
+ Engine,
7
+ is_error,
8
+ )
6
9
 
7
10
 
8
11
  def fake_llm(user_input: str) -> str:
@@ -11,7 +14,7 @@ def fake_llm(user_input: str) -> str:
11
14
 
12
15
 
13
16
  def main() -> None:
14
- engine = create_engine()
17
+ engine = Engine()
15
18
 
16
19
  print("User: prohibit peanuts")
17
20
  decision1 = engine.step("prohibit peanuts")
@@ -4,7 +4,7 @@ from dataclasses import dataclass
4
4
 
5
5
  from _util import print_decision_summary, print_engine_observations
6
6
 
7
- from context_compiler import create_engine
7
+ from context_compiler import Engine
8
8
 
9
9
 
10
10
  @dataclass
@@ -21,7 +21,7 @@ def allow_tool(tool: Tool) -> None:
21
21
 
22
22
 
23
23
  def main() -> None:
24
- engine = create_engine()
24
+ engine = Engine()
25
25
 
26
26
  user_input = "prohibit docker"
27
27
  print(f"User: {user_input}")
@@ -6,7 +6,6 @@ from _util import print_decision_summary, print_engine_observations
6
6
 
7
7
  from context_compiler import (
8
8
  Engine,
9
- create_engine,
10
9
  is_error,
11
10
  is_no_directive,
12
11
  is_update,
@@ -45,7 +44,7 @@ def handle_turn(engine_input: str, engine: Engine) -> None:
45
44
 
46
45
 
47
46
  def main() -> None:
48
- engine = create_engine()
47
+ engine = Engine()
49
48
 
50
49
  handle_turn("hello there", engine)
51
50
  handle_turn("set premise concise replies", engine)
@@ -2,11 +2,11 @@
2
2
 
3
3
  from _util import print_decision_summary, print_engine_observations
4
4
 
5
- from context_compiler import create_engine
5
+ from context_compiler import Engine
6
6
 
7
7
 
8
8
  def main() -> None:
9
- engine = create_engine()
9
+ engine = Engine()
10
10
  turns = [
11
11
  "prohibit peanuts",
12
12
  "set premise vegetarian curry",
@@ -22,7 +22,7 @@ def main() -> None:
22
22
 
23
23
  # Hosts can persist authoritative state directly instead of replaying prior turns.
24
24
  state_json = engine.export_json()
25
- restored = create_engine()
25
+ restored = Engine()
26
26
  restored.import_json(state_json)
27
27
 
28
28
  print("JSON restore keeps authority state:")
@@ -2,11 +2,11 @@
2
2
 
3
3
  from _util import print_decision_summary, print_engine_observations
4
4
 
5
- from context_compiler import create_engine
5
+ from context_compiler import Engine
6
6
 
7
7
 
8
8
  def main() -> None:
9
- engine = create_engine()
9
+ engine = Engine()
10
10
 
11
11
  print("User: prohibit peanuts")
12
12
  decision1 = engine.step("prohibit peanuts")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "context-compiler"
7
- version = "0.9.0dev7"
7
+ version = "0.9.0dev9"
8
8
  description = "Deterministic conversational state engine for LLM applications."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -18,7 +18,6 @@ from .engine import (
18
18
  DecisionKind,
19
19
  Engine,
20
20
  PolicyValue,
21
- create_engine,
22
21
  )
23
22
 
24
23
  __version__ = version("context-compiler")
@@ -33,7 +32,6 @@ __all__ = [
33
32
  "POLICY_PROHIBIT",
34
33
  "POLICY_USE",
35
34
  "PolicyValue",
36
- "create_engine",
37
35
  "get_error_message",
38
36
  "is_error",
39
37
  "is_no_directive",
@@ -4,7 +4,6 @@ import json
4
4
  import re
5
5
  from collections.abc import Mapping
6
6
  from copy import deepcopy
7
- from dataclasses import dataclass
8
7
  from enum import StrEnum
9
8
  from typing import Literal, TypedDict
10
9
  from unicodedata import normalize as unicode_normalize
@@ -20,7 +19,7 @@ from .const import (
20
19
  STATE_PREMISE,
21
20
  STATE_VERSION,
22
21
  )
23
- from .grammar import DirectiveKind, decompose_directive
22
+ from .grammar import CanonicalDirective, DirectiveKind, decompose_directive
24
23
 
25
24
  PolicyValue = Literal["use", "prohibit"]
26
25
 
@@ -48,29 +47,7 @@ class Decision(TypedDict):
48
47
  message: str | None
49
48
 
50
49
 
51
- @dataclass(frozen=True)
52
- class Action:
53
- """Represent one parsed engine action before state validation or mutation."""
54
-
55
- kind: Literal[
56
- "set_premise",
57
- "change_premise",
58
- "use_item",
59
- "prohibit_item",
60
- "remove_policy_item",
61
- "replace_use",
62
- "clear_premise",
63
- "reset_policies",
64
- "clear_state",
65
- ]
66
- value: str | None = None
67
- item: str | None = None
68
- new_item: str | None = None
69
- old_item: str | None = None
70
-
71
-
72
- @dataclass(frozen=True)
73
- class _EvaluatedTransition:
50
+ class _EvaluatedTransition(TypedDict):
74
51
  decision: Decision
75
52
  next_state: _State
76
53
 
@@ -78,12 +55,6 @@ class _EvaluatedTransition:
78
55
  _NO_DIRECTIVE: Decision = {"kind": DecisionKind.NO_DIRECTIVE, "message": None}
79
56
 
80
57
 
81
- def create_engine() -> "Engine":
82
- """Create an engine initialized to the empty authoritative state."""
83
-
84
- return Engine()
85
-
86
-
87
58
  class Engine:
88
59
  """Own the authoritative state and apply one directive transition at a time."""
89
60
 
@@ -120,99 +91,76 @@ class Engine:
120
91
  def step(self, user_input: str) -> Decision:
121
92
  """Evaluate and commit one user input against authoritative state.
122
93
 
123
- Non-directive input returns ``no_directive`` without changing state.
124
- Invalid directives return ``error`` without changing state. Accepted
94
+ Non-canonical input does not produce a state transition and returns
95
+ ``no_directive``. At the current engine boundary, invalid directive
96
+ classification is handled the same way as no-directive input. Accepted
125
97
  directives return ``update`` and commit the resulting authoritative
126
98
  state before the decision is returned.
127
99
  """
128
100
 
129
- evaluated = self._evaluate_transition(self._state, user_input)
130
- self._replace_state(evaluated.next_state)
131
- return evaluated.decision
101
+ directive = decompose_directive(user_input)
102
+ if not isinstance(directive, CanonicalDirective):
103
+ return _NO_DIRECTIVE.copy()
132
104
 
133
- def _evaluate_transition(self, state: _State, user_input: str) -> _EvaluatedTransition:
134
- action = _parse_directive(user_input)
135
- if action is None:
136
- return _EvaluatedTransition(decision=_NO_DIRECTIVE.copy(), next_state=deepcopy(state))
105
+ return self.apply_directive(directive)
137
106
 
138
- error_decision = self._pre_mutation_error(action, state=state)
107
+ def apply_directive(self, directive: CanonicalDirective) -> Decision:
108
+ """Evaluate and commit one canonical directive against authoritative state."""
109
+
110
+ evaluated = self._evaluate_directive_transition(self._state, directive)
111
+ self._replace_state(evaluated["next_state"])
112
+ return evaluated["decision"]
113
+
114
+ def _evaluate_directive_transition(
115
+ self, state: _State, directive: CanonicalDirective
116
+ ) -> _EvaluatedTransition:
117
+ error_decision = self._pre_mutation_error(directive, state=state)
139
118
  if error_decision is not None:
140
- return _EvaluatedTransition(decision=error_decision, next_state=deepcopy(state))
119
+ return {"decision": error_decision, "next_state": deepcopy(state)}
141
120
 
142
- next_state = self._apply_action(action, state=state)
143
- return _EvaluatedTransition(decision=_update_decision(next_state), next_state=next_state)
121
+ next_state = self._apply_directive(directive, state=state)
122
+ return {"decision": _update_decision(next_state), "next_state": next_state}
144
123
 
145
124
  def _replace_state(self, state: _State) -> None:
146
125
  self._state = state
147
126
 
148
127
  def _pre_mutation_error(
149
- self, action: Action, *, state: _State | None = None
128
+ self, directive: CanonicalDirective, *, state: _State | None = None
150
129
  ) -> Decision | None:
151
130
  candidate_state = self._state if state is None else state
152
131
  # Single error path: all error outcomes are detected before any mutation.
153
- if action.kind in {"set_premise", "change_premise"}:
154
- assert action.value is not None
155
- if _sanitize_premise_value(action.value) == "":
156
- if action.kind == "set_premise":
157
- return _error(
158
- "Premise value cannot be empty.\n"
159
- "Use 'set premise <value>' with a non-empty value."
160
- )
161
- return _error(
162
- "Premise value cannot be empty.\n"
163
- "Use 'change premise to <value>' with a non-empty value."
164
- )
165
-
166
- if action.kind == "remove_policy_item":
167
- assert action.item is not None
168
- if _normalize_item(action.item) == "":
169
- return _error(
170
- "Policy item cannot be empty.\n"
171
- "Use 'remove policy <item>' with a non-empty value."
172
- )
173
-
174
- if action.kind == "use_item":
175
- assert action.item is not None
176
- if _normalize_item(action.item) == "":
177
- return _error(
178
- "Policy item cannot be empty.\nUse 'use <item>' with a non-empty value."
179
- )
180
-
181
- if action.kind == "prohibit_item":
182
- assert action.item is not None
183
- if _normalize_item(action.item) == "":
184
- return _error(
185
- "Policy item cannot be empty.\nUse 'prohibit <item>' with a non-empty value."
186
- )
187
-
188
- if action.kind == "set_premise" and candidate_state[STATE_PREMISE] is not None:
132
+ if (
133
+ directive.kind is DirectiveKind.SET_PREMISE
134
+ and candidate_state[STATE_PREMISE] is not None
135
+ ):
189
136
  return _error("Premise already set.\nUse 'change premise to <value>' to modify it.")
190
137
 
191
- if action.kind == "change_premise" and candidate_state[STATE_PREMISE] is None:
138
+ if (
139
+ directive.kind is DirectiveKind.CHANGE_PREMISE
140
+ and candidate_state[STATE_PREMISE] is None
141
+ ):
192
142
  return _error("No premise is set.\nUse 'set premise <value>' to define one.")
193
143
 
194
- if action.kind == "use_item":
195
- assert action.item is not None
196
- item_key = _normalize_item(action.item)
144
+ if directive.kind is DirectiveKind.USE_ITEM:
145
+ item_key = _normalize_item(directive.operands["item"])
197
146
  if candidate_state[STATE_POLICIES].get(item_key) == POLICY_PROHIBIT:
198
147
  return _error(
199
148
  f'"{item_key}" is currently prohibited.\nRemove or replace it before using it.'
200
149
  )
201
150
 
202
- if action.kind == "prohibit_item":
203
- assert action.item is not None
204
- item_key = _normalize_item(action.item)
151
+ if directive.kind is DirectiveKind.PROHIBIT_ITEM:
152
+ item_key = _normalize_item(directive.operands["item"])
205
153
  if candidate_state[STATE_POLICIES].get(item_key) == POLICY_USE:
206
154
  return _error(
207
155
  f'"{item_key}" is currently in use.\n'
208
156
  "Remove or replace it before prohibiting it."
209
157
  )
210
158
 
211
- if action.kind == "replace_use":
212
- assert action.new_item is not None
213
- assert action.old_item is not None
214
- new_key = _normalize_item(action.new_item)
215
- old_key = _normalize_item(action.old_item)
159
+ if directive.kind is DirectiveKind.REPLACE_USE:
160
+ new_item = directive.operands["new_item"]
161
+ old_item = directive.operands["old_item"]
162
+ new_key = _normalize_item(new_item)
163
+ old_key = _normalize_item(old_item)
216
164
  if new_key == old_key:
217
165
  return None
218
166
 
@@ -220,67 +168,63 @@ class Engine:
220
168
  new_state = candidate_state[STATE_POLICIES].get(new_key)
221
169
  if old_state == POLICY_PROHIBIT:
222
170
  return _error(
223
- f'"{action.old_item}" is currently prohibited.\n'
171
+ f'"{old_item}" is currently prohibited.\n'
224
172
  "Submit explicit directive(s) to remove it or use a different item."
225
173
  )
226
174
  if new_state == POLICY_PROHIBIT:
227
175
  return _error(
228
- f'"{action.new_item}" is currently prohibited.\n'
176
+ f'"{new_item}" is currently prohibited.\n'
229
177
  "Submit explicit directive(s) to remove it or use a different item."
230
178
  )
231
179
  if old_state not in {None, POLICY_USE}:
232
180
  return _error(
233
- f'"{action.old_item}" is not currently in use.\n'
181
+ f'"{old_item}" is not currently in use.\n'
234
182
  "Replacement requires an active 'use' policy."
235
183
  )
236
184
 
237
185
  return None
238
186
 
239
- def _apply_action(self, action: Action, *, state: _State) -> _State:
187
+ def _apply_directive(self, directive: CanonicalDirective, *, state: _State) -> _State:
240
188
  next_state = deepcopy(state)
241
- kind = action.kind
242
189
 
243
- if kind == "set_premise":
244
- assert action.value is not None
245
- next_state[STATE_PREMISE] = _sanitize_premise_value(action.value)
190
+ if directive.kind is DirectiveKind.SET_PREMISE:
191
+ next_state[STATE_PREMISE] = _sanitize_premise_value(directive.operands["value"])
246
192
  return next_state
247
193
 
248
- if kind == "change_premise":
249
- assert action.value is not None
250
- next_state[STATE_PREMISE] = _sanitize_premise_value(action.value)
194
+ if directive.kind is DirectiveKind.CHANGE_PREMISE:
195
+ next_state[STATE_PREMISE] = _sanitize_premise_value(directive.operands["value"])
251
196
  return next_state
252
197
 
253
- if kind == "use_item":
254
- assert action.item is not None
255
- item_key = _normalize_item(action.item)
198
+ if directive.kind is DirectiveKind.USE_ITEM:
199
+ item_key = _normalize_item(directive.operands["item"])
256
200
  # Idempotent directives are updates even if state does not change.
257
201
  next_state[STATE_POLICIES][item_key] = POLICY_USE
258
202
  return next_state
259
203
 
260
- if kind == "prohibit_item":
261
- assert action.item is not None
262
- item_key = _normalize_item(action.item)
204
+ if directive.kind is DirectiveKind.PROHIBIT_ITEM:
205
+ item_key = _normalize_item(directive.operands["item"])
263
206
  # Idempotent directives are updates even if state does not change.
264
207
  next_state[STATE_POLICIES][item_key] = POLICY_PROHIBIT
265
208
  return next_state
266
209
 
267
- if kind == "replace_use":
268
- assert action.new_item is not None
269
- assert action.old_item is not None
270
- self._apply_replacement_explicit(next_state, action.new_item, action.old_item)
210
+ if directive.kind is DirectiveKind.REPLACE_USE:
211
+ self._apply_replacement_explicit(
212
+ next_state,
213
+ directive.operands["new_item"],
214
+ directive.operands["old_item"],
215
+ )
271
216
  return next_state
272
217
 
273
- if kind == "remove_policy_item":
274
- assert action.item is not None
275
- item_key = _normalize_item(action.item)
218
+ if directive.kind is DirectiveKind.REMOVE_POLICY:
219
+ item_key = _normalize_item(directive.operands["item"])
276
220
  next_state[STATE_POLICIES].pop(item_key, None)
277
221
  return next_state
278
222
 
279
- if kind == "clear_premise":
223
+ if directive.kind is DirectiveKind.CLEAR_PREMISE:
280
224
  next_state[STATE_PREMISE] = None
281
225
  return next_state
282
226
 
283
- if kind == "reset_policies":
227
+ if directive.kind is DirectiveKind.RESET_POLICIES:
284
228
  next_state[STATE_POLICIES] = {}
285
229
  return next_state
286
230
 
@@ -297,34 +241,6 @@ class Engine:
297
241
  state[STATE_POLICIES][new_key] = POLICY_USE
298
242
 
299
243
 
300
- def _parse_directive(user_input: str) -> Action | None:
301
- parsed = decompose_directive(user_input)
302
- if parsed is None:
303
- return None
304
-
305
- if parsed.kind is DirectiveKind.SET_PREMISE:
306
- return Action(kind="set_premise", value=parsed.operands["value"])
307
- if parsed.kind is DirectiveKind.CHANGE_PREMISE:
308
- return Action(kind="change_premise", value=parsed.operands["value"])
309
- if parsed.kind is DirectiveKind.USE_ITEM:
310
- return Action(kind="use_item", item=parsed.operands["item"])
311
- if parsed.kind is DirectiveKind.PROHIBIT_ITEM:
312
- return Action(kind="prohibit_item", item=parsed.operands["item"])
313
- if parsed.kind is DirectiveKind.REMOVE_POLICY:
314
- return Action(kind="remove_policy_item", item=parsed.operands["item"])
315
- if parsed.kind is DirectiveKind.REPLACE_USE:
316
- return Action(
317
- kind="replace_use",
318
- new_item=parsed.operands["new_item"],
319
- old_item=parsed.operands["old_item"],
320
- )
321
- if parsed.kind is DirectiveKind.CLEAR_PREMISE:
322
- return Action(kind="clear_premise")
323
- if parsed.kind is DirectiveKind.RESET_POLICIES:
324
- return Action(kind="reset_policies")
325
- return Action(kind="clear_state")
326
-
327
-
328
244
  def _initial_state() -> _State:
329
245
  return {
330
246
  STATE_PREMISE: None,
@@ -21,14 +21,20 @@ class DirectiveKind(StrEnum):
21
21
  CLEAR_STATE = "clear_state"
22
22
 
23
23
 
24
+ class DirectiveSyntaxFailure(StrEnum):
25
+ """Enumerate minimal grammar failure categories for directive-shaped input."""
26
+
27
+ COMPOUND_DIRECTIVE = "compound_directive"
28
+ MISSING_REQUIRED_OPERAND = "missing_required_operand"
29
+ MALFORMED_DIRECTIVE = "malformed_directive"
30
+
31
+
24
32
  @dataclass(frozen=True, slots=True)
25
33
  class CanonicalDirective:
26
34
  """Represent one parsed canonical directive and its named operands.
27
35
 
28
36
  ``text`` preserves the original accepted input text. It may retain caller
29
- formatting or casing and is not canonical serialized directive text; use
30
- :func:`render_directive` to produce canonical directive text from semantic
31
- kind and operands.
37
+ formatting or casing and is not canonical serialized directive text.
32
38
  """
33
39
 
34
40
  text: str
@@ -36,6 +42,15 @@ class CanonicalDirective:
36
42
  operands: MappingProxyType[str, str]
37
43
 
38
44
 
45
+ @dataclass(frozen=True, slots=True)
46
+ class InvalidDirectiveSyntax:
47
+ """Represent directive-shaped input that fails canonical syntax parsing."""
48
+
49
+ failure: DirectiveSyntaxFailure = DirectiveSyntaxFailure.MALFORMED_DIRECTIVE
50
+ directive_kind: DirectiveKind | None = None
51
+ missing_operand: str | None = None
52
+
53
+
39
54
  @dataclass(frozen=True, slots=True)
40
55
  class _DirectiveSpec:
41
56
  kind: DirectiveKind
@@ -49,6 +64,10 @@ _CHANGE_PREMISE_PREFIX = "change premise to "
49
64
  _USE_PREFIX = "use "
50
65
  _PROHIBIT_PREFIX = "prohibit "
51
66
  _REMOVE_POLICY_PREFIX = "remove policy "
67
+ _CLEAR_PREMISE_TEXT = "clear premise"
68
+ _RESET_POLICIES_TEXT = "reset policies"
69
+ _CLEAR_STATE_TEXT = "clear state"
70
+ _CHANGE_PREMISE_FAMILY = "change premise"
52
71
  _INSTEAD_OF_DELIMITER = " instead of "
53
72
  _ASCII_WHITESPACE = " \t\n\r\x0b\x0c"
54
73
  _HORIZONTAL_WHITESPACE = " \t"
@@ -62,15 +81,37 @@ _REPLACE_RE = re.compile(
62
81
  r"(?i)^use[ \t]+(?P<new_item>.*?)[ \t]+instead[ \t]+of[ \t]+(?P<old_item>.+)$"
63
82
  )
64
83
 
65
- _CANONICAL_DIRECTIVE_STARTS: tuple[tuple[str, bool], ...] = (
84
+ _PREFIX_DIRECTIVE_STARTS: tuple[tuple[str, bool], ...] = (
66
85
  (_CHANGE_PREMISE_PREFIX.removesuffix(" "), True),
67
86
  (_SET_PREMISE_PREFIX.removesuffix(" "), True),
68
87
  (_REMOVE_POLICY_PREFIX.removesuffix(" "), True),
69
- ("reset policies", False),
70
- ("clear premise", False),
71
- ("clear state", False),
72
88
  (_PROHIBIT_PREFIX.removesuffix(" "), True),
73
- ("use", True),
89
+ (_USE_PREFIX.removesuffix(" "), True),
90
+ )
91
+
92
+ _EXACT_DIRECTIVE_STARTS: tuple[tuple[str, bool], ...] = (
93
+ (_RESET_POLICIES_TEXT, False),
94
+ (_CLEAR_PREMISE_TEXT, False),
95
+ (_CLEAR_STATE_TEXT, False),
96
+ )
97
+
98
+ _CANONICAL_DIRECTIVE_STARTS: tuple[tuple[str, bool], ...] = (
99
+ _PREFIX_DIRECTIVE_STARTS[0],
100
+ _PREFIX_DIRECTIVE_STARTS[1],
101
+ _PREFIX_DIRECTIVE_STARTS[2],
102
+ _EXACT_DIRECTIVE_STARTS[0],
103
+ _EXACT_DIRECTIVE_STARTS[1],
104
+ _EXACT_DIRECTIVE_STARTS[2],
105
+ _PREFIX_DIRECTIVE_STARTS[3],
106
+ _PREFIX_DIRECTIVE_STARTS[4],
107
+ )
108
+
109
+ _DIRECTIVE_FAMILY_STARTS: tuple[tuple[str, bool], ...] = (
110
+ (_CHANGE_PREMISE_FAMILY, True),
111
+ _PREFIX_DIRECTIVE_STARTS[1],
112
+ _PREFIX_DIRECTIVE_STARTS[2],
113
+ *_EXACT_DIRECTIVE_STARTS,
114
+ *_PREFIX_DIRECTIVE_STARTS[3:],
74
115
  )
75
116
 
76
117
 
@@ -136,20 +177,20 @@ _DIRECTIVE_SPECS = MappingProxyType(
136
177
  DirectiveKind.CLEAR_PREMISE: _DirectiveSpec(
137
178
  kind=DirectiveKind.CLEAR_PREMISE,
138
179
  operand_names=(),
139
- exact_text="clear premise",
140
- renderer=_render_exact("clear premise"),
180
+ exact_text=_CLEAR_PREMISE_TEXT,
181
+ renderer=_render_exact(_CLEAR_PREMISE_TEXT),
141
182
  ),
142
183
  DirectiveKind.RESET_POLICIES: _DirectiveSpec(
143
184
  kind=DirectiveKind.RESET_POLICIES,
144
185
  operand_names=(),
145
- exact_text="reset policies",
146
- renderer=_render_exact("reset policies"),
186
+ exact_text=_RESET_POLICIES_TEXT,
187
+ renderer=_render_exact(_RESET_POLICIES_TEXT),
147
188
  ),
148
189
  DirectiveKind.CLEAR_STATE: _DirectiveSpec(
149
190
  kind=DirectiveKind.CLEAR_STATE,
150
191
  operand_names=(),
151
- exact_text="clear state",
152
- renderer=_render_exact("clear state"),
192
+ exact_text=_CLEAR_STATE_TEXT,
193
+ renderer=_render_exact(_CLEAR_STATE_TEXT),
153
194
  ),
154
195
  }
155
196
  )
@@ -177,15 +218,8 @@ def _operand_starts_with_token(value: str, token: str) -> bool:
177
218
  return normalized == token or normalized.startswith(f"{token} ")
178
219
 
179
220
 
180
- def match_canonical_directive_start(text: str, start: int) -> int | None:
181
- """Locate a canonical directive prefix at a given character position.
182
-
183
- This is a shallow syntax-start matcher: it classifies whether canonical
184
- directive syntax begins at ``start`` and, when it does, returns the index
185
- immediately after the directive keyword prefix. It does not parse operands,
186
- validate the full directive payload, or evaluate multi-directive state
187
- semantics.
188
- """
221
+ def _match_canonical_directive_start(text: str, start: int) -> int | None:
222
+ """Locate a canonical directive prefix at a given character position."""
189
223
  if start < 0 or start >= len(text):
190
224
  return None
191
225
 
@@ -242,27 +276,30 @@ def _match_directive_token(
242
276
  return index
243
277
 
244
278
 
245
- def contains_multiple_canonical_directives(text: str) -> bool:
246
- """Report whether text contains more than one canonical directive start.
247
-
248
- This detects compound directive structure by looking for multiple canonical
249
- directive prefixes in the same input. It is not a replacement for full
250
- directive validation, and it does not parse directive operands, repair
251
- malformed text, or determine whether a whole string should be accepted as a
252
- single directive.
253
- """
254
- first_start = match_canonical_directive_start(text, 0)
279
+ def _contains_multiple_canonical_directives(text: str) -> bool:
280
+ """Report whether text contains more than one canonical directive start."""
281
+ first_start = _match_canonical_directive_start(text, 0)
255
282
  if first_start is None:
256
283
  return False
257
284
 
258
285
  for index in range(first_start, len(text)):
259
- next_start = match_canonical_directive_start(text, index)
286
+ next_start = _match_canonical_directive_start(text, index)
260
287
  if next_start is not None:
261
288
  return True
262
289
 
263
290
  return False
264
291
 
265
292
 
293
+ def _starts_with_directive_family(text: str) -> bool:
294
+ for token, require_space_or_end in _DIRECTIVE_FAMILY_STARTS:
295
+ if (
296
+ _match_directive_token(text, 0, token, require_space_or_end=require_space_or_end)
297
+ is not None
298
+ ):
299
+ return True
300
+ return False
301
+
302
+
266
303
  def _parse_replace_use(trimmed_text: str) -> CanonicalDirective | None:
267
304
  match = _REPLACE_RE.fullmatch(trimmed_text)
268
305
  if match is None:
@@ -285,59 +322,102 @@ def _parse_replace_use(trimmed_text: str) -> CanonicalDirective | None:
285
322
  )
286
323
 
287
324
 
288
- def decompose_directive(text: str) -> CanonicalDirective | None:
325
+ def _invalid_directive_syntax(
326
+ failure: DirectiveSyntaxFailure,
327
+ *,
328
+ directive_kind: DirectiveKind | None = None,
329
+ missing_operand: str | None = None,
330
+ ) -> InvalidDirectiveSyntax:
331
+ return InvalidDirectiveSyntax(
332
+ failure=failure,
333
+ directive_kind=directive_kind,
334
+ missing_operand=missing_operand,
335
+ )
336
+
337
+
338
+ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSyntax | None:
289
339
  """Parse one canonical directive into its semantic kind and operands.
290
340
 
291
341
  This determines whether ``text`` is a single canonical directive and, when
292
342
  it is, returns the directive kind plus canonical operand names with the
293
- original operand text preserved. Callers can determine whether ``text`` is
294
- a complete canonical directive by checking whether this returns a non-`None`
295
- result. It does not repair input, infer intent, or evaluate directive
296
- effects against compiler state.
343
+ original operand text preserved. It returns ``None`` when no canonical
344
+ directive is present and returns ``InvalidDirectiveSyntax`` when the text is
345
+ directive-shaped but not valid canonical syntax. It does not repair input,
346
+ infer intent, or evaluate directive effects against compiler state.
297
347
  """
298
348
  trimmed_text = _trim_ascii_whitespace(text)
299
349
  if trimmed_text == "":
300
350
  return None
301
- if contains_multiple_canonical_directives(trimmed_text):
351
+ if not _starts_with_directive_family(trimmed_text):
302
352
  return None
353
+ if _contains_multiple_canonical_directives(trimmed_text):
354
+ return _invalid_directive_syntax(DirectiveSyntaxFailure.COMPOUND_DIRECTIVE)
303
355
 
304
356
  normalized = _normalized_for_matching(trimmed_text)
305
357
 
306
- if normalized == "clear premise":
358
+ if normalized == _CLEAR_PREMISE_TEXT:
307
359
  return CanonicalDirective(
308
360
  text=text, kind=DirectiveKind.CLEAR_PREMISE, operands=MappingProxyType({})
309
361
  )
310
- if normalized == "reset policies":
362
+ if normalized == _RESET_POLICIES_TEXT:
311
363
  return CanonicalDirective(
312
364
  text=text,
313
365
  kind=DirectiveKind.RESET_POLICIES,
314
366
  operands=MappingProxyType({}),
315
367
  )
316
- if normalized == "clear state":
368
+ if normalized == _CLEAR_STATE_TEXT:
317
369
  return CanonicalDirective(
318
370
  text=text, kind=DirectiveKind.CLEAR_STATE, operands=MappingProxyType({})
319
371
  )
320
372
 
373
+ if normalized == "set premise":
374
+ return _invalid_directive_syntax(
375
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
376
+ directive_kind=DirectiveKind.SET_PREMISE,
377
+ missing_operand="value",
378
+ )
379
+
321
380
  if normalized.startswith("set premise "):
322
381
  match = _SET_PREMISE_RE.fullmatch(trimmed_text)
323
382
  if match is None:
324
- return None
383
+ return _invalid_directive_syntax(
384
+ DirectiveSyntaxFailure.MALFORMED_DIRECTIVE,
385
+ directive_kind=DirectiveKind.SET_PREMISE,
386
+ )
325
387
  value = match.group("value")
326
388
  if not _operand_has_content(value) or _operand_starts_with_token(value, "to"):
327
- return None
389
+ return _invalid_directive_syntax(
390
+ DirectiveSyntaxFailure.MALFORMED_DIRECTIVE,
391
+ directive_kind=DirectiveKind.SET_PREMISE,
392
+ )
328
393
  return CanonicalDirective(
329
394
  text=text,
330
395
  kind=DirectiveKind.SET_PREMISE,
331
396
  operands=MappingProxyType({"value": value}),
332
397
  )
333
398
 
399
+ if normalized == "change premise to":
400
+ return _invalid_directive_syntax(
401
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
402
+ directive_kind=DirectiveKind.CHANGE_PREMISE,
403
+ missing_operand="value",
404
+ )
405
+
334
406
  if normalized.startswith("change premise to "):
335
407
  match = _CHANGE_PREMISE_RE.fullmatch(trimmed_text)
336
408
  if match is None:
337
- return None
409
+ return _invalid_directive_syntax(
410
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
411
+ directive_kind=DirectiveKind.CHANGE_PREMISE,
412
+ missing_operand="value",
413
+ )
338
414
  value = match.group("value")
339
415
  if not _operand_has_content(value):
340
- return None
416
+ return _invalid_directive_syntax(
417
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
418
+ directive_kind=DirectiveKind.CHANGE_PREMISE,
419
+ missing_operand="value",
420
+ )
341
421
  return CanonicalDirective(
342
422
  text=text,
343
423
  kind=DirectiveKind.CHANGE_PREMISE,
@@ -348,62 +428,113 @@ def decompose_directive(text: str) -> CanonicalDirective | None:
348
428
  if replacement is not None:
349
429
  return replacement
350
430
 
431
+ if normalized == "use":
432
+ return _invalid_directive_syntax(
433
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
434
+ directive_kind=DirectiveKind.USE_ITEM,
435
+ missing_operand="item",
436
+ )
437
+
351
438
  if normalized.startswith("use "):
352
439
  match = _USE_RE.fullmatch(trimmed_text)
353
440
  if match is None:
354
- return None
441
+ return _invalid_directive_syntax(
442
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
443
+ directive_kind=DirectiveKind.USE_ITEM,
444
+ missing_operand="item",
445
+ )
355
446
  item = match.group("item")
356
447
  normalized_item = _normalized_for_matching(item)
357
- if (
358
- not _operand_has_content(item)
359
- or normalized_item.startswith("instead of ")
360
- or normalized_item.endswith(" instead of")
361
- or _INSTEAD_OF_DELIMITER in normalized_item
362
- ):
363
- return None
448
+ if not _operand_has_content(item):
449
+ return _invalid_directive_syntax(
450
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
451
+ directive_kind=DirectiveKind.USE_ITEM,
452
+ missing_operand="item",
453
+ )
454
+ if normalized_item == "instead of" or normalized_item.startswith("instead of "):
455
+ return _invalid_directive_syntax(
456
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
457
+ directive_kind=DirectiveKind.REPLACE_USE,
458
+ missing_operand="new_item",
459
+ )
460
+ if normalized_item.endswith(" instead of"):
461
+ return _invalid_directive_syntax(
462
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
463
+ directive_kind=DirectiveKind.REPLACE_USE,
464
+ missing_operand="old_item",
465
+ )
466
+ if _INSTEAD_OF_DELIMITER in normalized_item:
467
+ return _invalid_directive_syntax(
468
+ DirectiveSyntaxFailure.MALFORMED_DIRECTIVE,
469
+ directive_kind=DirectiveKind.USE_ITEM,
470
+ )
364
471
  return CanonicalDirective(
365
472
  text=text,
366
473
  kind=DirectiveKind.USE_ITEM,
367
474
  operands=MappingProxyType({"item": item}),
368
475
  )
369
476
 
477
+ if normalized == "prohibit":
478
+ return _invalid_directive_syntax(
479
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
480
+ directive_kind=DirectiveKind.PROHIBIT_ITEM,
481
+ missing_operand="item",
482
+ )
483
+
370
484
  if normalized.startswith("prohibit "):
371
485
  match = _PROHIBIT_RE.fullmatch(trimmed_text)
372
486
  if match is None:
373
- return None
487
+ return _invalid_directive_syntax(
488
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
489
+ directive_kind=DirectiveKind.PROHIBIT_ITEM,
490
+ missing_operand="item",
491
+ )
374
492
  item = match.group("item")
375
493
  if not _operand_has_content(item):
376
- return None
494
+ return _invalid_directive_syntax(
495
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
496
+ directive_kind=DirectiveKind.PROHIBIT_ITEM,
497
+ missing_operand="item",
498
+ )
377
499
  return CanonicalDirective(
378
500
  text=text,
379
501
  kind=DirectiveKind.PROHIBIT_ITEM,
380
502
  operands=MappingProxyType({"item": item}),
381
503
  )
382
504
 
505
+ if normalized == "remove policy":
506
+ return _invalid_directive_syntax(
507
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
508
+ directive_kind=DirectiveKind.REMOVE_POLICY,
509
+ missing_operand="item",
510
+ )
511
+
383
512
  if normalized.startswith("remove policy "):
384
513
  match = _REMOVE_POLICY_RE.fullmatch(trimmed_text)
385
514
  if match is None:
386
- return None
515
+ return _invalid_directive_syntax(
516
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
517
+ directive_kind=DirectiveKind.REMOVE_POLICY,
518
+ missing_operand="item",
519
+ )
387
520
  item = match.group("item")
388
521
  if not _operand_has_content(item):
389
- return None
522
+ return _invalid_directive_syntax(
523
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
524
+ directive_kind=DirectiveKind.REMOVE_POLICY,
525
+ missing_operand="item",
526
+ )
390
527
  return CanonicalDirective(
391
528
  text=text,
392
529
  kind=DirectiveKind.REMOVE_POLICY,
393
530
  operands=MappingProxyType({"item": item}),
394
531
  )
395
532
 
396
- return None
397
-
533
+ return _invalid_directive_syntax(DirectiveSyntaxFailure.MALFORMED_DIRECTIVE)
398
534
 
399
- def render_directive(kind: DirectiveKind, /, **operands: str) -> str:
400
- """Produce canonical directive text from a semantic kind and operands.
401
535
 
402
- This determines the exact canonical spelling for an existing grammar
403
- capability and rejects operand combinations that would not round-trip as the
404
- requested directive kind. It does not infer missing operands, parse user
405
- input, or extend the grammar with new behaviors.
406
- """
536
+ def _render_directive(kind: DirectiveKind | str, /, **operands: str) -> str:
537
+ """Produce canonical directive text from a semantic kind and operands."""
407
538
  try:
408
539
  normalized_kind = kind if isinstance(kind, DirectiveKind) else DirectiveKind(kind)
409
540
  spec = _DIRECTIVE_SPECS[normalized_kind]
@@ -416,33 +547,32 @@ def render_directive(kind: DirectiveKind, /, **operands: str) -> str:
416
547
  missing_names = expected_names - actual_names
417
548
  if missing_names:
418
549
  missing = ", ".join(sorted(missing_names))
419
- raise ValueError(f"Missing required operands for {kind.value}: {missing}")
550
+ raise ValueError(f"Missing required operands for {normalized_kind.value}: {missing}")
420
551
  if unexpected_names:
421
552
  unexpected = ", ".join(sorted(unexpected_names))
422
- raise ValueError(f"Unexpected operands for {kind.value}: {unexpected}")
553
+ raise ValueError(f"Unexpected operands for {normalized_kind.value}: {unexpected}")
423
554
 
424
555
  normalized_operands: dict[str, str] = {}
425
556
  for name in spec.operand_names:
426
557
  raw_value = operands[name]
427
558
  if not isinstance(raw_value, str):
428
- raise ValueError(f"Operand {name!r} for {kind.value} must be a string.")
559
+ raise ValueError(f"Operand {name!r} for {normalized_kind.value} must be a string.")
429
560
  if raw_value.strip() == "":
430
- raise ValueError(f"Operand {name!r} for {kind.value} cannot be empty.")
561
+ raise ValueError(f"Operand {name!r} for {normalized_kind.value} cannot be empty.")
431
562
  normalized_operands[name] = raw_value
432
563
 
433
564
  operand_view = MappingProxyType(normalized_operands)
434
565
  rendered = spec.renderer(operand_view)
435
566
  decomposed = decompose_directive(rendered)
436
- if decomposed is None or decomposed.kind is not normalized_kind:
567
+ if not isinstance(decomposed, CanonicalDirective) or decomposed.kind is not normalized_kind:
437
568
  raise ValueError(f"Operands do not produce a canonical {normalized_kind.value} directive.")
438
569
  return rendered
439
570
 
440
571
 
441
572
  __all__ = [
442
- "CanonicalDirective",
443
573
  "DirectiveKind",
444
- "contains_multiple_canonical_directives",
574
+ "DirectiveSyntaxFailure",
575
+ "CanonicalDirective",
576
+ "InvalidDirectiveSyntax",
445
577
  "decompose_directive",
446
- "match_canonical_directive_start",
447
- "render_directive",
448
578
  ]
@@ -5,7 +5,7 @@ import sys
5
5
  from collections.abc import Mapping
6
6
  from typing import TextIO
7
7
 
8
- from . import __version__, create_engine
8
+ from . import __version__
9
9
  from .const import STATE_POLICIES, STATE_PREMISE, STATE_VERSION
10
10
  from .decision_helpers import is_error, is_no_directive, is_update
11
11
  from .engine import Decision, DecisionKind, Engine, PolicyValue
@@ -233,7 +233,7 @@ def run_repl(
233
233
  emit NDJSON records.
234
234
  """
235
235
 
236
- active_engine = create_engine() if engine is None else engine
236
+ active_engine = Engine() if engine is None else engine
237
237
 
238
238
  if _is_interactive(in_stream, out_stream):
239
239
  print("Context Compiler REPL (0.5). Type help for commands.", file=out_stream)
@@ -420,7 +420,7 @@ def main() -> int: # pragma: no cover
420
420
  print("error: --json requires non-interactive stdin/stdout.", file=sys.stderr)
421
421
  return 1
422
422
 
423
- engine = create_engine()
423
+ engine = Engine()
424
424
  try:
425
425
  _apply_preload_from_options(engine, options)
426
426
  except (OSError, ValueError) as exc: