context-compiler 0.9.0.dev8__tar.gz → 0.9.0.dev10__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.dev8 → context_compiler-0.9.0.dev10}/PKG-INFO +6 -7
  2. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/README.md +5 -6
  3. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/demos/08_llm_replacement_precondition.py +24 -24
  4. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/demos/09_llm_confirmation_no_directive.py +24 -27
  5. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/pyproject.toml +1 -1
  6. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/src/context_compiler/engine.py +15 -50
  7. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/src/context_compiler/grammar.py +257 -90
  8. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/.gitignore +0 -0
  9. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/LICENSE +0 -0
  10. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/demos/01_llm_contradiction_error.py +0 -0
  11. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/demos/02_llm_constraint_guardrail.py +0 -0
  12. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/demos/03_llm_premise_guardrail.py +0 -0
  13. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/demos/04_llm_tool_denylist_guardrail.py +0 -0
  14. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/demos/05_llm_prompt_drift_vs_state.py +0 -0
  15. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/demos/06_llm_context_compaction.py +0 -0
  16. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/demos/07_llm_prompt_vs_state.py +0 -0
  17. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/demos/README.md +0 -0
  18. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/demos/__init__.py +0 -0
  19. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/demos/common.py +0 -0
  20. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/demos/llm_client.py +0 -0
  21. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/demos/run_demo.py +0 -0
  22. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/examples/01_persistent_guardrails.py +0 -0
  23. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/examples/02_configuration_and_correction.py +0 -0
  24. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/examples/03_ambiguity_with_error.py +0 -0
  25. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/examples/04_tool_governance_denylist.py +0 -0
  26. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/examples/05_llm_integration_pattern.py +0 -0
  27. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/examples/06_step_sequence_and_state_restore.py +0 -0
  28. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/examples/07_single_policy_correction.py +0 -0
  29. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/examples/README.md +0 -0
  30. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/examples/_util.py +0 -0
  31. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/host_support/__init__.py +0 -0
  32. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/host_support/provider_mode.py +0 -0
  33. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/src/context_compiler/__init__.py +0 -0
  34. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/src/context_compiler/const.py +0 -0
  35. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/src/context_compiler/decision_helpers.py +0 -0
  36. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/src/context_compiler/py.typed +0 -0
  37. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev10}/src/context_compiler/repl.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: context-compiler
3
- Version: 0.9.0.dev8
3
+ Version: 0.9.0.dev10
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
@@ -414,10 +414,9 @@ Replacement:
414
414
  User: use podman instead of docker
415
415
  ```
416
416
 
417
- If `docker` is absent from saved state, that does not make the directive
418
- pending. The user's intended resulting state is still unambiguous, so the
419
- replacement follows the deterministic `use podman` transition when otherwise
420
- semantically valid.
417
+ If `docker` is absent from saved state, that is a semantic `error`.
418
+ Canonical replacement requires an active existing source `use` policy and
419
+ does not degrade to plain `use podman`.
421
420
 
422
421
  Removal and reset:
423
422
 
@@ -435,8 +434,8 @@ evaluation against authoritative state.
435
434
  Pending continuation is a separate runtime layer. It may exist only after a
436
435
  canonical directive reaches a supported semantic `error` case. It never
437
436
  repairs malformed syntax or reinterprets non-canonical input as a directive.
438
- An absent source item in a canonical replacement directive is not, by itself,
439
- such a `error` case.
437
+ An absent source item in a canonical replacement directive is itself a
438
+ semantic `error` case and does not authorize degradation to plain `use`.
440
439
 
441
440
  Examples:
442
441
 
@@ -392,10 +392,9 @@ Replacement:
392
392
  User: use podman instead of docker
393
393
  ```
394
394
 
395
- If `docker` is absent from saved state, that does not make the directive
396
- pending. The user's intended resulting state is still unambiguous, so the
397
- replacement follows the deterministic `use podman` transition when otherwise
398
- semantically valid.
395
+ If `docker` is absent from saved state, that is a semantic `error`.
396
+ Canonical replacement requires an active existing source `use` policy and
397
+ does not degrade to plain `use podman`.
399
398
 
400
399
  Removal and reset:
401
400
 
@@ -413,8 +412,8 @@ evaluation against authoritative state.
413
412
  Pending continuation is a separate runtime layer. It may exist only after a
414
413
  canonical directive reaches a supported semantic `error` case. It never
415
414
  repairs malformed syntax or reinterprets non-canonical input as a directive.
416
- An absent source item in a canonical replacement directive is not, by itself,
417
- such a `error` case.
415
+ An absent source item in a canonical replacement directive is itself a
416
+ semantic `error` case and does not authorize degradation to plain `use`.
418
417
 
419
418
  Examples:
420
419
 
@@ -1,10 +1,10 @@
1
- """Demo 8: missing-source replacement applies deterministically from authoritative state."""
1
+ """Demo 8: missing-source replacement fails without mutating authoritative state."""
2
2
 
3
3
  from collections.abc import Mapping
4
4
 
5
5
  from context_compiler import (
6
6
  Engine,
7
- is_update,
7
+ is_error,
8
8
  )
9
9
  from demos.common import (
10
10
  build_baseline_messages,
@@ -22,7 +22,7 @@ from demos.common import (
22
22
  )
23
23
  from demos.llm_client import complete_messages
24
24
 
25
- DEMO_NAME = "08_replacement_precondition — missing-source replacement applies deterministically"
25
+ DEMO_NAME = "08_replacement_precondition — missing-source replacement requires active source"
26
26
  USER_INPUT = "use podman instead of docker"
27
27
 
28
28
 
@@ -69,38 +69,38 @@ def main() -> None:
69
69
  reinjected_output = complete_messages(reinjected_messages)
70
70
  print_model_output("Reinjected-state", reinjected_output)
71
71
 
72
- if is_update(decision):
72
+ if is_error(decision):
73
73
  print_messages("compiler-mediated (full)", [])
74
- mediated_output = "[no call] authoritative state applied deterministic replacement update"
74
+ mediated_output = "[no call] authoritative state blocked replacement without source use"
75
75
  print_model_output("Compiler-mediated (full)", mediated_output)
76
76
  else:
77
77
  print_messages("compiler-mediated (full)", [])
78
- mediated_output = "[no call] expected update was not produced"
78
+ mediated_output = "[no call] expected semantic replacement error was not produced"
79
79
  print_model_output("Compiler-mediated (full)", mediated_output)
80
80
 
81
81
  compacted_turns, compacted_state, compacted_prompt = compact_user_turns(user_inputs)
82
- if compacted_prompt is None:
82
+ if compacted_prompt is not None:
83
83
  print_messages("compiler-mediated + compact", [])
84
- compact_output = "[no call] compaction preserved deterministic state update"
84
+ compact_output = "[no call] unexpected error was produced during compaction"
85
85
  print_model_output("Compiler-mediated + compact", compact_output)
86
86
  else:
87
87
  print_messages("compiler-mediated + compact", [])
88
- compact_output = "[no call] unexpected error was produced during compaction"
88
+ compact_output = "[no call] compaction preserved replacement error without state mutation"
89
89
  print_model_output("Compiler-mediated + compact", compact_output)
90
90
 
91
91
  premise, policies = observe_engine(engine)
92
92
  compacted_premise, compacted_policies = state_observations(compacted_state)
93
- state_applied = not _is_initial_authoritative_state(premise=premise, policies=policies)
94
- compact_state_applied = not _is_initial_authoritative_state(
93
+ state_preserved = _is_initial_authoritative_state(premise=premise, policies=policies)
94
+ compact_state_preserved = _is_initial_authoritative_state(
95
95
  premise=compacted_premise,
96
96
  policies=compacted_policies,
97
97
  )
98
- compact_no_pending = compacted_prompt is None
98
+ compact_error_preserved = compacted_prompt is not None
99
99
 
100
100
  baseline_has_authoritative_precondition = False
101
101
  reinjected_has_authoritative_precondition = False
102
- compiler_pass = is_update(decision) and state_applied
103
- compact_pass = compacted_prompt is None and compact_state_applied and compact_no_pending
102
+ compiler_pass = is_error(decision) and state_preserved
103
+ compact_pass = compact_error_preserved and compact_state_preserved
104
104
 
105
105
  print_host_check(
106
106
  "BASELINE_AUTHORITATIVE_PRECONDITION",
@@ -114,12 +114,12 @@ def main() -> None:
114
114
  )
115
115
  print_host_check(
116
116
  "COMPILER_BLOCKED_INVALID_REPLACEMENT",
117
- yes_no(is_update(decision)),
117
+ yes_no(is_error(decision)),
118
118
  context="compiler-mediated",
119
119
  )
120
120
  print_host_check(
121
- "COMPILER_STATE_APPLIED",
122
- yes_no(state_applied),
121
+ "COMPILER_STATE_PRESERVED",
122
+ yes_no(state_preserved),
123
123
  context="compiler-mediated",
124
124
  )
125
125
 
@@ -130,18 +130,18 @@ def main() -> None:
130
130
  compiler_pass=compiler_pass,
131
131
  compiler_compact_pass=compact_pass,
132
132
  expected=(
133
- "missing-source replacement should deterministically apply the resulting use update "
134
- "without pending continuation"
133
+ "missing-source replacement should return semantic error without mutating "
134
+ "authoritative state"
135
135
  ),
136
136
  actual=(
137
- "compiler applied deterministic replacement update; baseline and reinjected paths "
138
- "still lack authoritative state enforcement"
137
+ "compiler blocked missing-source replacement without mutating state; baseline and "
138
+ "reinjected paths still lack authoritative state enforcement"
139
139
  if compiler_pass and compact_pass
140
- else "compiler did not consistently apply deterministic replacement behavior"
140
+ else "compiler did not consistently enforce the replacement source precondition"
141
141
  ),
142
142
  passed=compiler_pass and compact_pass,
143
- result_pass="missing-source replacement applied deterministically",
144
- result_fail="missing-source replacement did not apply deterministically",
143
+ result_pass="missing-source replacement was rejected without mutation",
144
+ result_fail="missing-source replacement was not rejected correctly",
145
145
  )
146
146
 
147
147
 
@@ -1,11 +1,11 @@
1
- """Demo 9: confirmation-style followups remain ordinary no_directive."""
1
+ """Demo 9: replacement errors do not create confirmation-style followup state."""
2
2
 
3
3
  from collections.abc import Mapping
4
4
 
5
5
  from context_compiler import (
6
6
  Engine,
7
+ is_error,
7
8
  is_no_directive,
8
- is_update,
9
9
  )
10
10
  from demos.common import (
11
11
  build_baseline_messages,
@@ -24,8 +24,7 @@ from demos.common import (
24
24
  from demos.llm_client import complete_messages
25
25
 
26
26
  DEMO_NAME = (
27
- "09_confirmation_no_directive_boundary — "
28
- "missing-source replacement does not create a confirmation state"
27
+ "09_confirmation_no_directive_boundary — replacement errors do not create a confirmation state"
29
28
  )
30
29
  TURN_1 = "use podman instead of docker"
31
30
  TURN_2 = "maybe"
@@ -34,10 +33,6 @@ INITIAL_PREMISE: str | None = None
34
33
  INITIAL_POLICIES: dict[str, str] = {}
35
34
 
36
35
 
37
- def _has_podman_use(policies: Mapping[str, str]) -> bool:
38
- return policies.get("podman") == "use"
39
-
40
-
41
36
  def _is_initial_authoritative_state(*, premise: str | None, policies: Mapping[str, str]) -> bool:
42
37
  return premise == INITIAL_PREMISE and dict(policies) == INITIAL_POLICIES
43
38
 
@@ -50,12 +45,17 @@ def main() -> None:
50
45
  first = engine.step(TURN_1)
51
46
  premise, policies = observe_engine(engine)
52
47
  print_decision("turn 1", first, premise=premise, policies=policies)
53
- state_applied_after_first = _has_podman_use(policies)
48
+ state_preserved_after_first = _is_initial_authoritative_state(
49
+ premise=premise, policies=policies
50
+ )
54
51
 
55
52
  second = engine.step(TURN_2)
56
53
  premise, policies = observe_engine(engine)
57
54
  print_decision("turn 2", second, premise=premise, policies=policies)
58
- state_preserved_after_second = _has_podman_use(policies)
55
+ state_preserved_after_second = _is_initial_authoritative_state(
56
+ premise=premise,
57
+ policies=policies,
58
+ )
59
59
 
60
60
  third = engine.step(TURN_3)
61
61
  premise, policies = observe_engine(engine)
@@ -96,7 +96,7 @@ def main() -> None:
96
96
  compacted_turns, compacted_state, compacted_prompt = compact_user_turns(user_inputs)
97
97
  if compacted_prompt is not None:
98
98
  print_messages("compiler-mediated + compact", [])
99
- compact_output = f"[no call] error required: {compacted_prompt}"
99
+ compact_output = f"[no call] replacement error preserved: {compacted_prompt}"
100
100
  print_model_output("Compiler-mediated + compact", compact_output)
101
101
  else:
102
102
  print_messages("compiler-mediated + compact", [])
@@ -105,32 +105,30 @@ def main() -> None:
105
105
  )
106
106
  print_model_output("Compiler-mediated + compact", compact_output)
107
107
 
108
- deterministic_initial_update = is_update(first) and state_applied_after_first
108
+ deterministic_initial_error = is_error(first) and state_preserved_after_first
109
109
  unrelated_followup_no_directive = is_no_directive(second) and state_preserved_after_second
110
110
  confirmation_token_not_consumed = is_no_directive(third)
111
111
  premise, policies = observe_engine(engine)
112
- deterministic_final_state = _has_podman_use(policies)
112
+ deterministic_final_state = _is_initial_authoritative_state(premise=premise, policies=policies)
113
113
  _, compacted_policies = state_observations(compacted_state)
114
114
 
115
115
  baseline_has_confirmation_state_machine = False
116
116
  reinjected_has_confirmation_state_machine = False
117
117
 
118
118
  compiler_pass = (
119
- deterministic_initial_update
119
+ deterministic_initial_error
120
120
  and unrelated_followup_no_directive
121
121
  and confirmation_token_not_consumed
122
122
  and deterministic_final_state
123
123
  )
124
124
 
125
125
  compact_pass = (
126
- compacted_prompt is None
127
- and compacted_turns == [TURN_2, TURN_3]
128
- and _has_podman_use(compacted_policies)
126
+ compacted_prompt is not None and compacted_turns == [TURN_1] and compacted_policies == {}
129
127
  )
130
128
 
131
129
  print_host_check(
132
- "DETERMINISTIC_INITIAL_UPDATE",
133
- yes_no(deterministic_initial_update),
130
+ "DETERMINISTIC_INITIAL_ERROR",
131
+ yes_no(deterministic_initial_error),
134
132
  context="compiler-mediated",
135
133
  )
136
134
  print_host_check(
@@ -144,7 +142,7 @@ def main() -> None:
144
142
  context="compiler-mediated",
145
143
  )
146
144
  print_host_check(
147
- "FINAL_POLICY_PODMAN_PRESENT",
145
+ "FINAL_STATE_UNCHANGED",
148
146
  yes_no(deterministic_final_state),
149
147
  context="compiler-mediated",
150
148
  )
@@ -156,19 +154,18 @@ def main() -> None:
156
154
  compiler_pass=compiler_pass,
157
155
  compiler_compact_pass=compact_pass,
158
156
  expected=(
159
- "missing-source replacement should apply without creating an engine-owned "
160
- "confirmation state, and later yes/no-style input should remain ordinary "
161
- "no_directive"
157
+ "replacement error should not create an engine-owned confirmation state, "
158
+ "and later yes/no-style input should remain ordinary no_directive"
162
159
  ),
163
160
  actual=(
164
- "compiler applied deterministic replacement update and treated later inputs as "
165
- "ordinary no_directive"
161
+ "compiler returned semantic error and treated later inputs as ordinary "
162
+ "no_directive without mutating state"
166
163
  if compiler_pass and compact_pass
167
164
  else "compiler did not consistently preserve the confirmation-no_directive boundary"
168
165
  ),
169
166
  passed=compiler_pass and compact_pass,
170
- result_pass="missing-source replacement stayed outside engine-owned confirmation state",
171
- result_fail="missing-source replacement still behaved like engine-owned confirmation state",
167
+ result_pass="replacement error stayed outside engine-owned confirmation state",
168
+ result_fail="replacement error still behaved like engine-owned confirmation state",
172
169
  )
173
170
 
174
171
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "context-compiler"
7
- version = "0.9.0dev8"
7
+ version = "0.9.0dev10"
8
8
  description = "Deterministic conversational state engine for LLM applications."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -19,7 +19,7 @@ from .const import (
19
19
  STATE_PREMISE,
20
20
  STATE_VERSION,
21
21
  )
22
- from .grammar import CanonicalDirective, _DirectiveKind, decompose_directive
22
+ from .grammar import CanonicalDirective, DirectiveKind, decompose_directive
23
23
 
24
24
  PolicyValue = Literal["use", "prohibit"]
25
25
 
@@ -129,61 +129,26 @@ class Engine:
129
129
  ) -> Decision | None:
130
130
  candidate_state = self._state if state is None else state
131
131
  # Single error path: all error outcomes are detected before any mutation.
132
- if directive.kind in {_DirectiveKind.SET_PREMISE, _DirectiveKind.CHANGE_PREMISE}:
133
- value = directive.operands["value"]
134
- if _sanitize_premise_value(value) == "":
135
- if directive.kind is _DirectiveKind.SET_PREMISE:
136
- return _error(
137
- "Premise value cannot be empty.\n"
138
- "Use 'set premise <value>' with a non-empty value."
139
- )
140
- return _error(
141
- "Premise value cannot be empty.\n"
142
- "Use 'change premise to <value>' with a non-empty value."
143
- )
144
-
145
- if (
146
- directive.kind is _DirectiveKind.REMOVE_POLICY
147
- and _normalize_item(directive.operands["item"]) == ""
148
- ):
149
- return _error(
150
- "Policy item cannot be empty.\nUse 'remove policy <item>' with a non-empty value."
151
- )
152
-
153
- if (
154
- directive.kind is _DirectiveKind.USE_ITEM
155
- and _normalize_item(directive.operands["item"]) == ""
156
- ):
157
- return _error("Policy item cannot be empty.\nUse 'use <item>' with a non-empty value.")
158
-
159
- if (
160
- directive.kind is _DirectiveKind.PROHIBIT_ITEM
161
- and _normalize_item(directive.operands["item"]) == ""
162
- ):
163
- return _error(
164
- "Policy item cannot be empty.\nUse 'prohibit <item>' with a non-empty value."
165
- )
166
-
167
132
  if (
168
- directive.kind is _DirectiveKind.SET_PREMISE
133
+ directive.kind is DirectiveKind.SET_PREMISE
169
134
  and candidate_state[STATE_PREMISE] is not None
170
135
  ):
171
136
  return _error("Premise already set.\nUse 'change premise to <value>' to modify it.")
172
137
 
173
138
  if (
174
- directive.kind is _DirectiveKind.CHANGE_PREMISE
139
+ directive.kind is DirectiveKind.CHANGE_PREMISE
175
140
  and candidate_state[STATE_PREMISE] is None
176
141
  ):
177
142
  return _error("No premise is set.\nUse 'set premise <value>' to define one.")
178
143
 
179
- if directive.kind is _DirectiveKind.USE_ITEM:
144
+ if directive.kind is DirectiveKind.USE_ITEM:
180
145
  item_key = _normalize_item(directive.operands["item"])
181
146
  if candidate_state[STATE_POLICIES].get(item_key) == POLICY_PROHIBIT:
182
147
  return _error(
183
148
  f'"{item_key}" is currently prohibited.\nRemove or replace it before using it.'
184
149
  )
185
150
 
186
- if directive.kind is _DirectiveKind.PROHIBIT_ITEM:
151
+ if directive.kind is DirectiveKind.PROHIBIT_ITEM:
187
152
  item_key = _normalize_item(directive.operands["item"])
188
153
  if candidate_state[STATE_POLICIES].get(item_key) == POLICY_USE:
189
154
  return _error(
@@ -191,7 +156,7 @@ class Engine:
191
156
  "Remove or replace it before prohibiting it."
192
157
  )
193
158
 
194
- if directive.kind is _DirectiveKind.REPLACE_USE:
159
+ if directive.kind is DirectiveKind.REPLACE_USE:
195
160
  new_item = directive.operands["new_item"]
196
161
  old_item = directive.operands["old_item"]
197
162
  new_key = _normalize_item(new_item)
@@ -211,7 +176,7 @@ class Engine:
211
176
  f'"{new_item}" is currently prohibited.\n'
212
177
  "Submit explicit directive(s) to remove it or use a different item."
213
178
  )
214
- if old_state not in {None, POLICY_USE}:
179
+ if old_state != POLICY_USE:
215
180
  return _error(
216
181
  f'"{old_item}" is not currently in use.\n'
217
182
  "Replacement requires an active 'use' policy."
@@ -222,27 +187,27 @@ class Engine:
222
187
  def _apply_directive(self, directive: CanonicalDirective, *, state: _State) -> _State:
223
188
  next_state = deepcopy(state)
224
189
 
225
- if directive.kind is _DirectiveKind.SET_PREMISE:
190
+ if directive.kind is DirectiveKind.SET_PREMISE:
226
191
  next_state[STATE_PREMISE] = _sanitize_premise_value(directive.operands["value"])
227
192
  return next_state
228
193
 
229
- if directive.kind is _DirectiveKind.CHANGE_PREMISE:
194
+ if directive.kind is DirectiveKind.CHANGE_PREMISE:
230
195
  next_state[STATE_PREMISE] = _sanitize_premise_value(directive.operands["value"])
231
196
  return next_state
232
197
 
233
- if directive.kind is _DirectiveKind.USE_ITEM:
198
+ if directive.kind is DirectiveKind.USE_ITEM:
234
199
  item_key = _normalize_item(directive.operands["item"])
235
200
  # Idempotent directives are updates even if state does not change.
236
201
  next_state[STATE_POLICIES][item_key] = POLICY_USE
237
202
  return next_state
238
203
 
239
- if directive.kind is _DirectiveKind.PROHIBIT_ITEM:
204
+ if directive.kind is DirectiveKind.PROHIBIT_ITEM:
240
205
  item_key = _normalize_item(directive.operands["item"])
241
206
  # Idempotent directives are updates even if state does not change.
242
207
  next_state[STATE_POLICIES][item_key] = POLICY_PROHIBIT
243
208
  return next_state
244
209
 
245
- if directive.kind is _DirectiveKind.REPLACE_USE:
210
+ if directive.kind is DirectiveKind.REPLACE_USE:
246
211
  self._apply_replacement_explicit(
247
212
  next_state,
248
213
  directive.operands["new_item"],
@@ -250,16 +215,16 @@ class Engine:
250
215
  )
251
216
  return next_state
252
217
 
253
- if directive.kind is _DirectiveKind.REMOVE_POLICY:
218
+ if directive.kind is DirectiveKind.REMOVE_POLICY:
254
219
  item_key = _normalize_item(directive.operands["item"])
255
220
  next_state[STATE_POLICIES].pop(item_key, None)
256
221
  return next_state
257
222
 
258
- if directive.kind is _DirectiveKind.CLEAR_PREMISE:
223
+ if directive.kind is DirectiveKind.CLEAR_PREMISE:
259
224
  next_state[STATE_PREMISE] = None
260
225
  return next_state
261
226
 
262
- if directive.kind is _DirectiveKind.RESET_POLICIES:
227
+ if directive.kind is DirectiveKind.RESET_POLICIES:
263
228
  next_state[STATE_POLICIES] = {}
264
229
  return next_state
265
230
 
@@ -7,7 +7,7 @@ from enum import StrEnum
7
7
  from types import MappingProxyType
8
8
 
9
9
 
10
- class _DirectiveKind(StrEnum):
10
+ class DirectiveKind(StrEnum):
11
11
  """Enumerate the supported canonical directive families."""
12
12
 
13
13
  SET_PREMISE = "set_premise"
@@ -21,6 +21,14 @@ 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.
@@ -30,7 +38,7 @@ class CanonicalDirective:
30
38
  """
31
39
 
32
40
  text: str
33
- kind: _DirectiveKind
41
+ kind: DirectiveKind
34
42
  operands: MappingProxyType[str, str]
35
43
 
36
44
 
@@ -38,20 +46,39 @@ class CanonicalDirective:
38
46
  class InvalidDirectiveSyntax:
39
47
  """Represent directive-shaped input that fails canonical syntax parsing."""
40
48
 
49
+ failure: DirectiveSyntaxFailure = DirectiveSyntaxFailure.MALFORMED_DIRECTIVE
50
+ directive_kind: DirectiveKind | None = None
51
+ missing_operand: str | None = None
52
+
41
53
 
42
54
  @dataclass(frozen=True, slots=True)
43
55
  class _DirectiveSpec:
44
- kind: _DirectiveKind
56
+ kind: DirectiveKind
57
+ canonical_start: str
45
58
  operand_names: tuple[str, ...]
46
59
  exact_text: str | None
47
60
  renderer: Callable[[MappingProxyType[str, str]], str]
48
61
 
49
62
 
50
- _SET_PREMISE_PREFIX = "set premise "
51
- _CHANGE_PREMISE_PREFIX = "change premise to "
52
- _USE_PREFIX = "use "
53
- _PROHIBIT_PREFIX = "prohibit "
54
- _REMOVE_POLICY_PREFIX = "remove policy "
63
+ @dataclass(frozen=True, slots=True)
64
+ class DirectiveMetadata:
65
+ """Describe one canonical directive family without exposing parser internals."""
66
+
67
+ kind: DirectiveKind
68
+ canonical_start: str
69
+ operand_names: tuple[str, ...]
70
+
71
+
72
+ _SET_PREMISE_START = "set premise"
73
+ _CHANGE_PREMISE_START = "change premise to"
74
+ _USE_START = "use"
75
+ _PROHIBIT_START = "prohibit"
76
+ _REMOVE_POLICY_START = "remove policy"
77
+ _SET_PREMISE_PREFIX = f"{_SET_PREMISE_START} "
78
+ _CHANGE_PREMISE_PREFIX = f"{_CHANGE_PREMISE_START} "
79
+ _USE_PREFIX = f"{_USE_START} "
80
+ _PROHIBIT_PREFIX = f"{_PROHIBIT_START} "
81
+ _REMOVE_POLICY_PREFIX = f"{_REMOVE_POLICY_START} "
55
82
  _CLEAR_PREMISE_TEXT = "clear premise"
56
83
  _RESET_POLICIES_TEXT = "reset policies"
57
84
  _CLEAR_STATE_TEXT = "clear state"
@@ -69,39 +96,6 @@ _REPLACE_RE = re.compile(
69
96
  r"(?i)^use[ \t]+(?P<new_item>.*?)[ \t]+instead[ \t]+of[ \t]+(?P<old_item>.+)$"
70
97
  )
71
98
 
72
- _PREFIX_DIRECTIVE_STARTS: tuple[tuple[str, bool], ...] = (
73
- (_CHANGE_PREMISE_PREFIX.removesuffix(" "), True),
74
- (_SET_PREMISE_PREFIX.removesuffix(" "), True),
75
- (_REMOVE_POLICY_PREFIX.removesuffix(" "), True),
76
- (_PROHIBIT_PREFIX.removesuffix(" "), True),
77
- (_USE_PREFIX.removesuffix(" "), True),
78
- )
79
-
80
- _EXACT_DIRECTIVE_STARTS: tuple[tuple[str, bool], ...] = (
81
- (_RESET_POLICIES_TEXT, False),
82
- (_CLEAR_PREMISE_TEXT, False),
83
- (_CLEAR_STATE_TEXT, False),
84
- )
85
-
86
- _CANONICAL_DIRECTIVE_STARTS: tuple[tuple[str, bool], ...] = (
87
- _PREFIX_DIRECTIVE_STARTS[0],
88
- _PREFIX_DIRECTIVE_STARTS[1],
89
- _PREFIX_DIRECTIVE_STARTS[2],
90
- _EXACT_DIRECTIVE_STARTS[0],
91
- _EXACT_DIRECTIVE_STARTS[1],
92
- _EXACT_DIRECTIVE_STARTS[2],
93
- _PREFIX_DIRECTIVE_STARTS[3],
94
- _PREFIX_DIRECTIVE_STARTS[4],
95
- )
96
-
97
- _DIRECTIVE_FAMILY_STARTS: tuple[tuple[str, bool], ...] = (
98
- (_CHANGE_PREMISE_FAMILY, True),
99
- _PREFIX_DIRECTIVE_STARTS[1],
100
- _PREFIX_DIRECTIVE_STARTS[2],
101
- *_EXACT_DIRECTIVE_STARTS,
102
- *_PREFIX_DIRECTIVE_STARTS[3:],
103
- )
104
-
105
99
 
106
100
  def _render_with_prefix(
107
101
  prefix: str, operand_name: str
@@ -126,56 +120,65 @@ def _render_exact(text: str) -> Callable[[MappingProxyType[str, str]], str]:
126
120
 
127
121
  _DIRECTIVE_SPECS = MappingProxyType(
128
122
  {
129
- _DirectiveKind.SET_PREMISE: _DirectiveSpec(
130
- kind=_DirectiveKind.SET_PREMISE,
123
+ DirectiveKind.SET_PREMISE: _DirectiveSpec(
124
+ kind=DirectiveKind.SET_PREMISE,
125
+ canonical_start=_SET_PREMISE_START,
131
126
  operand_names=("value",),
132
127
  exact_text=None,
133
128
  renderer=_render_with_prefix(_SET_PREMISE_PREFIX, "value"),
134
129
  ),
135
- _DirectiveKind.CHANGE_PREMISE: _DirectiveSpec(
136
- kind=_DirectiveKind.CHANGE_PREMISE,
130
+ DirectiveKind.CHANGE_PREMISE: _DirectiveSpec(
131
+ kind=DirectiveKind.CHANGE_PREMISE,
132
+ canonical_start=_CHANGE_PREMISE_START,
137
133
  operand_names=("value",),
138
134
  exact_text=None,
139
135
  renderer=_render_with_prefix(_CHANGE_PREMISE_PREFIX, "value"),
140
136
  ),
141
- _DirectiveKind.USE_ITEM: _DirectiveSpec(
142
- kind=_DirectiveKind.USE_ITEM,
137
+ DirectiveKind.USE_ITEM: _DirectiveSpec(
138
+ kind=DirectiveKind.USE_ITEM,
139
+ canonical_start=_USE_START,
143
140
  operand_names=("item",),
144
141
  exact_text=None,
145
142
  renderer=_render_with_prefix(_USE_PREFIX, "item"),
146
143
  ),
147
- _DirectiveKind.PROHIBIT_ITEM: _DirectiveSpec(
148
- kind=_DirectiveKind.PROHIBIT_ITEM,
144
+ DirectiveKind.PROHIBIT_ITEM: _DirectiveSpec(
145
+ kind=DirectiveKind.PROHIBIT_ITEM,
146
+ canonical_start=_PROHIBIT_START,
149
147
  operand_names=("item",),
150
148
  exact_text=None,
151
149
  renderer=_render_with_prefix(_PROHIBIT_PREFIX, "item"),
152
150
  ),
153
- _DirectiveKind.REMOVE_POLICY: _DirectiveSpec(
154
- kind=_DirectiveKind.REMOVE_POLICY,
151
+ DirectiveKind.REMOVE_POLICY: _DirectiveSpec(
152
+ kind=DirectiveKind.REMOVE_POLICY,
153
+ canonical_start=_REMOVE_POLICY_START,
155
154
  operand_names=("item",),
156
155
  exact_text=None,
157
156
  renderer=_render_with_prefix(_REMOVE_POLICY_PREFIX, "item"),
158
157
  ),
159
- _DirectiveKind.REPLACE_USE: _DirectiveSpec(
160
- kind=_DirectiveKind.REPLACE_USE,
158
+ DirectiveKind.REPLACE_USE: _DirectiveSpec(
159
+ kind=DirectiveKind.REPLACE_USE,
160
+ canonical_start=_USE_START,
161
161
  operand_names=("new_item", "old_item"),
162
162
  exact_text=None,
163
163
  renderer=_render_replace_use,
164
164
  ),
165
- _DirectiveKind.CLEAR_PREMISE: _DirectiveSpec(
166
- kind=_DirectiveKind.CLEAR_PREMISE,
165
+ DirectiveKind.CLEAR_PREMISE: _DirectiveSpec(
166
+ kind=DirectiveKind.CLEAR_PREMISE,
167
+ canonical_start=_CLEAR_PREMISE_TEXT,
167
168
  operand_names=(),
168
169
  exact_text=_CLEAR_PREMISE_TEXT,
169
170
  renderer=_render_exact(_CLEAR_PREMISE_TEXT),
170
171
  ),
171
- _DirectiveKind.RESET_POLICIES: _DirectiveSpec(
172
- kind=_DirectiveKind.RESET_POLICIES,
172
+ DirectiveKind.RESET_POLICIES: _DirectiveSpec(
173
+ kind=DirectiveKind.RESET_POLICIES,
174
+ canonical_start=_RESET_POLICIES_TEXT,
173
175
  operand_names=(),
174
176
  exact_text=_RESET_POLICIES_TEXT,
175
177
  renderer=_render_exact(_RESET_POLICIES_TEXT),
176
178
  ),
177
- _DirectiveKind.CLEAR_STATE: _DirectiveSpec(
178
- kind=_DirectiveKind.CLEAR_STATE,
179
+ DirectiveKind.CLEAR_STATE: _DirectiveSpec(
180
+ kind=DirectiveKind.CLEAR_STATE,
181
+ canonical_start=_CLEAR_STATE_TEXT,
179
182
  operand_names=(),
180
183
  exact_text=_CLEAR_STATE_TEXT,
181
184
  renderer=_render_exact(_CLEAR_STATE_TEXT),
@@ -184,6 +187,64 @@ _DIRECTIVE_SPECS = MappingProxyType(
184
187
  )
185
188
 
186
189
 
190
+ def _starts_with_descriptor(spec: _DirectiveSpec) -> tuple[str, bool]:
191
+ return (spec.canonical_start, bool(spec.operand_names))
192
+
193
+
194
+ def _unique_start_descriptors(specs: tuple[_DirectiveSpec, ...]) -> tuple[tuple[str, bool], ...]:
195
+ descriptors: list[tuple[str, bool]] = []
196
+ seen: set[tuple[str, bool]] = set()
197
+ for spec in specs:
198
+ descriptor = _starts_with_descriptor(spec)
199
+ if descriptor not in seen:
200
+ seen.add(descriptor)
201
+ descriptors.append(descriptor)
202
+ return tuple(descriptors)
203
+
204
+
205
+ _CANONICAL_START_ORDER = (
206
+ DirectiveKind.CHANGE_PREMISE,
207
+ DirectiveKind.SET_PREMISE,
208
+ DirectiveKind.REMOVE_POLICY,
209
+ DirectiveKind.RESET_POLICIES,
210
+ DirectiveKind.CLEAR_PREMISE,
211
+ DirectiveKind.CLEAR_STATE,
212
+ DirectiveKind.PROHIBIT_ITEM,
213
+ DirectiveKind.USE_ITEM,
214
+ )
215
+
216
+ _CANONICAL_DIRECTIVE_STARTS = _unique_start_descriptors(
217
+ tuple(_DIRECTIVE_SPECS[kind] for kind in _CANONICAL_START_ORDER)
218
+ )
219
+
220
+ _DIRECTIVE_FAMILY_STARTS = (
221
+ (_CHANGE_PREMISE_FAMILY, True),
222
+ *_unique_start_descriptors(
223
+ tuple(
224
+ _DIRECTIVE_SPECS[kind]
225
+ for kind in (
226
+ DirectiveKind.SET_PREMISE,
227
+ DirectiveKind.REMOVE_POLICY,
228
+ DirectiveKind.RESET_POLICIES,
229
+ DirectiveKind.CLEAR_PREMISE,
230
+ DirectiveKind.CLEAR_STATE,
231
+ DirectiveKind.PROHIBIT_ITEM,
232
+ DirectiveKind.USE_ITEM,
233
+ )
234
+ )
235
+ ),
236
+ )
237
+
238
+ _PUBLIC_DIRECTIVE_METADATA = tuple(
239
+ DirectiveMetadata(
240
+ kind=spec.kind,
241
+ canonical_start=spec.canonical_start,
242
+ operand_names=spec.operand_names,
243
+ )
244
+ for spec in _DIRECTIVE_SPECS.values()
245
+ )
246
+
247
+
187
248
  def _trim_ascii_whitespace(text: str) -> str:
188
249
  return text.strip(_ASCII_WHITESPACE)
189
250
 
@@ -305,11 +366,24 @@ def _parse_replace_use(trimmed_text: str) -> CanonicalDirective | None:
305
366
  return None
306
367
  return CanonicalDirective(
307
368
  text=trimmed_text,
308
- kind=_DirectiveKind.REPLACE_USE,
369
+ kind=DirectiveKind.REPLACE_USE,
309
370
  operands=MappingProxyType({"new_item": new_item, "old_item": old_item}),
310
371
  )
311
372
 
312
373
 
374
+ def _invalid_directive_syntax(
375
+ failure: DirectiveSyntaxFailure,
376
+ *,
377
+ directive_kind: DirectiveKind | None = None,
378
+ missing_operand: str | None = None,
379
+ ) -> InvalidDirectiveSyntax:
380
+ return InvalidDirectiveSyntax(
381
+ failure=failure,
382
+ directive_kind=directive_kind,
383
+ missing_operand=missing_operand,
384
+ )
385
+
386
+
313
387
  def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSyntax | None:
314
388
  """Parse one canonical directive into its semantic kind and operands.
315
389
 
@@ -326,50 +400,76 @@ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSynta
326
400
  if not _starts_with_directive_family(trimmed_text):
327
401
  return None
328
402
  if _contains_multiple_canonical_directives(trimmed_text):
329
- return InvalidDirectiveSyntax()
330
-
331
- invalid_result = InvalidDirectiveSyntax()
403
+ return _invalid_directive_syntax(DirectiveSyntaxFailure.COMPOUND_DIRECTIVE)
332
404
 
333
405
  normalized = _normalized_for_matching(trimmed_text)
334
406
 
335
407
  if normalized == _CLEAR_PREMISE_TEXT:
336
408
  return CanonicalDirective(
337
- text=text, kind=_DirectiveKind.CLEAR_PREMISE, operands=MappingProxyType({})
409
+ text=text, kind=DirectiveKind.CLEAR_PREMISE, operands=MappingProxyType({})
338
410
  )
339
411
  if normalized == _RESET_POLICIES_TEXT:
340
412
  return CanonicalDirective(
341
413
  text=text,
342
- kind=_DirectiveKind.RESET_POLICIES,
414
+ kind=DirectiveKind.RESET_POLICIES,
343
415
  operands=MappingProxyType({}),
344
416
  )
345
417
  if normalized == _CLEAR_STATE_TEXT:
346
418
  return CanonicalDirective(
347
- text=text, kind=_DirectiveKind.CLEAR_STATE, operands=MappingProxyType({})
419
+ text=text, kind=DirectiveKind.CLEAR_STATE, operands=MappingProxyType({})
420
+ )
421
+
422
+ if normalized == "set premise":
423
+ return _invalid_directive_syntax(
424
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
425
+ directive_kind=DirectiveKind.SET_PREMISE,
426
+ missing_operand="value",
348
427
  )
349
428
 
350
429
  if normalized.startswith("set premise "):
351
430
  match = _SET_PREMISE_RE.fullmatch(trimmed_text)
352
431
  if match is None:
353
- return invalid_result
432
+ return _invalid_directive_syntax(
433
+ DirectiveSyntaxFailure.MALFORMED_DIRECTIVE,
434
+ directive_kind=DirectiveKind.SET_PREMISE,
435
+ )
354
436
  value = match.group("value")
355
437
  if not _operand_has_content(value) or _operand_starts_with_token(value, "to"):
356
- return invalid_result
438
+ return _invalid_directive_syntax(
439
+ DirectiveSyntaxFailure.MALFORMED_DIRECTIVE,
440
+ directive_kind=DirectiveKind.SET_PREMISE,
441
+ )
357
442
  return CanonicalDirective(
358
443
  text=text,
359
- kind=_DirectiveKind.SET_PREMISE,
444
+ kind=DirectiveKind.SET_PREMISE,
360
445
  operands=MappingProxyType({"value": value}),
361
446
  )
362
447
 
448
+ if normalized == "change premise to":
449
+ return _invalid_directive_syntax(
450
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
451
+ directive_kind=DirectiveKind.CHANGE_PREMISE,
452
+ missing_operand="value",
453
+ )
454
+
363
455
  if normalized.startswith("change premise to "):
364
456
  match = _CHANGE_PREMISE_RE.fullmatch(trimmed_text)
365
457
  if match is None:
366
- return invalid_result
458
+ return _invalid_directive_syntax(
459
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
460
+ directive_kind=DirectiveKind.CHANGE_PREMISE,
461
+ missing_operand="value",
462
+ )
367
463
  value = match.group("value")
368
464
  if not _operand_has_content(value):
369
- return invalid_result
465
+ return _invalid_directive_syntax(
466
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
467
+ directive_kind=DirectiveKind.CHANGE_PREMISE,
468
+ missing_operand="value",
469
+ )
370
470
  return CanonicalDirective(
371
471
  text=text,
372
- kind=_DirectiveKind.CHANGE_PREMISE,
472
+ kind=DirectiveKind.CHANGE_PREMISE,
373
473
  operands=MappingProxyType({"value": value}),
374
474
  )
375
475
 
@@ -377,58 +477,121 @@ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSynta
377
477
  if replacement is not None:
378
478
  return replacement
379
479
 
480
+ if normalized == "use":
481
+ return _invalid_directive_syntax(
482
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
483
+ directive_kind=DirectiveKind.USE_ITEM,
484
+ missing_operand="item",
485
+ )
486
+
380
487
  if normalized.startswith("use "):
381
488
  match = _USE_RE.fullmatch(trimmed_text)
382
489
  if match is None:
383
- return invalid_result
490
+ return _invalid_directive_syntax(
491
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
492
+ directive_kind=DirectiveKind.USE_ITEM,
493
+ missing_operand="item",
494
+ )
384
495
  item = match.group("item")
385
496
  normalized_item = _normalized_for_matching(item)
386
- if (
387
- not _operand_has_content(item)
388
- or normalized_item.startswith("instead of ")
389
- or normalized_item.endswith(" instead of")
390
- or _INSTEAD_OF_DELIMITER in normalized_item
391
- ):
392
- return invalid_result
497
+ if not _operand_has_content(item):
498
+ return _invalid_directive_syntax(
499
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
500
+ directive_kind=DirectiveKind.USE_ITEM,
501
+ missing_operand="item",
502
+ )
503
+ if normalized_item == "instead of" or normalized_item.startswith("instead of "):
504
+ return _invalid_directive_syntax(
505
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
506
+ directive_kind=DirectiveKind.REPLACE_USE,
507
+ missing_operand="new_item",
508
+ )
509
+ if normalized_item.endswith(" instead of"):
510
+ return _invalid_directive_syntax(
511
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
512
+ directive_kind=DirectiveKind.REPLACE_USE,
513
+ missing_operand="old_item",
514
+ )
515
+ if _INSTEAD_OF_DELIMITER in normalized_item:
516
+ return _invalid_directive_syntax(
517
+ DirectiveSyntaxFailure.MALFORMED_DIRECTIVE,
518
+ directive_kind=DirectiveKind.USE_ITEM,
519
+ )
393
520
  return CanonicalDirective(
394
521
  text=text,
395
- kind=_DirectiveKind.USE_ITEM,
522
+ kind=DirectiveKind.USE_ITEM,
396
523
  operands=MappingProxyType({"item": item}),
397
524
  )
398
525
 
526
+ if normalized == "prohibit":
527
+ return _invalid_directive_syntax(
528
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
529
+ directive_kind=DirectiveKind.PROHIBIT_ITEM,
530
+ missing_operand="item",
531
+ )
532
+
399
533
  if normalized.startswith("prohibit "):
400
534
  match = _PROHIBIT_RE.fullmatch(trimmed_text)
401
535
  if match is None:
402
- return invalid_result
536
+ return _invalid_directive_syntax(
537
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
538
+ directive_kind=DirectiveKind.PROHIBIT_ITEM,
539
+ missing_operand="item",
540
+ )
403
541
  item = match.group("item")
404
542
  if not _operand_has_content(item):
405
- return invalid_result
543
+ return _invalid_directive_syntax(
544
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
545
+ directive_kind=DirectiveKind.PROHIBIT_ITEM,
546
+ missing_operand="item",
547
+ )
406
548
  return CanonicalDirective(
407
549
  text=text,
408
- kind=_DirectiveKind.PROHIBIT_ITEM,
550
+ kind=DirectiveKind.PROHIBIT_ITEM,
409
551
  operands=MappingProxyType({"item": item}),
410
552
  )
411
553
 
554
+ if normalized == "remove policy":
555
+ return _invalid_directive_syntax(
556
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
557
+ directive_kind=DirectiveKind.REMOVE_POLICY,
558
+ missing_operand="item",
559
+ )
560
+
412
561
  if normalized.startswith("remove policy "):
413
562
  match = _REMOVE_POLICY_RE.fullmatch(trimmed_text)
414
563
  if match is None:
415
- return invalid_result
564
+ return _invalid_directive_syntax(
565
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
566
+ directive_kind=DirectiveKind.REMOVE_POLICY,
567
+ missing_operand="item",
568
+ )
416
569
  item = match.group("item")
417
570
  if not _operand_has_content(item):
418
- return invalid_result
571
+ return _invalid_directive_syntax(
572
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
573
+ directive_kind=DirectiveKind.REMOVE_POLICY,
574
+ missing_operand="item",
575
+ )
419
576
  return CanonicalDirective(
420
577
  text=text,
421
- kind=_DirectiveKind.REMOVE_POLICY,
578
+ kind=DirectiveKind.REMOVE_POLICY,
422
579
  operands=MappingProxyType({"item": item}),
423
580
  )
424
581
 
425
- return invalid_result
582
+ return _invalid_directive_syntax(DirectiveSyntaxFailure.MALFORMED_DIRECTIVE)
583
+
584
+
585
+ def get_directive_metadata() -> tuple[DirectiveMetadata, ...]:
586
+ """Return immutable public directive metadata derived from internal specs."""
587
+
588
+ return _PUBLIC_DIRECTIVE_METADATA
426
589
 
427
590
 
428
- def _render_directive(kind: _DirectiveKind | str, /, **operands: str) -> str:
591
+ def _render_directive(kind: DirectiveKind | str, /, **operands: str) -> str:
429
592
  """Produce canonical directive text from a semantic kind and operands."""
430
593
  try:
431
- normalized_kind = kind if isinstance(kind, _DirectiveKind) else _DirectiveKind(kind)
594
+ normalized_kind = kind if isinstance(kind, DirectiveKind) else DirectiveKind(kind)
432
595
  spec = _DIRECTIVE_SPECS[normalized_kind]
433
596
  except (KeyError, ValueError) as exc:
434
597
  raise ValueError(f"Unsupported directive kind: {kind!r}") from exc
@@ -462,7 +625,11 @@ def _render_directive(kind: _DirectiveKind | str, /, **operands: str) -> str:
462
625
 
463
626
 
464
627
  __all__ = [
628
+ "DirectiveKind",
629
+ "DirectiveSyntaxFailure",
630
+ "DirectiveMetadata",
465
631
  "CanonicalDirective",
466
632
  "InvalidDirectiveSyntax",
633
+ "get_directive_metadata",
467
634
  "decompose_directive",
468
635
  ]