context-compiler 0.9.0.dev8__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.dev8 → context_compiler-0.9.0.dev9}/PKG-INFO +1 -1
  2. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/pyproject.toml +1 -1
  3. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/src/context_compiler/engine.py +14 -49
  4. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/src/context_compiler/grammar.py +162 -52
  5. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/.gitignore +0 -0
  6. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/LICENSE +0 -0
  7. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/README.md +0 -0
  8. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/demos/01_llm_contradiction_error.py +0 -0
  9. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/demos/02_llm_constraint_guardrail.py +0 -0
  10. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/demos/03_llm_premise_guardrail.py +0 -0
  11. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/demos/04_llm_tool_denylist_guardrail.py +0 -0
  12. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/demos/05_llm_prompt_drift_vs_state.py +0 -0
  13. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/demos/06_llm_context_compaction.py +0 -0
  14. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/demos/07_llm_prompt_vs_state.py +0 -0
  15. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/demos/08_llm_replacement_precondition.py +0 -0
  16. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/demos/09_llm_confirmation_no_directive.py +0 -0
  17. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/demos/README.md +0 -0
  18. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/demos/__init__.py +0 -0
  19. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/demos/common.py +0 -0
  20. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/demos/llm_client.py +0 -0
  21. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/demos/run_demo.py +0 -0
  22. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/examples/01_persistent_guardrails.py +0 -0
  23. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/examples/02_configuration_and_correction.py +0 -0
  24. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/examples/03_ambiguity_with_error.py +0 -0
  25. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/examples/04_tool_governance_denylist.py +0 -0
  26. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/examples/05_llm_integration_pattern.py +0 -0
  27. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/examples/06_step_sequence_and_state_restore.py +0 -0
  28. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/examples/07_single_policy_correction.py +0 -0
  29. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/examples/README.md +0 -0
  30. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/examples/_util.py +0 -0
  31. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/host_support/__init__.py +0 -0
  32. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/host_support/provider_mode.py +0 -0
  33. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/src/context_compiler/__init__.py +0 -0
  34. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/src/context_compiler/const.py +0 -0
  35. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/src/context_compiler/decision_helpers.py +0 -0
  36. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/src/context_compiler/py.typed +0 -0
  37. {context_compiler-0.9.0.dev8 → context_compiler-0.9.0.dev9}/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.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
@@ -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.0dev9"
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)
@@ -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,10 +46,14 @@ 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
45
57
  operand_names: tuple[str, ...]
46
58
  exact_text: str | None
47
59
  renderer: Callable[[MappingProxyType[str, str]], str]
@@ -126,56 +138,56 @@ def _render_exact(text: str) -> Callable[[MappingProxyType[str, str]], str]:
126
138
 
127
139
  _DIRECTIVE_SPECS = MappingProxyType(
128
140
  {
129
- _DirectiveKind.SET_PREMISE: _DirectiveSpec(
130
- kind=_DirectiveKind.SET_PREMISE,
141
+ DirectiveKind.SET_PREMISE: _DirectiveSpec(
142
+ kind=DirectiveKind.SET_PREMISE,
131
143
  operand_names=("value",),
132
144
  exact_text=None,
133
145
  renderer=_render_with_prefix(_SET_PREMISE_PREFIX, "value"),
134
146
  ),
135
- _DirectiveKind.CHANGE_PREMISE: _DirectiveSpec(
136
- kind=_DirectiveKind.CHANGE_PREMISE,
147
+ DirectiveKind.CHANGE_PREMISE: _DirectiveSpec(
148
+ kind=DirectiveKind.CHANGE_PREMISE,
137
149
  operand_names=("value",),
138
150
  exact_text=None,
139
151
  renderer=_render_with_prefix(_CHANGE_PREMISE_PREFIX, "value"),
140
152
  ),
141
- _DirectiveKind.USE_ITEM: _DirectiveSpec(
142
- kind=_DirectiveKind.USE_ITEM,
153
+ DirectiveKind.USE_ITEM: _DirectiveSpec(
154
+ kind=DirectiveKind.USE_ITEM,
143
155
  operand_names=("item",),
144
156
  exact_text=None,
145
157
  renderer=_render_with_prefix(_USE_PREFIX, "item"),
146
158
  ),
147
- _DirectiveKind.PROHIBIT_ITEM: _DirectiveSpec(
148
- kind=_DirectiveKind.PROHIBIT_ITEM,
159
+ DirectiveKind.PROHIBIT_ITEM: _DirectiveSpec(
160
+ kind=DirectiveKind.PROHIBIT_ITEM,
149
161
  operand_names=("item",),
150
162
  exact_text=None,
151
163
  renderer=_render_with_prefix(_PROHIBIT_PREFIX, "item"),
152
164
  ),
153
- _DirectiveKind.REMOVE_POLICY: _DirectiveSpec(
154
- kind=_DirectiveKind.REMOVE_POLICY,
165
+ DirectiveKind.REMOVE_POLICY: _DirectiveSpec(
166
+ kind=DirectiveKind.REMOVE_POLICY,
155
167
  operand_names=("item",),
156
168
  exact_text=None,
157
169
  renderer=_render_with_prefix(_REMOVE_POLICY_PREFIX, "item"),
158
170
  ),
159
- _DirectiveKind.REPLACE_USE: _DirectiveSpec(
160
- kind=_DirectiveKind.REPLACE_USE,
171
+ DirectiveKind.REPLACE_USE: _DirectiveSpec(
172
+ kind=DirectiveKind.REPLACE_USE,
161
173
  operand_names=("new_item", "old_item"),
162
174
  exact_text=None,
163
175
  renderer=_render_replace_use,
164
176
  ),
165
- _DirectiveKind.CLEAR_PREMISE: _DirectiveSpec(
166
- kind=_DirectiveKind.CLEAR_PREMISE,
177
+ DirectiveKind.CLEAR_PREMISE: _DirectiveSpec(
178
+ kind=DirectiveKind.CLEAR_PREMISE,
167
179
  operand_names=(),
168
180
  exact_text=_CLEAR_PREMISE_TEXT,
169
181
  renderer=_render_exact(_CLEAR_PREMISE_TEXT),
170
182
  ),
171
- _DirectiveKind.RESET_POLICIES: _DirectiveSpec(
172
- kind=_DirectiveKind.RESET_POLICIES,
183
+ DirectiveKind.RESET_POLICIES: _DirectiveSpec(
184
+ kind=DirectiveKind.RESET_POLICIES,
173
185
  operand_names=(),
174
186
  exact_text=_RESET_POLICIES_TEXT,
175
187
  renderer=_render_exact(_RESET_POLICIES_TEXT),
176
188
  ),
177
- _DirectiveKind.CLEAR_STATE: _DirectiveSpec(
178
- kind=_DirectiveKind.CLEAR_STATE,
189
+ DirectiveKind.CLEAR_STATE: _DirectiveSpec(
190
+ kind=DirectiveKind.CLEAR_STATE,
179
191
  operand_names=(),
180
192
  exact_text=_CLEAR_STATE_TEXT,
181
193
  renderer=_render_exact(_CLEAR_STATE_TEXT),
@@ -305,11 +317,24 @@ def _parse_replace_use(trimmed_text: str) -> CanonicalDirective | None:
305
317
  return None
306
318
  return CanonicalDirective(
307
319
  text=trimmed_text,
308
- kind=_DirectiveKind.REPLACE_USE,
320
+ kind=DirectiveKind.REPLACE_USE,
309
321
  operands=MappingProxyType({"new_item": new_item, "old_item": old_item}),
310
322
  )
311
323
 
312
324
 
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
+
313
338
  def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSyntax | None:
314
339
  """Parse one canonical directive into its semantic kind and operands.
315
340
 
@@ -326,50 +351,76 @@ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSynta
326
351
  if not _starts_with_directive_family(trimmed_text):
327
352
  return None
328
353
  if _contains_multiple_canonical_directives(trimmed_text):
329
- return InvalidDirectiveSyntax()
330
-
331
- invalid_result = InvalidDirectiveSyntax()
354
+ return _invalid_directive_syntax(DirectiveSyntaxFailure.COMPOUND_DIRECTIVE)
332
355
 
333
356
  normalized = _normalized_for_matching(trimmed_text)
334
357
 
335
358
  if normalized == _CLEAR_PREMISE_TEXT:
336
359
  return CanonicalDirective(
337
- text=text, kind=_DirectiveKind.CLEAR_PREMISE, operands=MappingProxyType({})
360
+ text=text, kind=DirectiveKind.CLEAR_PREMISE, operands=MappingProxyType({})
338
361
  )
339
362
  if normalized == _RESET_POLICIES_TEXT:
340
363
  return CanonicalDirective(
341
364
  text=text,
342
- kind=_DirectiveKind.RESET_POLICIES,
365
+ kind=DirectiveKind.RESET_POLICIES,
343
366
  operands=MappingProxyType({}),
344
367
  )
345
368
  if normalized == _CLEAR_STATE_TEXT:
346
369
  return CanonicalDirective(
347
- text=text, kind=_DirectiveKind.CLEAR_STATE, operands=MappingProxyType({})
370
+ text=text, kind=DirectiveKind.CLEAR_STATE, operands=MappingProxyType({})
371
+ )
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",
348
378
  )
349
379
 
350
380
  if normalized.startswith("set premise "):
351
381
  match = _SET_PREMISE_RE.fullmatch(trimmed_text)
352
382
  if match is None:
353
- return invalid_result
383
+ return _invalid_directive_syntax(
384
+ DirectiveSyntaxFailure.MALFORMED_DIRECTIVE,
385
+ directive_kind=DirectiveKind.SET_PREMISE,
386
+ )
354
387
  value = match.group("value")
355
388
  if not _operand_has_content(value) or _operand_starts_with_token(value, "to"):
356
- return invalid_result
389
+ return _invalid_directive_syntax(
390
+ DirectiveSyntaxFailure.MALFORMED_DIRECTIVE,
391
+ directive_kind=DirectiveKind.SET_PREMISE,
392
+ )
357
393
  return CanonicalDirective(
358
394
  text=text,
359
- kind=_DirectiveKind.SET_PREMISE,
395
+ kind=DirectiveKind.SET_PREMISE,
360
396
  operands=MappingProxyType({"value": value}),
361
397
  )
362
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
+
363
406
  if normalized.startswith("change premise to "):
364
407
  match = _CHANGE_PREMISE_RE.fullmatch(trimmed_text)
365
408
  if match is None:
366
- return invalid_result
409
+ return _invalid_directive_syntax(
410
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
411
+ directive_kind=DirectiveKind.CHANGE_PREMISE,
412
+ missing_operand="value",
413
+ )
367
414
  value = match.group("value")
368
415
  if not _operand_has_content(value):
369
- return invalid_result
416
+ return _invalid_directive_syntax(
417
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
418
+ directive_kind=DirectiveKind.CHANGE_PREMISE,
419
+ missing_operand="value",
420
+ )
370
421
  return CanonicalDirective(
371
422
  text=text,
372
- kind=_DirectiveKind.CHANGE_PREMISE,
423
+ kind=DirectiveKind.CHANGE_PREMISE,
373
424
  operands=MappingProxyType({"value": value}),
374
425
  )
375
426
 
@@ -377,58 +428,115 @@ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSynta
377
428
  if replacement is not None:
378
429
  return replacement
379
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
+
380
438
  if normalized.startswith("use "):
381
439
  match = _USE_RE.fullmatch(trimmed_text)
382
440
  if match is None:
383
- return invalid_result
441
+ return _invalid_directive_syntax(
442
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
443
+ directive_kind=DirectiveKind.USE_ITEM,
444
+ missing_operand="item",
445
+ )
384
446
  item = match.group("item")
385
447
  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
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
+ )
393
471
  return CanonicalDirective(
394
472
  text=text,
395
- kind=_DirectiveKind.USE_ITEM,
473
+ kind=DirectiveKind.USE_ITEM,
396
474
  operands=MappingProxyType({"item": item}),
397
475
  )
398
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
+
399
484
  if normalized.startswith("prohibit "):
400
485
  match = _PROHIBIT_RE.fullmatch(trimmed_text)
401
486
  if match is None:
402
- return invalid_result
487
+ return _invalid_directive_syntax(
488
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
489
+ directive_kind=DirectiveKind.PROHIBIT_ITEM,
490
+ missing_operand="item",
491
+ )
403
492
  item = match.group("item")
404
493
  if not _operand_has_content(item):
405
- return invalid_result
494
+ return _invalid_directive_syntax(
495
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
496
+ directive_kind=DirectiveKind.PROHIBIT_ITEM,
497
+ missing_operand="item",
498
+ )
406
499
  return CanonicalDirective(
407
500
  text=text,
408
- kind=_DirectiveKind.PROHIBIT_ITEM,
501
+ kind=DirectiveKind.PROHIBIT_ITEM,
409
502
  operands=MappingProxyType({"item": item}),
410
503
  )
411
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
+
412
512
  if normalized.startswith("remove policy "):
413
513
  match = _REMOVE_POLICY_RE.fullmatch(trimmed_text)
414
514
  if match is None:
415
- return invalid_result
515
+ return _invalid_directive_syntax(
516
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
517
+ directive_kind=DirectiveKind.REMOVE_POLICY,
518
+ missing_operand="item",
519
+ )
416
520
  item = match.group("item")
417
521
  if not _operand_has_content(item):
418
- return invalid_result
522
+ return _invalid_directive_syntax(
523
+ DirectiveSyntaxFailure.MISSING_REQUIRED_OPERAND,
524
+ directive_kind=DirectiveKind.REMOVE_POLICY,
525
+ missing_operand="item",
526
+ )
419
527
  return CanonicalDirective(
420
528
  text=text,
421
- kind=_DirectiveKind.REMOVE_POLICY,
529
+ kind=DirectiveKind.REMOVE_POLICY,
422
530
  operands=MappingProxyType({"item": item}),
423
531
  )
424
532
 
425
- return invalid_result
533
+ return _invalid_directive_syntax(DirectiveSyntaxFailure.MALFORMED_DIRECTIVE)
426
534
 
427
535
 
428
- def _render_directive(kind: _DirectiveKind | str, /, **operands: str) -> str:
536
+ def _render_directive(kind: DirectiveKind | str, /, **operands: str) -> str:
429
537
  """Produce canonical directive text from a semantic kind and operands."""
430
538
  try:
431
- normalized_kind = kind if isinstance(kind, _DirectiveKind) else _DirectiveKind(kind)
539
+ normalized_kind = kind if isinstance(kind, DirectiveKind) else DirectiveKind(kind)
432
540
  spec = _DIRECTIVE_SPECS[normalized_kind]
433
541
  except (KeyError, ValueError) as exc:
434
542
  raise ValueError(f"Unsupported directive kind: {kind!r}") from exc
@@ -462,6 +570,8 @@ def _render_directive(kind: _DirectiveKind | str, /, **operands: str) -> str:
462
570
 
463
571
 
464
572
  __all__ = [
573
+ "DirectiveKind",
574
+ "DirectiveSyntaxFailure",
465
575
  "CanonicalDirective",
466
576
  "InvalidDirectiveSyntax",
467
577
  "decompose_directive",