flowspec2 1.0.0__py3-none-any.whl
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.
- flowspec2/__init__.py +105 -0
- flowspec2/authoring/__init__.py +275 -0
- flowspec2/authoring/authoring-evidence-signature.schema.json +34 -0
- flowspec2/authoring/authoring-evidence.schema.json +455 -0
- flowspec2/authoring/authoring-operational-evidence.schema.json +160 -0
- flowspec2/authoring/benchmark.py +1409 -0
- flowspec2/authoring/corpus/await_correction.case.json +220 -0
- flowspec2/authoring/corpus/flowspec2.ctk.json +769 -0
- flowspec2/authoring/corpus/gated_derive.case.json +100 -0
- flowspec2/authoring/corpus/linear.case.json +55 -0
- flowspec2/authoring/corpus/manifest.json +31 -0
- flowspec2/authoring/corpus/subflow.case.json +66 -0
- flowspec2/authoring/corpus/terminal.case.json +94 -0
- flowspec2/authoring/corpus.py +307 -0
- flowspec2/authoring/ctk.py +836 -0
- flowspec2/authoring/detached_signature.py +120 -0
- flowspec2/authoring/evidence.py +311 -0
- flowspec2/authoring/evidence_signature.py +187 -0
- flowspec2/authoring/evidence_verification.py +229 -0
- flowspec2/authoring/gemini.py +215 -0
- flowspec2/authoring/operational-corpus.json +61 -0
- flowspec2/authoring/operational.py +956 -0
- flowspec2/authoring/operational_providers.py +167 -0
- flowspec2/authoring/presentation_review.py +1013 -0
- flowspec2/authoring/presentation_review_signature.py +305 -0
- flowspec2/authoring/projection.py +299 -0
- flowspec2/authoring/provider_prompt.py +83 -0
- flowspec2/backends/__init__.py +82 -0
- flowspec2/backends/http.py +189 -0
- flowspec2/checker.py +238 -0
- flowspec2/cli.py +789 -0
- flowspec2/cli_parser.py +345 -0
- flowspec2/clock.py +23 -0
- flowspec2/compat/__init__.py +47 -0
- flowspec2/compat/models.py +82 -0
- flowspec2/compat/open_workflow.py +616 -0
- flowspec2/compat/rasa.py +19 -0
- flowspec2/compat/rasa_export.py +876 -0
- flowspec2/compat/rasa_import.py +992 -0
- flowspec2/compat/rasa_shared.py +270 -0
- flowspec2/compat/schemas/open-workflow-conversation-1.schema.json +138 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.LICENSE +201 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.provenance.json +13 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.workflow.yaml +1956 -0
- flowspec2/compat/tool_profiles.py +149 -0
- flowspec2/compat/yaml.py +147 -0
- flowspec2/compiler.py +957 -0
- flowspec2/compiler_contracts.py +17 -0
- flowspec2/compiler_resume_contracts.py +594 -0
- flowspec2/compiler_schema_relations.py +1830 -0
- flowspec2/compiler_tool_contracts.py +245 -0
- flowspec2/compiler_value_contracts.py +447 -0
- flowspec2/derive.py +32 -0
- flowspec2/diagnostics.py +94 -0
- flowspec2/domains.py +489 -0
- flowspec2/experimental/__init__.py +57 -0
- flowspec2/experimental/flowspec-3-draft.schema.json +923 -0
- flowspec2/experimental/v3-preview-loss-policy.json +161 -0
- flowspec2/experimental/v3_lowering.py +928 -0
- flowspec2/experimental/v3_preview.py +2460 -0
- flowspec2/flowspec-2.schema.json +635 -0
- flowspec2/interactive.py +300 -0
- flowspec2/ir.py +1459 -0
- flowspec2/json_codec.py +120 -0
- flowspec2/llm.py +366 -0
- flowspec2/models.py +121 -0
- flowspec2/nodes.py +1537 -0
- flowspec2/observability.py +151 -0
- flowspec2/predicates.py +89 -0
- flowspec2/profiles.py +118 -0
- flowspec2/py.typed +0 -0
- flowspec2/runtime.py +1059 -0
- flowspec2/schema.py +54 -0
- flowspec2/schema_contracts.py +203 -0
- flowspec2/semantic_derive_contracts.py +530 -0
- flowspec2/semantic_path_contracts.py +528 -0
- flowspec2/semantic_predicate_contracts.py +355 -0
- flowspec2/semantic_schema_contracts.py +137 -0
- flowspec2/semantic_source_contracts.py +21 -0
- flowspec2/semantic_state_contracts.py +450 -0
- flowspec2/semantic_support.py +40 -0
- flowspec2/semantics.py +410 -0
- flowspec2/state_migration.py +1034 -0
- flowspec2/subflows/__init__.py +1117 -0
- flowspec2/subflows/address.py +328 -0
- flowspec2/subflows/identification.py +1031 -0
- flowspec2/tools.py +1154 -0
- flowspec2-1.0.0.dist-info/METADATA +482 -0
- flowspec2-1.0.0.dist-info/RECORD +92 -0
- flowspec2-1.0.0.dist-info/WHEEL +4 -0
- flowspec2-1.0.0.dist-info/entry_points.txt +2 -0
- flowspec2-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,2460 @@
|
|
|
1
|
+
"""Experimental flowspec/3-draft source preview and v2 migration helpers.
|
|
2
|
+
|
|
3
|
+
The preview is deliberately isolated from runtime compilation. Its only purpose
|
|
4
|
+
is to make the proposed authoring surface executable as a schema and measurable
|
|
5
|
+
against existing flowspec/2 documents before any stable format decision.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import unicodedata
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Iterable, Mapping, cast
|
|
17
|
+
|
|
18
|
+
import jsonschema
|
|
19
|
+
|
|
20
|
+
from ..diagnostics import DiagnosticLocation, FlowDiagnostic
|
|
21
|
+
from ..domains import categorical_number_bindings, parse_affirmation
|
|
22
|
+
from ..schema import validate_flow
|
|
23
|
+
from ..semantics import semantic_diagnostics
|
|
24
|
+
|
|
25
|
+
V2_SCHEMA_IDENTIFIER = "flowspec/2"
|
|
26
|
+
V3_PREVIEW_SCHEMA_IDENTIFIER = "flowspec/3-draft"
|
|
27
|
+
V3_PREVIEW_LOSS_POLICY_CONTRACT = "flowspec2/v3-preview-loss-policy@1"
|
|
28
|
+
|
|
29
|
+
_SCHEMA_PATH = Path(__file__).with_name("flowspec-3-draft.schema.json")
|
|
30
|
+
_LOSS_POLICY_PATH = Path(__file__).with_name("v3-preview-loss-policy.json")
|
|
31
|
+
_PREDICATE_REFERENCE_NAMESPACES = {
|
|
32
|
+
"internal": "$internal",
|
|
33
|
+
"payload": "$payload",
|
|
34
|
+
"config": "$config",
|
|
35
|
+
"address": "$address",
|
|
36
|
+
}
|
|
37
|
+
_SLOT_CONTRACT_KEYS = (
|
|
38
|
+
"persist",
|
|
39
|
+
"required",
|
|
40
|
+
"nullable",
|
|
41
|
+
"prefill_sources",
|
|
42
|
+
"max_attempts",
|
|
43
|
+
"on_exhaust",
|
|
44
|
+
"default",
|
|
45
|
+
"fill_only_when_asked",
|
|
46
|
+
)
|
|
47
|
+
_TOP_LEVEL_NATIVE_KEYS = {
|
|
48
|
+
"schema",
|
|
49
|
+
"flow",
|
|
50
|
+
"version",
|
|
51
|
+
"service",
|
|
52
|
+
"route",
|
|
53
|
+
"config",
|
|
54
|
+
"domains",
|
|
55
|
+
"slots",
|
|
56
|
+
"path",
|
|
57
|
+
"uses",
|
|
58
|
+
"derive",
|
|
59
|
+
"confirm",
|
|
60
|
+
"terminal",
|
|
61
|
+
"overrides",
|
|
62
|
+
"entry",
|
|
63
|
+
"auto_flow",
|
|
64
|
+
"capabilities",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_schema_cache: dict[str, Any] | None = None
|
|
68
|
+
_loss_policy_cache: dict[V3PreviewLossCategory, _V3PreviewLossRule] | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class V3PreviewDocumentMode(StrEnum):
|
|
72
|
+
"""Validation boundary for authored sources and v2 migration artifacts."""
|
|
73
|
+
|
|
74
|
+
AUTHORING = "authoring"
|
|
75
|
+
V2_MIGRATION = "v2_migration"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class V3PreviewLossCategory(StrEnum):
|
|
79
|
+
"""Closed reasons why the v2 migrator cannot emit native preview syntax."""
|
|
80
|
+
|
|
81
|
+
AGENT_CAPABILITY = "agent_capability"
|
|
82
|
+
AUTOMATIC_FLOW_CONTRACT = "automatic_flow_contract"
|
|
83
|
+
AWAIT_RESUME_CONTRACT = "await_resume_contract"
|
|
84
|
+
AWAIT_TIMEOUT_DURATION = "await_timeout_duration"
|
|
85
|
+
DUPLICATE_DOMAIN_ROW = "duplicate_domain_row"
|
|
86
|
+
DUPLICATE_TERMINAL_INPUT_PARAMETER = "duplicate_terminal_input_parameter"
|
|
87
|
+
ENTRY_CONTRACT = "entry_contract"
|
|
88
|
+
IMPLICIT_AWAIT_CAPABILITY = "implicit_await_capability"
|
|
89
|
+
INVALID_BOOLEAN_DOMAIN_FIELD = "invalid_boolean_domain_field"
|
|
90
|
+
INVALID_NON_CHOICE_DOMAIN_FIELD = "invalid_non_choice_domain_field"
|
|
91
|
+
INVALID_NON_CHOICE_SYNONYMS = "invalid_non_choice_synonyms"
|
|
92
|
+
INVALID_SYNONYM_TARGET = "invalid_synonym_target"
|
|
93
|
+
LEGACY_AWAIT_ENRICHMENT = "legacy_await_enrichment"
|
|
94
|
+
LEGACY_INTERACTIVE_GATE = "legacy_interactive_gate"
|
|
95
|
+
MISMATCHED_INTERACTIVE_DOMAIN = "mismatched_interactive_domain"
|
|
96
|
+
ORPHAN_DOMAIN_ROW = "orphan_domain_row"
|
|
97
|
+
SHADOWED_AWAIT_PRESENTATION = "shadowed_await_presentation"
|
|
98
|
+
UNKNOWN_DERIVE_ANCHOR = "unknown_derive_anchor"
|
|
99
|
+
UNKNOWN_TOP_LEVEL_FRAGMENT = "unknown_top_level_fragment"
|
|
100
|
+
UNUSED_CONFIRMATION_CONTRACT = "unused_confirmation_contract"
|
|
101
|
+
UNUSED_DOMAIN_DECLARATION = "unused_domain_declaration"
|
|
102
|
+
UNUSED_OVERRIDE_GATE = "unused_override_gate"
|
|
103
|
+
UNUSED_SLOT_DECLARATION = "unused_slot_declaration"
|
|
104
|
+
UNUSED_SUBFLOW_DECLARATION = "unused_subflow_declaration"
|
|
105
|
+
UNUSED_TERMINAL_CONTRACT = "unused_terminal_contract"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class V3PreviewLossDisposition(StrEnum):
|
|
109
|
+
"""Normative status assigned to a non-native source fragment."""
|
|
110
|
+
|
|
111
|
+
COMPATIBILITY_ONLY = "compatibility_only"
|
|
112
|
+
EXCLUDED = "excluded"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class V3PreviewLossHandling(StrEnum):
|
|
116
|
+
"""Required lowerer behavior for a loss-accounting category."""
|
|
117
|
+
|
|
118
|
+
REHYDRATE = "rehydrate"
|
|
119
|
+
REJECT = "reject"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True, slots=True)
|
|
123
|
+
class _V3PreviewLossRule:
|
|
124
|
+
disposition: V3PreviewLossDisposition
|
|
125
|
+
handling: V3PreviewLossHandling
|
|
126
|
+
description: str
|
|
127
|
+
source_path_pattern: str
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class V3PreviewMigrationError(ValueError):
|
|
131
|
+
"""A source construct cannot be represented as a valid preview document."""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class V3PreviewValidationError(ValueError):
|
|
135
|
+
"""A structurally valid preview violates one or more semantic contracts."""
|
|
136
|
+
|
|
137
|
+
def __init__(self, diagnostics: tuple[FlowDiagnostic, ...]) -> None:
|
|
138
|
+
self.diagnostics = diagnostics
|
|
139
|
+
summary = "; ".join(
|
|
140
|
+
f"{diagnostic.code} at {diagnostic.path or '/'}: {diagnostic.message}"
|
|
141
|
+
for diagnostic in diagnostics
|
|
142
|
+
)
|
|
143
|
+
super().__init__(summary)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True, slots=True)
|
|
147
|
+
class CompactByteComparison:
|
|
148
|
+
"""UTF-8 byte sizes of canonical compact JSON for a migration pair."""
|
|
149
|
+
|
|
150
|
+
source_bytes: int
|
|
151
|
+
preview_bytes: int
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def delta_bytes(self) -> int:
|
|
155
|
+
"""Return preview bytes minus source bytes without interpreting the sign."""
|
|
156
|
+
return self.preview_bytes - self.source_bytes
|
|
157
|
+
|
|
158
|
+
def as_dict(self) -> dict[str, int]:
|
|
159
|
+
"""Return a serialization-friendly measurement report."""
|
|
160
|
+
return {
|
|
161
|
+
"source_bytes": self.source_bytes,
|
|
162
|
+
"preview_bytes": self.preview_bytes,
|
|
163
|
+
"delta_bytes": self.delta_bytes,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True, slots=True)
|
|
168
|
+
class V3PreviewLossEntry:
|
|
169
|
+
"""Immutable classification of one non-native v2 source fragment."""
|
|
170
|
+
|
|
171
|
+
source_path: str
|
|
172
|
+
category: V3PreviewLossCategory
|
|
173
|
+
disposition: V3PreviewLossDisposition
|
|
174
|
+
_source_fragment_json: str = field(repr=False)
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def source_fragment(self) -> Any:
|
|
178
|
+
"""Return a fresh JSON value so callers cannot mutate the entry."""
|
|
179
|
+
return json.loads(self._source_fragment_json)
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def handling(self) -> V3PreviewLossHandling:
|
|
183
|
+
"""Return the policy-mandated lowerer behavior."""
|
|
184
|
+
return _loss_policy_rules()[self.category].handling
|
|
185
|
+
|
|
186
|
+
def as_dict(self) -> dict[str, Any]:
|
|
187
|
+
"""Return the canonical document representation of this entry."""
|
|
188
|
+
return {
|
|
189
|
+
"source_path": self.source_path,
|
|
190
|
+
"category": self.category.value,
|
|
191
|
+
"disposition": self.disposition.value,
|
|
192
|
+
"source_fragment": self.source_fragment,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(frozen=True, slots=True)
|
|
197
|
+
class V3PreviewMigrationReport:
|
|
198
|
+
"""Immutable migration artifact with explicit non-native source locations."""
|
|
199
|
+
|
|
200
|
+
_preview_json: str = field(repr=False)
|
|
201
|
+
loss_entries: tuple[V3PreviewLossEntry, ...]
|
|
202
|
+
compact_bytes: CompactByteComparison
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def preview_document(self) -> dict[str, Any]:
|
|
206
|
+
"""Return a fresh preview document so callers cannot mutate the report."""
|
|
207
|
+
return cast(dict[str, Any], json.loads(self._preview_json))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass(slots=True)
|
|
211
|
+
class _MigrationContext:
|
|
212
|
+
source_document: dict[str, Any]
|
|
213
|
+
domains: dict[str, dict[str, Any]]
|
|
214
|
+
slots: dict[str, dict[str, Any]]
|
|
215
|
+
use_declarations: list[dict[str, Any]]
|
|
216
|
+
derive_definitions: list[dict[str, Any]]
|
|
217
|
+
confirmation_definition: dict[str, Any] | None
|
|
218
|
+
terminal_definition: dict[str, Any] | None
|
|
219
|
+
override_gates: dict[str, dict[str, Any]]
|
|
220
|
+
await_capability: dict[str, Any] | None
|
|
221
|
+
used_domain_names: set[str] = field(default_factory=set)
|
|
222
|
+
placed_slot_names: set[str] = field(default_factory=set)
|
|
223
|
+
consumed_use_indexes: set[int] = field(default_factory=set)
|
|
224
|
+
consumed_derive_indexes: set[int] = field(default_factory=set)
|
|
225
|
+
consumed_override_gate_names: set[str] = field(default_factory=set)
|
|
226
|
+
confirmation_consumed: bool = False
|
|
227
|
+
terminal_consumed: bool = False
|
|
228
|
+
await_capability_consumed: bool = False
|
|
229
|
+
loss_entries_by_path: dict[str, V3PreviewLossEntry] = field(default_factory=dict)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def preview_loss_policy() -> dict[str, Any]:
|
|
233
|
+
"""Return a private copy of the authoritative preview loss policy."""
|
|
234
|
+
policy_document = cast(
|
|
235
|
+
dict[str, Any], json.loads(_LOSS_POLICY_PATH.read_text(encoding="utf-8"))
|
|
236
|
+
)
|
|
237
|
+
_validate_loss_policy_document(policy_document)
|
|
238
|
+
return cast(dict[str, Any], _canonical_clone(policy_document))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _loss_policy_rules() -> dict[V3PreviewLossCategory, _V3PreviewLossRule]:
|
|
242
|
+
global _loss_policy_cache
|
|
243
|
+
cached_rules = _loss_policy_cache
|
|
244
|
+
if cached_rules is not None:
|
|
245
|
+
return cached_rules
|
|
246
|
+
policy_document = preview_loss_policy()
|
|
247
|
+
category_documents = cast(dict[str, dict[str, str]], policy_document["categories"])
|
|
248
|
+
cached_rules = {
|
|
249
|
+
V3PreviewLossCategory(category_name): _V3PreviewLossRule(
|
|
250
|
+
disposition=V3PreviewLossDisposition(category_document["disposition"]),
|
|
251
|
+
handling=V3PreviewLossHandling(category_document["handling"]),
|
|
252
|
+
description=category_document["description"],
|
|
253
|
+
source_path_pattern=category_document["source_path_pattern"],
|
|
254
|
+
)
|
|
255
|
+
for category_name, category_document in category_documents.items()
|
|
256
|
+
}
|
|
257
|
+
_loss_policy_cache = cached_rules
|
|
258
|
+
return cached_rules
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _validate_loss_policy_document(policy_document: dict[str, Any]) -> None:
|
|
262
|
+
if set(policy_document) != {"_sections", "contract", "categories"}:
|
|
263
|
+
raise RuntimeError("preview loss policy has an invalid top-level shape")
|
|
264
|
+
section_index = policy_document.get("_sections")
|
|
265
|
+
if not isinstance(section_index, dict) or set(section_index) != {"loss-policy"}:
|
|
266
|
+
raise RuntimeError("preview loss policy has an invalid section index")
|
|
267
|
+
if policy_document.get("contract") != V3_PREVIEW_LOSS_POLICY_CONTRACT:
|
|
268
|
+
raise RuntimeError("preview loss policy has an unsupported contract")
|
|
269
|
+
category_documents = policy_document.get("categories")
|
|
270
|
+
if not isinstance(category_documents, dict):
|
|
271
|
+
raise RuntimeError("preview loss policy categories must be an object")
|
|
272
|
+
expected_categories = {category.value for category in V3PreviewLossCategory}
|
|
273
|
+
if set(category_documents) != expected_categories:
|
|
274
|
+
raise RuntimeError("preview loss policy categories do not match the closed category enum")
|
|
275
|
+
for category_name, category_document in category_documents.items():
|
|
276
|
+
if not isinstance(category_document, dict) or set(category_document) != {
|
|
277
|
+
"description",
|
|
278
|
+
"disposition",
|
|
279
|
+
"handling",
|
|
280
|
+
"source_path_pattern",
|
|
281
|
+
}:
|
|
282
|
+
raise RuntimeError(f"preview loss policy category {category_name!r} is malformed")
|
|
283
|
+
try:
|
|
284
|
+
disposition = V3PreviewLossDisposition(category_document["disposition"])
|
|
285
|
+
handling = V3PreviewLossHandling(category_document["handling"])
|
|
286
|
+
except (TypeError, ValueError) as error:
|
|
287
|
+
raise RuntimeError(
|
|
288
|
+
f"preview loss policy category {category_name!r} has an invalid decision"
|
|
289
|
+
) from error
|
|
290
|
+
expected_handling = (
|
|
291
|
+
V3PreviewLossHandling.REHYDRATE
|
|
292
|
+
if disposition is V3PreviewLossDisposition.COMPATIBILITY_ONLY
|
|
293
|
+
else V3PreviewLossHandling.REJECT
|
|
294
|
+
)
|
|
295
|
+
if handling is not expected_handling:
|
|
296
|
+
raise RuntimeError(
|
|
297
|
+
f"preview loss policy category {category_name!r} has an inconsistent decision"
|
|
298
|
+
)
|
|
299
|
+
if (
|
|
300
|
+
not isinstance(category_document["description"], str)
|
|
301
|
+
or not category_document["description"].strip()
|
|
302
|
+
):
|
|
303
|
+
raise RuntimeError(
|
|
304
|
+
f"preview loss policy category {category_name!r} needs a description"
|
|
305
|
+
)
|
|
306
|
+
source_path_pattern = category_document["source_path_pattern"]
|
|
307
|
+
if not isinstance(source_path_pattern, str):
|
|
308
|
+
raise RuntimeError(
|
|
309
|
+
f"preview loss policy category {category_name!r} needs a source path pattern"
|
|
310
|
+
)
|
|
311
|
+
try:
|
|
312
|
+
re.compile(source_path_pattern)
|
|
313
|
+
except re.error as error:
|
|
314
|
+
raise RuntimeError(
|
|
315
|
+
f"preview loss policy category {category_name!r} has an invalid source path pattern"
|
|
316
|
+
) from error
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def preview_schema() -> dict[str, Any]:
|
|
320
|
+
"""Return a private copy of the closed experimental Draft 2020-12 schema."""
|
|
321
|
+
global _schema_cache
|
|
322
|
+
cached_schema = _schema_cache
|
|
323
|
+
if cached_schema is None:
|
|
324
|
+
cached_schema = cast(dict[str, Any], json.loads(_SCHEMA_PATH.read_text(encoding="utf-8")))
|
|
325
|
+
jsonschema.Draft202012Validator.check_schema(cached_schema)
|
|
326
|
+
_schema_cache = cached_schema
|
|
327
|
+
return cast(dict[str, Any], _canonical_clone(cached_schema))
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def validate_v3_preview(
|
|
331
|
+
preview_document: Mapping[str, Any],
|
|
332
|
+
*,
|
|
333
|
+
mode: V3PreviewDocumentMode = V3PreviewDocumentMode.AUTHORING,
|
|
334
|
+
) -> None:
|
|
335
|
+
"""Raise when a document violates structural or semantic preview contracts."""
|
|
336
|
+
mode = V3PreviewDocumentMode(mode)
|
|
337
|
+
jsonschema.Draft202012Validator(preview_schema()).validate(preview_document)
|
|
338
|
+
if diagnostics := _semantic_v3_diagnostics(cast(dict[str, Any], preview_document), mode):
|
|
339
|
+
raise V3PreviewValidationError(diagnostics)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def check_v3_preview(
|
|
343
|
+
preview_document: object,
|
|
344
|
+
*,
|
|
345
|
+
mode: V3PreviewDocumentMode = V3PreviewDocumentMode.AUTHORING,
|
|
346
|
+
) -> tuple[FlowDiagnostic, ...]:
|
|
347
|
+
"""Return all deterministic structural or semantic preview diagnostics."""
|
|
348
|
+
mode = V3PreviewDocumentMode(mode)
|
|
349
|
+
validator = jsonschema.Draft202012Validator(preview_schema())
|
|
350
|
+
structural_findings = tuple(
|
|
351
|
+
sorted(
|
|
352
|
+
(
|
|
353
|
+
_schema_diagnostic(validation_error)
|
|
354
|
+
for validation_error in validator.iter_errors(cast(Any, preview_document))
|
|
355
|
+
),
|
|
356
|
+
key=lambda diagnostic: (diagnostic.path, diagnostic.code, diagnostic.message),
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
if structural_findings:
|
|
360
|
+
return structural_findings
|
|
361
|
+
return _semantic_v3_diagnostics(cast(dict[str, Any], preview_document), mode)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _schema_diagnostic(validation_error: jsonschema.ValidationError) -> FlowDiagnostic:
|
|
365
|
+
validator_name = re.sub(
|
|
366
|
+
r"[^A-Za-z0-9]+", "_", str(validation_error.validator or "violation")
|
|
367
|
+
).strip("_")
|
|
368
|
+
return FlowDiagnostic(
|
|
369
|
+
code=f"FLOWSPEC3_SCHEMA_{validator_name.upper() or 'VIOLATION'}",
|
|
370
|
+
severity="error",
|
|
371
|
+
path=_validation_error_pointer(validation_error),
|
|
372
|
+
message=validation_error.message,
|
|
373
|
+
related_locations=_validation_context_locations(validation_error),
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _validation_error_pointer(validation_error: jsonschema.ValidationError) -> str:
|
|
378
|
+
path_segments = list(validation_error.absolute_path)
|
|
379
|
+
if missing_property := _required_property(validation_error):
|
|
380
|
+
path_segments.append(missing_property)
|
|
381
|
+
return _pointer_from_segments(path_segments)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _required_property(validation_error: jsonschema.ValidationError) -> str | None:
|
|
385
|
+
if validation_error.validator != "required":
|
|
386
|
+
return None
|
|
387
|
+
required_properties = validation_error.validator_value
|
|
388
|
+
instance = validation_error.instance
|
|
389
|
+
if not isinstance(required_properties, list) or not isinstance(instance, Mapping):
|
|
390
|
+
return None
|
|
391
|
+
missing_properties = [
|
|
392
|
+
property_name
|
|
393
|
+
for property_name in required_properties
|
|
394
|
+
if isinstance(property_name, str) and property_name not in instance
|
|
395
|
+
]
|
|
396
|
+
return missing_properties[0] if len(missing_properties) == 1 else None
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _validation_context_locations(
|
|
400
|
+
validation_error: jsonschema.ValidationError,
|
|
401
|
+
) -> tuple[DiagnosticLocation, ...]:
|
|
402
|
+
locations = {
|
|
403
|
+
DiagnosticLocation(
|
|
404
|
+
path=_validation_error_pointer(context_error),
|
|
405
|
+
message=context_error.message,
|
|
406
|
+
)
|
|
407
|
+
for context_error in validation_error.context
|
|
408
|
+
}
|
|
409
|
+
return tuple(sorted(locations, key=lambda location: (location.path, location.message or "")))
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _pointer_from_segments(path_segments: Iterable[str | int]) -> str:
|
|
413
|
+
encoded_segments = (
|
|
414
|
+
str(path_segment).replace("~", "~0").replace("/", "~1") for path_segment in path_segments
|
|
415
|
+
)
|
|
416
|
+
return "".join(f"/{path_segment}" for path_segment in encoded_segments)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _v3_diagnostic(code: str, path: str, message: str) -> FlowDiagnostic:
|
|
420
|
+
return FlowDiagnostic(code=code, severity="error", path=path, message=message)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _loss_accounting_diagnostics(
|
|
424
|
+
preview_document: dict[str, Any], mode: V3PreviewDocumentMode
|
|
425
|
+
) -> list[FlowDiagnostic]:
|
|
426
|
+
passthrough = cast(dict[str, Any] | None, preview_document.get("v2_passthrough"))
|
|
427
|
+
if passthrough is None:
|
|
428
|
+
return []
|
|
429
|
+
if mode == V3PreviewDocumentMode.AUTHORING:
|
|
430
|
+
return [
|
|
431
|
+
_v3_diagnostic(
|
|
432
|
+
"FLOWSPEC3_AUTHORED_LOSS_ACCOUNTING",
|
|
433
|
+
"/v2_passthrough",
|
|
434
|
+
"v2 loss accounting is allowed only on v2 migration artifacts",
|
|
435
|
+
)
|
|
436
|
+
]
|
|
437
|
+
|
|
438
|
+
diagnostics: list[FlowDiagnostic] = []
|
|
439
|
+
loss_entries = cast(list[dict[str, Any]], passthrough["entries"])
|
|
440
|
+
source_paths = [cast(str, loss_entry["source_path"]) for loss_entry in loss_entries]
|
|
441
|
+
if source_paths != sorted(source_paths):
|
|
442
|
+
diagnostics.append(
|
|
443
|
+
_v3_diagnostic(
|
|
444
|
+
"FLOWSPEC3_UNORDERED_LOSS_ENTRIES",
|
|
445
|
+
"/v2_passthrough/entries",
|
|
446
|
+
"loss entries must be ordered by source_path",
|
|
447
|
+
)
|
|
448
|
+
)
|
|
449
|
+
first_indexes_by_path: dict[str, int] = {}
|
|
450
|
+
policy_rules = _loss_policy_rules()
|
|
451
|
+
for loss_index, loss_entry in enumerate(loss_entries):
|
|
452
|
+
loss_path = f"/v2_passthrough/entries/{loss_index}"
|
|
453
|
+
source_path = cast(str, loss_entry["source_path"])
|
|
454
|
+
if (first_index := first_indexes_by_path.get(source_path)) is not None:
|
|
455
|
+
diagnostics.append(
|
|
456
|
+
_v3_diagnostic(
|
|
457
|
+
"FLOWSPEC3_DUPLICATE_LOSS_SOURCE_PATH",
|
|
458
|
+
f"{loss_path}/source_path",
|
|
459
|
+
f"source path {source_path!r} is already accounted at "
|
|
460
|
+
f"/v2_passthrough/entries/{first_index}",
|
|
461
|
+
)
|
|
462
|
+
)
|
|
463
|
+
else:
|
|
464
|
+
first_indexes_by_path[source_path] = loss_index
|
|
465
|
+
try:
|
|
466
|
+
category = V3PreviewLossCategory(cast(str, loss_entry["category"]))
|
|
467
|
+
except ValueError:
|
|
468
|
+
diagnostics.append(
|
|
469
|
+
_v3_diagnostic(
|
|
470
|
+
"FLOWSPEC3_UNKNOWN_LOSS_CATEGORY",
|
|
471
|
+
f"{loss_path}/category",
|
|
472
|
+
f"unknown loss category {loss_entry['category']!r}",
|
|
473
|
+
)
|
|
474
|
+
)
|
|
475
|
+
continue
|
|
476
|
+
expected_disposition = policy_rules[category].disposition
|
|
477
|
+
if loss_entry["disposition"] != expected_disposition.value:
|
|
478
|
+
diagnostics.append(
|
|
479
|
+
_v3_diagnostic(
|
|
480
|
+
"FLOWSPEC3_LOSS_DISPOSITION_MISMATCH",
|
|
481
|
+
f"{loss_path}/disposition",
|
|
482
|
+
f"category {category.value!r} requires disposition "
|
|
483
|
+
f"{expected_disposition.value!r}",
|
|
484
|
+
)
|
|
485
|
+
)
|
|
486
|
+
if re.fullmatch(policy_rules[category].source_path_pattern, source_path) is None:
|
|
487
|
+
diagnostics.append(
|
|
488
|
+
_v3_diagnostic(
|
|
489
|
+
"FLOWSPEC3_LOSS_SOURCE_PATH_MISMATCH",
|
|
490
|
+
f"{loss_path}/source_path",
|
|
491
|
+
f"source path {source_path!r} is not allowed for category {category.value!r}",
|
|
492
|
+
)
|
|
493
|
+
)
|
|
494
|
+
return diagnostics
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _semantic_v3_diagnostics(
|
|
498
|
+
preview_document: dict[str, Any], mode: V3PreviewDocumentMode
|
|
499
|
+
) -> tuple[FlowDiagnostic, ...]:
|
|
500
|
+
diagnostics = _loss_accounting_diagnostics(preview_document, mode)
|
|
501
|
+
steps = cast(list[dict[str, Any]], preview_document["steps"])
|
|
502
|
+
identifiers: dict[str, tuple[int, str]] = {}
|
|
503
|
+
slot_positions: dict[str, int] = {}
|
|
504
|
+
derive_positions: dict[str, int] = {}
|
|
505
|
+
use_positions: dict[str, int] = {}
|
|
506
|
+
has_profile_owned_slots = any("use" in preview_step for preview_step in steps)
|
|
507
|
+
|
|
508
|
+
for step_index, preview_step in enumerate(steps):
|
|
509
|
+
step_path = _json_pointer("steps", step_index)
|
|
510
|
+
if subflow_reference := preview_step.get("use"):
|
|
511
|
+
subflow_reference = cast(str, subflow_reference)
|
|
512
|
+
previous_use_position = use_positions.get(subflow_reference)
|
|
513
|
+
if previous_use_position is not None:
|
|
514
|
+
diagnostics.append(
|
|
515
|
+
_v3_diagnostic(
|
|
516
|
+
"FLOWSPEC3_DUPLICATE_SUBFLOW_USE",
|
|
517
|
+
f"{step_path}/use",
|
|
518
|
+
f"subflow {subflow_reference!r} is already used at "
|
|
519
|
+
f"/steps/{previous_use_position}",
|
|
520
|
+
)
|
|
521
|
+
)
|
|
522
|
+
else:
|
|
523
|
+
use_positions[subflow_reference] = step_index
|
|
524
|
+
step_identifier = _preview_step_identifier(preview_step)
|
|
525
|
+
if step_identifier is not None:
|
|
526
|
+
identifier_path = f"{step_path}/id" if "id" in preview_step else step_path
|
|
527
|
+
if previous_identifier := identifiers.get(step_identifier):
|
|
528
|
+
diagnostics.append(
|
|
529
|
+
_v3_diagnostic(
|
|
530
|
+
"FLOWSPEC3_DUPLICATE_STEP_ID",
|
|
531
|
+
identifier_path,
|
|
532
|
+
f"step identifier {step_identifier!r} is already declared at "
|
|
533
|
+
f"{previous_identifier[1]}",
|
|
534
|
+
)
|
|
535
|
+
)
|
|
536
|
+
else:
|
|
537
|
+
identifiers[step_identifier] = (step_index, identifier_path)
|
|
538
|
+
|
|
539
|
+
for slot_kind in ("collect", "confirm"):
|
|
540
|
+
if slot_name := preview_step.get(slot_kind):
|
|
541
|
+
slot_name = cast(str, slot_name)
|
|
542
|
+
previous_position = slot_positions.get(slot_name)
|
|
543
|
+
if previous_position is not None:
|
|
544
|
+
diagnostics.append(
|
|
545
|
+
_v3_diagnostic(
|
|
546
|
+
"FLOWSPEC3_DUPLICATE_SLOT_WRITER",
|
|
547
|
+
f"{step_path}/{slot_kind}",
|
|
548
|
+
f"slot {slot_name!r} is already written by /steps/{previous_position}",
|
|
549
|
+
)
|
|
550
|
+
)
|
|
551
|
+
else:
|
|
552
|
+
slot_positions[slot_name] = step_index
|
|
553
|
+
|
|
554
|
+
if derive_name := preview_step.get("derive"):
|
|
555
|
+
derive_name = cast(str, derive_name)
|
|
556
|
+
if derive_name in derive_positions:
|
|
557
|
+
diagnostics.append(
|
|
558
|
+
_v3_diagnostic(
|
|
559
|
+
"FLOWSPEC3_DUPLICATE_DERIVE_WRITER",
|
|
560
|
+
f"{step_path}/derive",
|
|
561
|
+
f"derive target {derive_name!r} is already written",
|
|
562
|
+
)
|
|
563
|
+
)
|
|
564
|
+
elif derive_name in slot_positions:
|
|
565
|
+
diagnostics.append(
|
|
566
|
+
_v3_diagnostic(
|
|
567
|
+
"FLOWSPEC3_STATE_WRITER_COLLISION",
|
|
568
|
+
f"{step_path}/derive",
|
|
569
|
+
f"derive target {derive_name!r} collides with a slot writer",
|
|
570
|
+
)
|
|
571
|
+
)
|
|
572
|
+
else:
|
|
573
|
+
derive_positions[derive_name] = step_index
|
|
574
|
+
|
|
575
|
+
for slot_name, slot_position in slot_positions.items():
|
|
576
|
+
if slot_name in derive_positions:
|
|
577
|
+
diagnostics.append(
|
|
578
|
+
_v3_diagnostic(
|
|
579
|
+
"FLOWSPEC3_STATE_WRITER_COLLISION",
|
|
580
|
+
f"/steps/{slot_position}",
|
|
581
|
+
f"slot {slot_name!r} collides with a derive target",
|
|
582
|
+
)
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
_check_state_writer_collisions(preview_document, steps, diagnostics)
|
|
586
|
+
_check_step_cardinality(steps, diagnostics)
|
|
587
|
+
for step_index, preview_step in enumerate(steps):
|
|
588
|
+
_check_step_semantics(
|
|
589
|
+
preview_step,
|
|
590
|
+
step_index,
|
|
591
|
+
identifiers,
|
|
592
|
+
slot_positions,
|
|
593
|
+
derive_positions,
|
|
594
|
+
has_profile_owned_slots,
|
|
595
|
+
diagnostics,
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
return tuple(
|
|
599
|
+
sorted(
|
|
600
|
+
set(diagnostics),
|
|
601
|
+
key=lambda diagnostic: (diagnostic.path, diagnostic.code, diagnostic.message),
|
|
602
|
+
)
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _check_state_writer_collisions(
|
|
607
|
+
preview_document: dict[str, Any],
|
|
608
|
+
steps: list[dict[str, Any]],
|
|
609
|
+
diagnostics: list[FlowDiagnostic],
|
|
610
|
+
) -> None:
|
|
611
|
+
first_writer_by_key: dict[str, str] = {}
|
|
612
|
+
|
|
613
|
+
if "service" in preview_document:
|
|
614
|
+
_register_state_writer(
|
|
615
|
+
"service",
|
|
616
|
+
"/service",
|
|
617
|
+
first_writer_by_key,
|
|
618
|
+
diagnostics,
|
|
619
|
+
)
|
|
620
|
+
for step_index, preview_step in enumerate(steps):
|
|
621
|
+
step_path = _json_pointer("steps", step_index)
|
|
622
|
+
for writer_kind in ("collect", "confirm", "derive"):
|
|
623
|
+
if state_key := preview_step.get(writer_kind):
|
|
624
|
+
_register_state_writer(
|
|
625
|
+
cast(str, state_key),
|
|
626
|
+
f"{step_path}/{writer_kind}",
|
|
627
|
+
first_writer_by_key,
|
|
628
|
+
diagnostics,
|
|
629
|
+
)
|
|
630
|
+
if "await" in preview_step:
|
|
631
|
+
on_resume = cast(dict[str, Any], preview_step.get("on_resume", {}))
|
|
632
|
+
for state_key in cast(dict[str, Any], on_resume.get("set", {})):
|
|
633
|
+
_register_state_writer(
|
|
634
|
+
state_key,
|
|
635
|
+
f"{step_path}/on_resume/set/{_pointer_segment(state_key)}",
|
|
636
|
+
first_writer_by_key,
|
|
637
|
+
diagnostics,
|
|
638
|
+
)
|
|
639
|
+
enrichment = cast(dict[str, Any], on_resume.get("enrich", {}))
|
|
640
|
+
for state_key in cast(dict[str, Any], enrichment.get("set", {})):
|
|
641
|
+
_register_state_writer(
|
|
642
|
+
state_key,
|
|
643
|
+
f"{step_path}/on_resume/enrich/set/{_pointer_segment(state_key)}",
|
|
644
|
+
first_writer_by_key,
|
|
645
|
+
diagnostics,
|
|
646
|
+
)
|
|
647
|
+
if "submit" in preview_step:
|
|
648
|
+
for state_key in cast(dict[str, Any], preview_step.get("outputs", {})):
|
|
649
|
+
_register_state_writer(
|
|
650
|
+
state_key,
|
|
651
|
+
f"{step_path}/outputs/{_pointer_segment(state_key)}",
|
|
652
|
+
first_writer_by_key,
|
|
653
|
+
diagnostics,
|
|
654
|
+
)
|
|
655
|
+
success_set = cast(dict[str, Any], preview_step["outcomes"]["success"].get("set", {}))
|
|
656
|
+
for state_key in success_set:
|
|
657
|
+
_register_state_writer(
|
|
658
|
+
state_key,
|
|
659
|
+
f"{step_path}/outcomes/success/set/{_pointer_segment(state_key)}",
|
|
660
|
+
first_writer_by_key,
|
|
661
|
+
diagnostics,
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _register_state_writer(
|
|
666
|
+
state_key: str,
|
|
667
|
+
writer_path: str,
|
|
668
|
+
first_writer_by_key: dict[str, str],
|
|
669
|
+
diagnostics: list[FlowDiagnostic],
|
|
670
|
+
) -> None:
|
|
671
|
+
previous_writer = first_writer_by_key.get(state_key)
|
|
672
|
+
if previous_writer is None:
|
|
673
|
+
first_writer_by_key[state_key] = writer_path
|
|
674
|
+
return
|
|
675
|
+
diagnostics.append(
|
|
676
|
+
_v3_diagnostic(
|
|
677
|
+
"FLOWSPEC3_STATE_WRITER_COLLISION",
|
|
678
|
+
writer_path,
|
|
679
|
+
f"state key {state_key!r} is already written at {previous_writer}",
|
|
680
|
+
)
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _check_step_cardinality(steps: list[dict[str, Any]], diagnostics: list[FlowDiagnostic]) -> None:
|
|
685
|
+
cardinality_contracts = (
|
|
686
|
+
("submit", "FLOWSPEC3_MULTIPLE_SUBMIT_STEPS"),
|
|
687
|
+
("await", "FLOWSPEC3_MULTIPLE_AWAIT_STEPS"),
|
|
688
|
+
("correction_hub", "FLOWSPEC3_MULTIPLE_CORRECTION_HUBS"),
|
|
689
|
+
)
|
|
690
|
+
for step_key, diagnostic_code in cardinality_contracts:
|
|
691
|
+
matching_indexes = [
|
|
692
|
+
step_index for step_index, preview_step in enumerate(steps) if step_key in preview_step
|
|
693
|
+
]
|
|
694
|
+
for duplicate_index in matching_indexes[1:]:
|
|
695
|
+
diagnostics.append(
|
|
696
|
+
_v3_diagnostic(
|
|
697
|
+
diagnostic_code,
|
|
698
|
+
_json_pointer("steps", duplicate_index, step_key),
|
|
699
|
+
f"{step_key!r} is a singular flow-level construct",
|
|
700
|
+
)
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _check_step_semantics(
|
|
705
|
+
preview_step: dict[str, Any],
|
|
706
|
+
step_index: int,
|
|
707
|
+
identifiers: dict[str, tuple[int, str]],
|
|
708
|
+
slot_positions: dict[str, int],
|
|
709
|
+
derive_positions: dict[str, int],
|
|
710
|
+
has_profile_owned_slots: bool,
|
|
711
|
+
diagnostics: list[FlowDiagnostic],
|
|
712
|
+
) -> None:
|
|
713
|
+
step_path = _json_pointer("steps", step_index)
|
|
714
|
+
domain = cast(dict[str, Any] | None, preview_step.get("domain"))
|
|
715
|
+
if domain is not None:
|
|
716
|
+
_check_domain_semantics(domain, preview_step, step_path, diagnostics)
|
|
717
|
+
|
|
718
|
+
for required_index, required_reference in enumerate(preview_step.get("requires", [])):
|
|
719
|
+
_check_source_reference(
|
|
720
|
+
cast(dict[str, str], required_reference),
|
|
721
|
+
f"{step_path}/requires/{required_index}",
|
|
722
|
+
step_index,
|
|
723
|
+
slot_positions,
|
|
724
|
+
derive_positions,
|
|
725
|
+
has_profile_owned_slots,
|
|
726
|
+
diagnostics,
|
|
727
|
+
slots_only=True,
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
if "derive" in preview_step:
|
|
731
|
+
for source_index, source_reference in enumerate(preview_step["from"]):
|
|
732
|
+
_check_source_reference(
|
|
733
|
+
cast(dict[str, str], source_reference),
|
|
734
|
+
f"{step_path}/from/{source_index}",
|
|
735
|
+
step_index,
|
|
736
|
+
slot_positions,
|
|
737
|
+
derive_positions,
|
|
738
|
+
has_profile_owned_slots,
|
|
739
|
+
diagnostics,
|
|
740
|
+
)
|
|
741
|
+
if isinstance(preview_step.get("default"), dict):
|
|
742
|
+
default_reference = cast(dict[str, str], preview_step["default"])
|
|
743
|
+
if set(default_reference) & {"$slot", "$derive"}:
|
|
744
|
+
_check_source_reference(
|
|
745
|
+
default_reference,
|
|
746
|
+
f"{step_path}/default",
|
|
747
|
+
step_index,
|
|
748
|
+
slot_positions,
|
|
749
|
+
derive_positions,
|
|
750
|
+
has_profile_owned_slots,
|
|
751
|
+
diagnostics,
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
if "submit" in preview_step:
|
|
755
|
+
for parameter_name, source_reference in preview_step.get("input", {}).items():
|
|
756
|
+
_check_source_reference(
|
|
757
|
+
cast(dict[str, str], source_reference),
|
|
758
|
+
f"{step_path}/input/{_pointer_segment(parameter_name)}",
|
|
759
|
+
step_index,
|
|
760
|
+
slot_positions,
|
|
761
|
+
derive_positions,
|
|
762
|
+
has_profile_owned_slots,
|
|
763
|
+
diagnostics,
|
|
764
|
+
)
|
|
765
|
+
output_keys = set(cast(dict[str, Any], preview_step.get("outputs", {})))
|
|
766
|
+
success_keys = set(cast(dict[str, Any], preview_step["outcomes"]["success"].get("set", {})))
|
|
767
|
+
for colliding_key in sorted(output_keys & success_keys):
|
|
768
|
+
diagnostics.append(
|
|
769
|
+
_v3_diagnostic(
|
|
770
|
+
"FLOWSPEC3_SUBMIT_WRITE_COLLISION",
|
|
771
|
+
f"{step_path}/outcomes/success/set/{_pointer_segment(colliding_key)}",
|
|
772
|
+
f"state key {colliding_key!r} is also written by submit outputs",
|
|
773
|
+
)
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
if "confirm" in preview_step:
|
|
777
|
+
_check_confirmation_semantics(
|
|
778
|
+
preview_step,
|
|
779
|
+
step_index,
|
|
780
|
+
identifiers,
|
|
781
|
+
slot_positions,
|
|
782
|
+
derive_positions,
|
|
783
|
+
has_profile_owned_slots,
|
|
784
|
+
diagnostics,
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
if "await" in preview_step:
|
|
788
|
+
_check_await_semantics(preview_step, step_index, identifiers, diagnostics)
|
|
789
|
+
|
|
790
|
+
ui = cast(dict[str, Any] | None, preview_step.get("ui"))
|
|
791
|
+
if ui is not None:
|
|
792
|
+
_check_ui_semantics(
|
|
793
|
+
ui,
|
|
794
|
+
domain,
|
|
795
|
+
step_index,
|
|
796
|
+
slot_positions,
|
|
797
|
+
derive_positions,
|
|
798
|
+
has_profile_owned_slots,
|
|
799
|
+
diagnostics,
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
for predicate_key in ("ask_when", "skip_when", "gate"):
|
|
803
|
+
if predicate := preview_step.get(predicate_key):
|
|
804
|
+
_check_predicate_references(
|
|
805
|
+
cast(dict[str, Any], predicate),
|
|
806
|
+
f"{step_path}/{predicate_key}",
|
|
807
|
+
step_index,
|
|
808
|
+
slot_positions,
|
|
809
|
+
derive_positions,
|
|
810
|
+
has_profile_owned_slots,
|
|
811
|
+
diagnostics,
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _check_domain_semantics(
|
|
816
|
+
domain: dict[str, Any],
|
|
817
|
+
preview_step: dict[str, Any],
|
|
818
|
+
step_path: str,
|
|
819
|
+
diagnostics: list[FlowDiagnostic],
|
|
820
|
+
) -> None:
|
|
821
|
+
domain_type = cast(str, domain["type"])
|
|
822
|
+
minimum = domain.get("minimum")
|
|
823
|
+
maximum = domain.get("maximum")
|
|
824
|
+
if minimum is not None and maximum is not None and minimum > maximum:
|
|
825
|
+
diagnostics.append(
|
|
826
|
+
_v3_diagnostic(
|
|
827
|
+
"FLOWSPEC3_INVALID_NUMERIC_RANGE",
|
|
828
|
+
f"{step_path}/domain/minimum",
|
|
829
|
+
"minimum must not exceed maximum",
|
|
830
|
+
)
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
if domain_type in {"categorical", "bool"}:
|
|
834
|
+
_check_domain_options(domain, step_path, diagnostics)
|
|
835
|
+
|
|
836
|
+
if "default" in preview_step and not _domain_accepts_value(
|
|
837
|
+
domain, preview_step["default"], nullable=preview_step.get("nullable") is True
|
|
838
|
+
):
|
|
839
|
+
diagnostics.append(
|
|
840
|
+
_v3_diagnostic(
|
|
841
|
+
"FLOWSPEC3_INVALID_DEFAULT",
|
|
842
|
+
f"{step_path}/default",
|
|
843
|
+
"default is outside the inline domain or nullability contract",
|
|
844
|
+
)
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _check_domain_options(
|
|
849
|
+
domain: dict[str, Any], step_path: str, diagnostics: list[FlowDiagnostic]
|
|
850
|
+
) -> None:
|
|
851
|
+
option_owner_by_identity: dict[str, int] = {}
|
|
852
|
+
normalized_owner: dict[str, tuple[int | None, str]] = {}
|
|
853
|
+
accent_fold = cast(dict[str, Any], domain.get("normalize", {})).get("accent_fold") is True
|
|
854
|
+
for option_index, option in enumerate(cast(list[dict[str, Any]], domain["options"])):
|
|
855
|
+
option_value = option["value"]
|
|
856
|
+
option_identity = _json_identity(option_value)
|
|
857
|
+
option_path = f"{step_path}/domain/options/{option_index}"
|
|
858
|
+
if not cast(str, option["label"]).strip():
|
|
859
|
+
diagnostics.append(
|
|
860
|
+
_v3_diagnostic(
|
|
861
|
+
"FLOWSPEC3_EMPTY_OPTION_LABEL",
|
|
862
|
+
f"{option_path}/label",
|
|
863
|
+
"option label must contain non-whitespace text",
|
|
864
|
+
)
|
|
865
|
+
)
|
|
866
|
+
if option_identity in option_owner_by_identity:
|
|
867
|
+
diagnostics.append(
|
|
868
|
+
_v3_diagnostic(
|
|
869
|
+
"FLOWSPEC3_DUPLICATE_OPTION_VALUE",
|
|
870
|
+
f"{option_path}/value",
|
|
871
|
+
f"option value duplicates /domain/options/{option_owner_by_identity[option_identity]}",
|
|
872
|
+
)
|
|
873
|
+
)
|
|
874
|
+
else:
|
|
875
|
+
option_owner_by_identity[option_identity] = option_index
|
|
876
|
+
|
|
877
|
+
candidate_inputs = [str(option_value), *cast(list[str], option.get("aliases", []))]
|
|
878
|
+
for candidate_index, candidate_input in enumerate(candidate_inputs):
|
|
879
|
+
candidate_path = (
|
|
880
|
+
f"{option_path}/value"
|
|
881
|
+
if candidate_index == 0
|
|
882
|
+
else f"{option_path}/aliases/{candidate_index - 1}"
|
|
883
|
+
)
|
|
884
|
+
_register_normalized_input(
|
|
885
|
+
candidate_input,
|
|
886
|
+
option_index,
|
|
887
|
+
candidate_path,
|
|
888
|
+
accent_fold,
|
|
889
|
+
normalized_owner,
|
|
890
|
+
diagnostics,
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
for null_alias_index, null_alias in enumerate(domain.get("null_aliases", [])):
|
|
894
|
+
_register_normalized_input(
|
|
895
|
+
cast(str, null_alias),
|
|
896
|
+
None,
|
|
897
|
+
f"{step_path}/domain/null_aliases/{null_alias_index}",
|
|
898
|
+
accent_fold,
|
|
899
|
+
normalized_owner,
|
|
900
|
+
diagnostics,
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
normalization = cast(dict[str, Any], domain.get("normalize", {}))
|
|
904
|
+
if domain["type"] == "categorical" and normalization.get("number_words") is True:
|
|
905
|
+
option_values = [
|
|
906
|
+
option["value"] for option in cast(list[dict[str, Any]], domain["options"])
|
|
907
|
+
]
|
|
908
|
+
for number_input, target_value in categorical_number_bindings(option_values).items():
|
|
909
|
+
option_index = next(
|
|
910
|
+
index
|
|
911
|
+
for index, option_value in enumerate(option_values)
|
|
912
|
+
if _same_json_scalar(option_value, target_value)
|
|
913
|
+
)
|
|
914
|
+
_register_normalized_input(
|
|
915
|
+
number_input,
|
|
916
|
+
option_index,
|
|
917
|
+
f"{step_path}/domain/normalize/number_words",
|
|
918
|
+
accent_fold,
|
|
919
|
+
normalized_owner,
|
|
920
|
+
diagnostics,
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
if domain["type"] == "bool" and normalization.get("affirmation") is True:
|
|
924
|
+
for option_index, option in enumerate(cast(list[dict[str, Any]], domain["options"])):
|
|
925
|
+
for alias_index, option_alias in enumerate(option.get("aliases", [])):
|
|
926
|
+
parsed_alias = parse_affirmation(
|
|
927
|
+
option_alias,
|
|
928
|
+
emoji_veto=normalization.get("emoji_veto") is True,
|
|
929
|
+
)
|
|
930
|
+
if parsed_alias is not None and parsed_alias is not option["value"]:
|
|
931
|
+
diagnostics.append(
|
|
932
|
+
_v3_diagnostic(
|
|
933
|
+
"FLOWSPEC3_BOOLEAN_ALIAS_CONFLICT",
|
|
934
|
+
f"{step_path}/domain/options/{option_index}/aliases/{alias_index}",
|
|
935
|
+
"alias conflicts with the enabled affirmation normalization",
|
|
936
|
+
)
|
|
937
|
+
)
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _register_normalized_input(
|
|
941
|
+
candidate_input: str,
|
|
942
|
+
option_index: int | None,
|
|
943
|
+
candidate_path: str,
|
|
944
|
+
accent_fold: bool,
|
|
945
|
+
normalized_owner: dict[str, tuple[int | None, str]],
|
|
946
|
+
diagnostics: list[FlowDiagnostic],
|
|
947
|
+
) -> None:
|
|
948
|
+
normalized_input = _normalize_domain_input(candidate_input, accent_fold=accent_fold)
|
|
949
|
+
if not normalized_input:
|
|
950
|
+
diagnostics.append(
|
|
951
|
+
_v3_diagnostic(
|
|
952
|
+
"FLOWSPEC3_EMPTY_OPTION_INPUT",
|
|
953
|
+
candidate_path,
|
|
954
|
+
"option values and aliases must contain non-whitespace input",
|
|
955
|
+
)
|
|
956
|
+
)
|
|
957
|
+
return
|
|
958
|
+
if previous_owner := normalized_owner.get(normalized_input):
|
|
959
|
+
if previous_owner[0] == option_index:
|
|
960
|
+
if not previous_owner[1].endswith("/value"):
|
|
961
|
+
diagnostics.append(
|
|
962
|
+
_v3_diagnostic(
|
|
963
|
+
"FLOWSPEC3_DUPLICATE_OPTION_INPUT",
|
|
964
|
+
candidate_path,
|
|
965
|
+
f"normalized input duplicates {previous_owner[1]}",
|
|
966
|
+
)
|
|
967
|
+
)
|
|
968
|
+
return
|
|
969
|
+
diagnostics.append(
|
|
970
|
+
_v3_diagnostic(
|
|
971
|
+
"FLOWSPEC3_AMBIGUOUS_OPTION_INPUT",
|
|
972
|
+
candidate_path,
|
|
973
|
+
f"normalized input collides with {previous_owner[1]}",
|
|
974
|
+
)
|
|
975
|
+
)
|
|
976
|
+
return
|
|
977
|
+
normalized_owner[normalized_input] = (option_index, candidate_path)
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _normalize_domain_input(candidate_input: str, *, accent_fold: bool) -> str:
|
|
981
|
+
normalized_input = " ".join(candidate_input.split()).casefold()
|
|
982
|
+
if accent_fold:
|
|
983
|
+
normalized_input = "".join(
|
|
984
|
+
character
|
|
985
|
+
for character in unicodedata.normalize("NFKD", normalized_input)
|
|
986
|
+
if not unicodedata.combining(character)
|
|
987
|
+
)
|
|
988
|
+
return normalized_input
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _domain_accepts_value(domain: dict[str, Any], candidate: Any, *, nullable: bool) -> bool:
|
|
992
|
+
if candidate is None:
|
|
993
|
+
return nullable
|
|
994
|
+
domain_type = domain["type"]
|
|
995
|
+
if domain_type == "categorical":
|
|
996
|
+
return any(
|
|
997
|
+
_same_json_scalar(candidate, option["value"])
|
|
998
|
+
for option in cast(list[dict[str, Any]], domain["options"])
|
|
999
|
+
)
|
|
1000
|
+
if domain_type == "bool":
|
|
1001
|
+
return isinstance(candidate, bool)
|
|
1002
|
+
if domain_type == "integer":
|
|
1003
|
+
valid_type = isinstance(candidate, int) and not isinstance(candidate, bool)
|
|
1004
|
+
elif domain_type == "number":
|
|
1005
|
+
valid_type = isinstance(candidate, (int, float)) and not isinstance(candidate, bool)
|
|
1006
|
+
else:
|
|
1007
|
+
valid_type = isinstance(candidate, str)
|
|
1008
|
+
if not valid_type:
|
|
1009
|
+
return False
|
|
1010
|
+
if isinstance(candidate, (int, float)) and not isinstance(candidate, bool):
|
|
1011
|
+
minimum = domain.get("minimum")
|
|
1012
|
+
maximum = domain.get("maximum")
|
|
1013
|
+
return (minimum is None or candidate >= minimum) and (
|
|
1014
|
+
maximum is None or candidate <= maximum
|
|
1015
|
+
)
|
|
1016
|
+
return True
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _check_source_reference(
|
|
1020
|
+
source_reference: dict[str, str],
|
|
1021
|
+
reference_path: str,
|
|
1022
|
+
consumer_index: int,
|
|
1023
|
+
slot_positions: dict[str, int],
|
|
1024
|
+
derive_positions: dict[str, int],
|
|
1025
|
+
has_profile_owned_slots: bool,
|
|
1026
|
+
diagnostics: list[FlowDiagnostic],
|
|
1027
|
+
*,
|
|
1028
|
+
slots_only: bool = False,
|
|
1029
|
+
) -> None:
|
|
1030
|
+
reference_kind, source_name = next(iter(source_reference.items()))
|
|
1031
|
+
if reference_kind == "$slot":
|
|
1032
|
+
producer_index = slot_positions.get(source_name)
|
|
1033
|
+
if producer_index is None:
|
|
1034
|
+
if not has_profile_owned_slots:
|
|
1035
|
+
diagnostics.append(
|
|
1036
|
+
_v3_diagnostic(
|
|
1037
|
+
"FLOWSPEC3_UNKNOWN_SLOT_REFERENCE",
|
|
1038
|
+
reference_path,
|
|
1039
|
+
f"slot {source_name!r} has no local or profile-owned producer",
|
|
1040
|
+
)
|
|
1041
|
+
)
|
|
1042
|
+
return
|
|
1043
|
+
elif reference_kind == "$derive" and not slots_only:
|
|
1044
|
+
producer_index = derive_positions.get(source_name)
|
|
1045
|
+
if producer_index is None:
|
|
1046
|
+
diagnostics.append(
|
|
1047
|
+
_v3_diagnostic(
|
|
1048
|
+
"FLOWSPEC3_UNKNOWN_DERIVE_REFERENCE",
|
|
1049
|
+
reference_path,
|
|
1050
|
+
f"derive target {source_name!r} is not declared",
|
|
1051
|
+
)
|
|
1052
|
+
)
|
|
1053
|
+
return
|
|
1054
|
+
else:
|
|
1055
|
+
diagnostics.append(
|
|
1056
|
+
_v3_diagnostic(
|
|
1057
|
+
"FLOWSPEC3_INVALID_SOURCE_REFERENCE",
|
|
1058
|
+
reference_path,
|
|
1059
|
+
"this contract requires a slot reference",
|
|
1060
|
+
)
|
|
1061
|
+
)
|
|
1062
|
+
return
|
|
1063
|
+
if producer_index >= consumer_index:
|
|
1064
|
+
diagnostics.append(
|
|
1065
|
+
_v3_diagnostic(
|
|
1066
|
+
"FLOWSPEC3_SOURCE_NOT_AVAILABLE",
|
|
1067
|
+
reference_path,
|
|
1068
|
+
f"source {source_name!r} must be produced by an earlier step",
|
|
1069
|
+
)
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _check_confirmation_semantics(
|
|
1074
|
+
preview_step: dict[str, Any],
|
|
1075
|
+
step_index: int,
|
|
1076
|
+
identifiers: dict[str, tuple[int, str]],
|
|
1077
|
+
slot_positions: dict[str, int],
|
|
1078
|
+
derive_positions: dict[str, int],
|
|
1079
|
+
has_profile_owned_slots: bool,
|
|
1080
|
+
diagnostics: list[FlowDiagnostic],
|
|
1081
|
+
) -> None:
|
|
1082
|
+
step_path = _json_pointer("steps", step_index)
|
|
1083
|
+
if target_reference := preview_step.get("on_confirm"):
|
|
1084
|
+
_check_step_reference(
|
|
1085
|
+
cast(dict[str, str], target_reference),
|
|
1086
|
+
f"{step_path}/on_confirm",
|
|
1087
|
+
identifiers,
|
|
1088
|
+
diagnostics,
|
|
1089
|
+
after_index=step_index,
|
|
1090
|
+
)
|
|
1091
|
+
for correctable_index, correctable_reference in enumerate(preview_step.get("correctable", [])):
|
|
1092
|
+
_check_source_reference(
|
|
1093
|
+
cast(dict[str, str], correctable_reference),
|
|
1094
|
+
f"{step_path}/correctable/{correctable_index}",
|
|
1095
|
+
step_index,
|
|
1096
|
+
slot_positions,
|
|
1097
|
+
derive_positions,
|
|
1098
|
+
has_profile_owned_slots,
|
|
1099
|
+
diagnostics,
|
|
1100
|
+
slots_only=True,
|
|
1101
|
+
)
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def _check_await_semantics(
|
|
1105
|
+
preview_step: dict[str, Any],
|
|
1106
|
+
step_index: int,
|
|
1107
|
+
identifiers: dict[str, tuple[int, str]],
|
|
1108
|
+
diagnostics: list[FlowDiagnostic],
|
|
1109
|
+
) -> None:
|
|
1110
|
+
step_path = _json_pointer("steps", step_index)
|
|
1111
|
+
if ui := preview_step.get("ui"):
|
|
1112
|
+
await_ui = cast(dict[str, Any], ui)
|
|
1113
|
+
if await_ui["field"] != preview_step["resume_on"]:
|
|
1114
|
+
diagnostics.append(
|
|
1115
|
+
_v3_diagnostic(
|
|
1116
|
+
"FLOWSPEC3_AWAIT_FIELD_MISMATCH",
|
|
1117
|
+
f"{step_path}/ui/field",
|
|
1118
|
+
"CTA field must equal the await resume_on payload key",
|
|
1119
|
+
)
|
|
1120
|
+
)
|
|
1121
|
+
if await_ui["next_step"]["$step"] != preview_step["id"]:
|
|
1122
|
+
diagnostics.append(
|
|
1123
|
+
_v3_diagnostic(
|
|
1124
|
+
"FLOWSPEC3_AWAIT_NEXT_STEP_MISMATCH",
|
|
1125
|
+
f"{step_path}/ui/next_step",
|
|
1126
|
+
"CTA next_step must target its enclosing await step",
|
|
1127
|
+
)
|
|
1128
|
+
)
|
|
1129
|
+
|
|
1130
|
+
transition_locations: list[tuple[str, dict[str, Any]]] = []
|
|
1131
|
+
if timeout_transition := preview_step.get("timeout"):
|
|
1132
|
+
transition_locations.append((f"{step_path}/timeout", timeout_transition))
|
|
1133
|
+
transition_locations.extend(
|
|
1134
|
+
(f"{step_path}/recovery/{event_name}", transition)
|
|
1135
|
+
for event_name, transition in preview_step.get("recovery", {}).items()
|
|
1136
|
+
)
|
|
1137
|
+
for transition_path, transition in transition_locations:
|
|
1138
|
+
if isinstance(transition["goto"], dict):
|
|
1139
|
+
_check_step_reference(
|
|
1140
|
+
cast(dict[str, str], transition["goto"]),
|
|
1141
|
+
f"{transition_path}/goto",
|
|
1142
|
+
identifiers,
|
|
1143
|
+
diagnostics,
|
|
1144
|
+
)
|
|
1145
|
+
resend_transition = cast(
|
|
1146
|
+
dict[str, Any] | None,
|
|
1147
|
+
cast(dict[str, Any], preview_step.get("recovery", {})).get("resend"),
|
|
1148
|
+
)
|
|
1149
|
+
if resend_transition is not None and resend_transition["goto"] != {"$step": preview_step["id"]}:
|
|
1150
|
+
diagnostics.append(
|
|
1151
|
+
_v3_diagnostic(
|
|
1152
|
+
"FLOWSPEC3_AWAIT_RESEND_TARGET_MISMATCH",
|
|
1153
|
+
f"{step_path}/recovery/resend/goto",
|
|
1154
|
+
"resend must target its enclosing await step",
|
|
1155
|
+
)
|
|
1156
|
+
)
|
|
1157
|
+
|
|
1158
|
+
on_resume = cast(dict[str, Any], preview_step.get("on_resume", {}))
|
|
1159
|
+
direct_writes = set(cast(dict[str, Any], on_resume.get("set", {})))
|
|
1160
|
+
enrichment = cast(dict[str, Any], on_resume.get("enrich", {}))
|
|
1161
|
+
enrichment_writes = set(cast(dict[str, Any], enrichment.get("set", {})))
|
|
1162
|
+
for colliding_key in sorted(direct_writes & enrichment_writes):
|
|
1163
|
+
diagnostics.append(
|
|
1164
|
+
_v3_diagnostic(
|
|
1165
|
+
"FLOWSPEC3_AWAIT_WRITE_COLLISION",
|
|
1166
|
+
f"{step_path}/on_resume/enrich/set/{_pointer_segment(colliding_key)}",
|
|
1167
|
+
f"state key {colliding_key!r} is also written by on_resume.set",
|
|
1168
|
+
)
|
|
1169
|
+
)
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def _check_ui_semantics(
|
|
1173
|
+
ui: dict[str, Any],
|
|
1174
|
+
domain: dict[str, Any] | None,
|
|
1175
|
+
step_index: int,
|
|
1176
|
+
slot_positions: dict[str, int],
|
|
1177
|
+
derive_positions: dict[str, int],
|
|
1178
|
+
has_profile_owned_slots: bool,
|
|
1179
|
+
diagnostics: list[FlowDiagnostic],
|
|
1180
|
+
) -> None:
|
|
1181
|
+
step_path = _json_pointer("steps", step_index)
|
|
1182
|
+
if ui["kind"] in {"buttons", "list"} and (
|
|
1183
|
+
domain is None or domain["type"] not in {"categorical", "bool"}
|
|
1184
|
+
):
|
|
1185
|
+
diagnostics.append(
|
|
1186
|
+
_v3_diagnostic(
|
|
1187
|
+
"FLOWSPEC3_CHOICE_UI_REQUIRES_OPTIONS",
|
|
1188
|
+
f"{step_path}/ui/kind",
|
|
1189
|
+
"buttons and list UI require a categorical or boolean inline domain",
|
|
1190
|
+
)
|
|
1191
|
+
)
|
|
1192
|
+
if (
|
|
1193
|
+
ui["kind"] in {"buttons", "list"}
|
|
1194
|
+
and domain is not None
|
|
1195
|
+
and domain["type"] in {"categorical", "bool"}
|
|
1196
|
+
):
|
|
1197
|
+
allowed_values = [option["value"] for option in domain.get("options", [])]
|
|
1198
|
+
if not allowed_values:
|
|
1199
|
+
diagnostics.append(
|
|
1200
|
+
_v3_diagnostic(
|
|
1201
|
+
"FLOWSPEC3_CHOICE_UI_REQUIRES_OPTIONS",
|
|
1202
|
+
f"{step_path}/ui/kind",
|
|
1203
|
+
"buttons and list UI require renderable domain options",
|
|
1204
|
+
)
|
|
1205
|
+
)
|
|
1206
|
+
seen_values: list[Any] = []
|
|
1207
|
+
for condition_index, conditional_option in enumerate(ui.get("options_when", [])):
|
|
1208
|
+
condition_value = conditional_option["value"]
|
|
1209
|
+
condition_path = f"{step_path}/ui/options_when/{condition_index}/value"
|
|
1210
|
+
if any(_same_json_scalar(condition_value, seen_value) for seen_value in seen_values):
|
|
1211
|
+
diagnostics.append(
|
|
1212
|
+
_v3_diagnostic(
|
|
1213
|
+
"FLOWSPEC3_DUPLICATE_CONDITIONAL_OPTION",
|
|
1214
|
+
condition_path,
|
|
1215
|
+
"options_when repeats an option value",
|
|
1216
|
+
)
|
|
1217
|
+
)
|
|
1218
|
+
seen_values.append(condition_value)
|
|
1219
|
+
if not any(
|
|
1220
|
+
_same_json_scalar(condition_value, allowed_value)
|
|
1221
|
+
for allowed_value in allowed_values
|
|
1222
|
+
):
|
|
1223
|
+
diagnostics.append(
|
|
1224
|
+
_v3_diagnostic(
|
|
1225
|
+
"FLOWSPEC3_UNKNOWN_CONDITIONAL_OPTION",
|
|
1226
|
+
condition_path,
|
|
1227
|
+
"options_when value is not rendered by the inline domain",
|
|
1228
|
+
)
|
|
1229
|
+
)
|
|
1230
|
+
_check_predicate_references(
|
|
1231
|
+
cast(dict[str, Any], conditional_option["gate"]),
|
|
1232
|
+
f"{step_path}/ui/options_when/{condition_index}/gate",
|
|
1233
|
+
step_index,
|
|
1234
|
+
slot_positions,
|
|
1235
|
+
derive_positions,
|
|
1236
|
+
has_profile_owned_slots,
|
|
1237
|
+
diagnostics,
|
|
1238
|
+
)
|
|
1239
|
+
if ui["kind"] == "flow":
|
|
1240
|
+
for prefill_index, prefill_reference in enumerate(ui.get("prefill_from", [])):
|
|
1241
|
+
_check_source_reference(
|
|
1242
|
+
cast(dict[str, str], prefill_reference),
|
|
1243
|
+
f"{step_path}/ui/prefill_from/{prefill_index}",
|
|
1244
|
+
step_index,
|
|
1245
|
+
slot_positions,
|
|
1246
|
+
derive_positions,
|
|
1247
|
+
has_profile_owned_slots,
|
|
1248
|
+
diagnostics,
|
|
1249
|
+
slots_only=True,
|
|
1250
|
+
)
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
def _check_predicate_references(
|
|
1254
|
+
predicate: dict[str, Any],
|
|
1255
|
+
predicate_path: str,
|
|
1256
|
+
consumer_index: int,
|
|
1257
|
+
slot_positions: dict[str, int],
|
|
1258
|
+
derive_positions: dict[str, int],
|
|
1259
|
+
has_profile_owned_slots: bool,
|
|
1260
|
+
diagnostics: list[FlowDiagnostic],
|
|
1261
|
+
) -> None:
|
|
1262
|
+
operator, operand = next(iter(predicate.items()))
|
|
1263
|
+
if operator in {"and", "or"}:
|
|
1264
|
+
for child_index, child_predicate in enumerate(operand):
|
|
1265
|
+
_check_predicate_references(
|
|
1266
|
+
cast(dict[str, Any], child_predicate),
|
|
1267
|
+
f"{predicate_path}/{operator}/{child_index}",
|
|
1268
|
+
consumer_index,
|
|
1269
|
+
slot_positions,
|
|
1270
|
+
derive_positions,
|
|
1271
|
+
has_profile_owned_slots,
|
|
1272
|
+
diagnostics,
|
|
1273
|
+
)
|
|
1274
|
+
return
|
|
1275
|
+
if operator == "not":
|
|
1276
|
+
_check_predicate_references(
|
|
1277
|
+
cast(dict[str, Any], operand),
|
|
1278
|
+
f"{predicate_path}/not",
|
|
1279
|
+
consumer_index,
|
|
1280
|
+
slot_positions,
|
|
1281
|
+
derive_positions,
|
|
1282
|
+
has_profile_owned_slots,
|
|
1283
|
+
diagnostics,
|
|
1284
|
+
)
|
|
1285
|
+
return
|
|
1286
|
+
operands = [operand] if operator == "is_present" else cast(list[Any], operand)
|
|
1287
|
+
for operand_index, candidate_operand in enumerate(operands):
|
|
1288
|
+
if isinstance(candidate_operand, dict) and set(candidate_operand) & {"$slot", "$derive"}:
|
|
1289
|
+
_check_source_reference(
|
|
1290
|
+
cast(dict[str, str], candidate_operand),
|
|
1291
|
+
f"{predicate_path}/{operator}/{operand_index}",
|
|
1292
|
+
consumer_index,
|
|
1293
|
+
slot_positions,
|
|
1294
|
+
derive_positions,
|
|
1295
|
+
has_profile_owned_slots,
|
|
1296
|
+
diagnostics,
|
|
1297
|
+
)
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def _check_step_reference(
|
|
1301
|
+
step_reference: dict[str, str],
|
|
1302
|
+
reference_path: str,
|
|
1303
|
+
identifiers: dict[str, tuple[int, str]],
|
|
1304
|
+
diagnostics: list[FlowDiagnostic],
|
|
1305
|
+
*,
|
|
1306
|
+
after_index: int | None = None,
|
|
1307
|
+
) -> None:
|
|
1308
|
+
target_identifier = step_reference["$step"]
|
|
1309
|
+
target = identifiers.get(target_identifier)
|
|
1310
|
+
if target is None:
|
|
1311
|
+
diagnostics.append(
|
|
1312
|
+
_v3_diagnostic(
|
|
1313
|
+
"FLOWSPEC3_UNKNOWN_STEP_REFERENCE",
|
|
1314
|
+
reference_path,
|
|
1315
|
+
f"step {target_identifier!r} is not declared",
|
|
1316
|
+
)
|
|
1317
|
+
)
|
|
1318
|
+
elif after_index is not None and target[0] <= after_index:
|
|
1319
|
+
diagnostics.append(
|
|
1320
|
+
_v3_diagnostic(
|
|
1321
|
+
"FLOWSPEC3_NON_FORWARD_CONFIRM_TARGET",
|
|
1322
|
+
reference_path,
|
|
1323
|
+
"confirmation target must be a later step",
|
|
1324
|
+
)
|
|
1325
|
+
)
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def _pointer_segment(path_segment: str) -> str:
|
|
1329
|
+
return path_segment.replace("~", "~0").replace("/", "~1")
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
def compact_json_bytes(flow_document: Mapping[str, Any]) -> int:
|
|
1333
|
+
"""Measure canonical compact JSON in UTF-8 bytes."""
|
|
1334
|
+
return len(_canonical_json(flow_document).encode("utf-8"))
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def migrate_v2_to_v3_preview(source_document: Mapping[str, Any]) -> dict[str, Any]:
|
|
1338
|
+
"""Purely migrate a valid flowspec/2 source into the non-stable preview."""
|
|
1339
|
+
return migrate_v2_to_v3_preview_report(source_document).preview_document
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
def migrate_v2_to_v3_preview_report(
|
|
1343
|
+
source_document: Mapping[str, Any],
|
|
1344
|
+
) -> V3PreviewMigrationReport:
|
|
1345
|
+
"""Migrate and report classified source losses plus neutral byte measurements."""
|
|
1346
|
+
private_source = _canonical_clone(source_document)
|
|
1347
|
+
if not isinstance(private_source, dict):
|
|
1348
|
+
raise V3PreviewMigrationError("flowspec/2 source must be a JSON object")
|
|
1349
|
+
if private_source.get("schema") != V2_SCHEMA_IDENTIFIER:
|
|
1350
|
+
raise V3PreviewMigrationError(
|
|
1351
|
+
f"source schema must be {V2_SCHEMA_IDENTIFIER!r}, got {private_source.get('schema')!r}"
|
|
1352
|
+
)
|
|
1353
|
+
_reject_ephemeral_slot_persistence(private_source)
|
|
1354
|
+
validation_source = _without_legacy_interactive_gates(private_source)
|
|
1355
|
+
validate_flow(validation_source)
|
|
1356
|
+
if source_diagnostics := semantic_diagnostics(validation_source):
|
|
1357
|
+
first_diagnostic = source_diagnostics[0]
|
|
1358
|
+
raise V3PreviewMigrationError(
|
|
1359
|
+
"flowspec/2 source is not semantically linked: "
|
|
1360
|
+
f"{first_diagnostic.code} at {first_diagnostic.path or '/'}: "
|
|
1361
|
+
f"{first_diagnostic.message}"
|
|
1362
|
+
)
|
|
1363
|
+
|
|
1364
|
+
migration_context = _new_migration_context(private_source)
|
|
1365
|
+
preview_document = _migrate_document(migration_context)
|
|
1366
|
+
validate_v3_preview(preview_document, mode=V3PreviewDocumentMode.V2_MIGRATION)
|
|
1367
|
+
canonical_preview = _canonical_json(preview_document)
|
|
1368
|
+
compact_bytes = CompactByteComparison(
|
|
1369
|
+
source_bytes=compact_json_bytes(private_source),
|
|
1370
|
+
preview_bytes=len(canonical_preview.encode("utf-8")),
|
|
1371
|
+
)
|
|
1372
|
+
return V3PreviewMigrationReport(
|
|
1373
|
+
_preview_json=canonical_preview,
|
|
1374
|
+
loss_entries=tuple(
|
|
1375
|
+
sorted(
|
|
1376
|
+
migration_context.loss_entries_by_path.values(),
|
|
1377
|
+
key=lambda loss_entry: loss_entry.source_path,
|
|
1378
|
+
)
|
|
1379
|
+
),
|
|
1380
|
+
compact_bytes=compact_bytes,
|
|
1381
|
+
)
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def _reject_ephemeral_slot_persistence(source_document: dict[str, Any]) -> None:
|
|
1385
|
+
slots = source_document.get("slots")
|
|
1386
|
+
if not isinstance(slots, dict):
|
|
1387
|
+
return
|
|
1388
|
+
for slot_name, slot_definition in slots.items():
|
|
1389
|
+
if isinstance(slot_definition, dict) and slot_definition.get("persist") == "payload":
|
|
1390
|
+
raise V3PreviewMigrationError(
|
|
1391
|
+
f"{_json_pointer('slots', slot_name, 'persist')} uses ephemeral payload "
|
|
1392
|
+
"persistence, which cannot survive a conversational pause"
|
|
1393
|
+
)
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
def _without_legacy_interactive_gates(
|
|
1397
|
+
source_document: dict[str, Any],
|
|
1398
|
+
) -> dict[str, Any]:
|
|
1399
|
+
cleaned_document = cast(dict[str, Any], _canonical_clone(source_document))
|
|
1400
|
+
interactive_definitions: list[Any] = [
|
|
1401
|
+
path_step.get("interactive")
|
|
1402
|
+
for path_step in cast(list[dict[str, Any]], cleaned_document.get("path", []))
|
|
1403
|
+
]
|
|
1404
|
+
interactive_definitions.extend(
|
|
1405
|
+
[
|
|
1406
|
+
cast(dict[str, Any], cleaned_document.get("confirm", {})).get("interactive"),
|
|
1407
|
+
cast(dict[str, Any], cleaned_document.get("auto_flow", {})).get("interactive"),
|
|
1408
|
+
cast(
|
|
1409
|
+
dict[str, Any],
|
|
1410
|
+
cast(dict[str, Any], cleaned_document.get("capabilities", {})).get(
|
|
1411
|
+
"await_external", {}
|
|
1412
|
+
),
|
|
1413
|
+
).get("interactive"),
|
|
1414
|
+
]
|
|
1415
|
+
)
|
|
1416
|
+
for interactive_definition in interactive_definitions:
|
|
1417
|
+
if isinstance(interactive_definition, dict):
|
|
1418
|
+
interactive_definition.pop("gate", None)
|
|
1419
|
+
return cleaned_document
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
def _new_migration_context(source_document: dict[str, Any]) -> _MigrationContext:
|
|
1423
|
+
capabilities = cast(dict[str, Any], source_document.get("capabilities") or {})
|
|
1424
|
+
overrides = cast(dict[str, Any], source_document.get("overrides") or {})
|
|
1425
|
+
return _MigrationContext(
|
|
1426
|
+
source_document=source_document,
|
|
1427
|
+
domains=cast(dict[str, dict[str, Any]], source_document.get("domains") or {}),
|
|
1428
|
+
slots=cast(dict[str, dict[str, Any]], source_document.get("slots") or {}),
|
|
1429
|
+
use_declarations=cast(list[dict[str, Any]], source_document.get("uses") or []),
|
|
1430
|
+
derive_definitions=cast(list[dict[str, Any]], source_document.get("derive") or []),
|
|
1431
|
+
confirmation_definition=cast(dict[str, Any] | None, source_document.get("confirm")),
|
|
1432
|
+
terminal_definition=cast(dict[str, Any] | None, source_document.get("terminal")),
|
|
1433
|
+
override_gates=cast(dict[str, dict[str, Any]], overrides.get("gates") or {}),
|
|
1434
|
+
await_capability=cast(dict[str, Any] | None, capabilities.get("await_external")),
|
|
1435
|
+
)
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
def _migrate_document(migration_context: _MigrationContext) -> dict[str, Any]:
|
|
1439
|
+
source_document = migration_context.source_document
|
|
1440
|
+
preview_document: dict[str, Any] = {
|
|
1441
|
+
"schema": V3_PREVIEW_SCHEMA_IDENTIFIER,
|
|
1442
|
+
"flow": source_document["flow"],
|
|
1443
|
+
"version": source_document["version"],
|
|
1444
|
+
"route": _canonical_clone(source_document["route"]),
|
|
1445
|
+
}
|
|
1446
|
+
for native_top_level_key in ("service", "config"):
|
|
1447
|
+
if native_top_level_key in source_document:
|
|
1448
|
+
preview_document[native_top_level_key] = _canonical_clone(
|
|
1449
|
+
source_document[native_top_level_key]
|
|
1450
|
+
)
|
|
1451
|
+
|
|
1452
|
+
preview_steps = [
|
|
1453
|
+
_migrate_path_step(path_step, path_index, migration_context)
|
|
1454
|
+
for path_index, path_step in enumerate(cast(list[dict[str, Any]], source_document["path"]))
|
|
1455
|
+
]
|
|
1456
|
+
_insert_unanchored_derives(preview_steps, migration_context)
|
|
1457
|
+
preview_document["steps"] = preview_steps
|
|
1458
|
+
_collect_loss_entries(migration_context)
|
|
1459
|
+
if migration_context.loss_entries_by_path:
|
|
1460
|
+
ordered_loss_entries = sorted(
|
|
1461
|
+
migration_context.loss_entries_by_path.values(),
|
|
1462
|
+
key=lambda loss_entry: loss_entry.source_path,
|
|
1463
|
+
)
|
|
1464
|
+
preview_document["v2_passthrough"] = {
|
|
1465
|
+
"contract": V3_PREVIEW_LOSS_POLICY_CONTRACT,
|
|
1466
|
+
"entries": [loss_entry.as_dict() for loss_entry in ordered_loss_entries],
|
|
1467
|
+
}
|
|
1468
|
+
return cast(dict[str, Any], _canonical_clone(preview_document))
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
def _migrate_path_step(
|
|
1472
|
+
path_step: dict[str, Any],
|
|
1473
|
+
path_index: int,
|
|
1474
|
+
migration_context: _MigrationContext,
|
|
1475
|
+
) -> dict[str, Any]:
|
|
1476
|
+
if "slot" in path_step:
|
|
1477
|
+
return _migrate_collect_step(path_step, path_index, migration_context)
|
|
1478
|
+
if "confirm" in path_step:
|
|
1479
|
+
return _migrate_confirm_step(path_step, path_index, migration_context)
|
|
1480
|
+
if "use" in path_step:
|
|
1481
|
+
return _migrate_use_step(path_step, migration_context)
|
|
1482
|
+
if "derive" in path_step:
|
|
1483
|
+
return _migrate_explicit_derive_step(path_step, path_index, migration_context)
|
|
1484
|
+
if "terminal" in path_step:
|
|
1485
|
+
return _migrate_submit_step(path_index, migration_context)
|
|
1486
|
+
if "await_external" in path_step:
|
|
1487
|
+
return _migrate_await_step(path_step, path_index, migration_context)
|
|
1488
|
+
raise V3PreviewMigrationError(f"/path/{path_index} has no supported step kind")
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
def _migrate_collect_step(
|
|
1492
|
+
path_step: dict[str, Any],
|
|
1493
|
+
path_index: int,
|
|
1494
|
+
migration_context: _MigrationContext,
|
|
1495
|
+
) -> dict[str, Any]:
|
|
1496
|
+
slot_name = cast(str, path_step["slot"])
|
|
1497
|
+
preview_step = _migrate_slot_step(
|
|
1498
|
+
kind="collect",
|
|
1499
|
+
slot_name=slot_name,
|
|
1500
|
+
path_step=path_step,
|
|
1501
|
+
path_index=path_index,
|
|
1502
|
+
migration_context=migration_context,
|
|
1503
|
+
)
|
|
1504
|
+
_migrate_path_presentation(path_step, path_index, preview_step, slot_name, migration_context)
|
|
1505
|
+
_apply_step_gate(path_step, slot_name, preview_step, migration_context)
|
|
1506
|
+
return preview_step
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
def _migrate_confirm_step(
|
|
1510
|
+
path_step: dict[str, Any],
|
|
1511
|
+
path_index: int,
|
|
1512
|
+
migration_context: _MigrationContext,
|
|
1513
|
+
) -> dict[str, Any]:
|
|
1514
|
+
slot_name = cast(str, path_step["confirm"])
|
|
1515
|
+
preview_step = _migrate_slot_step(
|
|
1516
|
+
kind="confirm",
|
|
1517
|
+
slot_name=slot_name,
|
|
1518
|
+
path_step=path_step,
|
|
1519
|
+
path_index=path_index,
|
|
1520
|
+
migration_context=migration_context,
|
|
1521
|
+
)
|
|
1522
|
+
_migrate_path_presentation(path_step, path_index, preview_step, slot_name, migration_context)
|
|
1523
|
+
_apply_step_gate(path_step, slot_name, preview_step, migration_context)
|
|
1524
|
+
if "on_reject" in path_step:
|
|
1525
|
+
preview_step["on_reject"] = _canonical_clone(path_step["on_reject"])
|
|
1526
|
+
if "correctable" in path_step:
|
|
1527
|
+
preview_step["correction_hub"] = path_step["correctable"]
|
|
1528
|
+
if path_step.get("correctable") is True:
|
|
1529
|
+
_merge_confirmation_definition(path_step, path_index, preview_step, migration_context)
|
|
1530
|
+
return preview_step
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
def _migrate_slot_step(
|
|
1534
|
+
*,
|
|
1535
|
+
kind: str,
|
|
1536
|
+
slot_name: str,
|
|
1537
|
+
path_step: dict[str, Any],
|
|
1538
|
+
path_index: int,
|
|
1539
|
+
migration_context: _MigrationContext,
|
|
1540
|
+
) -> dict[str, Any]:
|
|
1541
|
+
slot_definition = migration_context.slots.get(slot_name)
|
|
1542
|
+
if slot_definition is None:
|
|
1543
|
+
raise V3PreviewMigrationError(
|
|
1544
|
+
f"/path/{path_index}/{kind} references undeclared slot {slot_name!r}"
|
|
1545
|
+
)
|
|
1546
|
+
domain_name = cast(str, slot_definition["domain"])
|
|
1547
|
+
preview_step: dict[str, Any] = {
|
|
1548
|
+
kind: slot_name,
|
|
1549
|
+
"domain": _migrate_domain(domain_name, migration_context),
|
|
1550
|
+
}
|
|
1551
|
+
migration_context.placed_slot_names.add(slot_name)
|
|
1552
|
+
if "step" in path_step:
|
|
1553
|
+
preview_step["id"] = path_step["step"]
|
|
1554
|
+
for slot_contract_key in _SLOT_CONTRACT_KEYS:
|
|
1555
|
+
if slot_contract_key in slot_definition:
|
|
1556
|
+
preview_step[slot_contract_key] = _canonical_clone(slot_definition[slot_contract_key])
|
|
1557
|
+
if "requires" in slot_definition:
|
|
1558
|
+
preview_step["requires"] = [
|
|
1559
|
+
_slot_reference(required_slot_name)
|
|
1560
|
+
for required_slot_name in cast(list[str], slot_definition["requires"])
|
|
1561
|
+
]
|
|
1562
|
+
return preview_step
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
def _migrate_domain(domain_name: str, migration_context: _MigrationContext) -> dict[str, Any]:
|
|
1566
|
+
domain_definition = migration_context.domains.get(domain_name)
|
|
1567
|
+
if domain_definition is None:
|
|
1568
|
+
raise V3PreviewMigrationError(f"slot references undeclared domain {domain_name!r}")
|
|
1569
|
+
migration_context.used_domain_names.add(domain_name)
|
|
1570
|
+
domain_type = cast(str, domain_definition.get("type", "categorical"))
|
|
1571
|
+
preview_domain: dict[str, Any] = {"type": domain_type}
|
|
1572
|
+
normalization = cast(dict[str, Any], domain_definition.get("normalize") or {})
|
|
1573
|
+
native_normalization = {
|
|
1574
|
+
normalization_key: normalization[normalization_key]
|
|
1575
|
+
for normalization_key in (
|
|
1576
|
+
"accent_fold",
|
|
1577
|
+
"number_words",
|
|
1578
|
+
"affirmation",
|
|
1579
|
+
"emoji_veto",
|
|
1580
|
+
)
|
|
1581
|
+
if normalization_key in normalization
|
|
1582
|
+
}
|
|
1583
|
+
if native_normalization:
|
|
1584
|
+
preview_domain["normalize"] = native_normalization
|
|
1585
|
+
if "optional" in domain_definition:
|
|
1586
|
+
preview_domain["optional"] = domain_definition["optional"]
|
|
1587
|
+
for numeric_constraint in ("minimum", "maximum"):
|
|
1588
|
+
if numeric_constraint in domain_definition:
|
|
1589
|
+
preview_domain[numeric_constraint] = domain_definition[numeric_constraint]
|
|
1590
|
+
if domain_type in {"categorical", "bool"}:
|
|
1591
|
+
migrated_options = _migrate_options(
|
|
1592
|
+
domain_name, domain_definition, normalization, migration_context
|
|
1593
|
+
)
|
|
1594
|
+
if domain_type == "categorical":
|
|
1595
|
+
if normalization.get("number_words") is True and any(
|
|
1596
|
+
option["value"] is None for option in migrated_options
|
|
1597
|
+
):
|
|
1598
|
+
_materialize_nullable_number_aliases(migrated_options)
|
|
1599
|
+
cast(dict[str, Any], preview_domain.get("normalize", {})).pop("number_words", None)
|
|
1600
|
+
if not preview_domain.get("normalize"):
|
|
1601
|
+
preview_domain.pop("normalize", None)
|
|
1602
|
+
null_option = next(
|
|
1603
|
+
(option for option in migrated_options if option["value"] is None), None
|
|
1604
|
+
)
|
|
1605
|
+
preview_domain["options"] = [
|
|
1606
|
+
option for option in migrated_options if option["value"] is not None
|
|
1607
|
+
]
|
|
1608
|
+
if null_option is not None:
|
|
1609
|
+
preview_domain["accepts_null"] = True
|
|
1610
|
+
if null_aliases := null_option.get("aliases"):
|
|
1611
|
+
preview_domain["null_aliases"] = null_aliases
|
|
1612
|
+
else:
|
|
1613
|
+
preview_domain["options"] = migrated_options
|
|
1614
|
+
if domain_type == "bool":
|
|
1615
|
+
for ignored_domain_key in ("values", "rows"):
|
|
1616
|
+
if ignored_domain_key in domain_definition:
|
|
1617
|
+
_record_loss(
|
|
1618
|
+
migration_context,
|
|
1619
|
+
V3PreviewLossCategory.INVALID_BOOLEAN_DOMAIN_FIELD,
|
|
1620
|
+
_json_pointer("domains", domain_name, ignored_domain_key),
|
|
1621
|
+
domain_definition[ignored_domain_key],
|
|
1622
|
+
)
|
|
1623
|
+
if domain_type not in {"categorical", "bool"}:
|
|
1624
|
+
for ignored_domain_key in ("values", "rows"):
|
|
1625
|
+
if ignored_domain_key in domain_definition:
|
|
1626
|
+
_record_loss(
|
|
1627
|
+
migration_context,
|
|
1628
|
+
V3PreviewLossCategory.INVALID_NON_CHOICE_DOMAIN_FIELD,
|
|
1629
|
+
_json_pointer("domains", domain_name, ignored_domain_key),
|
|
1630
|
+
domain_definition[ignored_domain_key],
|
|
1631
|
+
)
|
|
1632
|
+
if "synonyms" in normalization:
|
|
1633
|
+
_record_loss(
|
|
1634
|
+
migration_context,
|
|
1635
|
+
V3PreviewLossCategory.INVALID_NON_CHOICE_SYNONYMS,
|
|
1636
|
+
_json_pointer("domains", domain_name, "normalize", "synonyms"),
|
|
1637
|
+
normalization["synonyms"],
|
|
1638
|
+
)
|
|
1639
|
+
return preview_domain
|
|
1640
|
+
|
|
1641
|
+
|
|
1642
|
+
def _materialize_nullable_number_aliases(migrated_options: list[dict[str, Any]]) -> None:
|
|
1643
|
+
option_values = [option["value"] for option in migrated_options]
|
|
1644
|
+
for number_input, target_value in categorical_number_bindings(option_values).items():
|
|
1645
|
+
target_option = next(
|
|
1646
|
+
option
|
|
1647
|
+
for option in migrated_options
|
|
1648
|
+
if _same_json_scalar(option["value"], target_value)
|
|
1649
|
+
)
|
|
1650
|
+
target_option["aliases"] = sorted(
|
|
1651
|
+
{*cast(list[str], target_option.get("aliases", [])), number_input}
|
|
1652
|
+
)
|
|
1653
|
+
|
|
1654
|
+
|
|
1655
|
+
def _migrate_options(
|
|
1656
|
+
domain_name: str,
|
|
1657
|
+
domain_definition: dict[str, Any],
|
|
1658
|
+
normalization: dict[str, Any],
|
|
1659
|
+
migration_context: _MigrationContext,
|
|
1660
|
+
) -> list[dict[str, Any]]:
|
|
1661
|
+
domain_type = cast(str, domain_definition.get("type", "categorical"))
|
|
1662
|
+
option_values: list[Any] = (
|
|
1663
|
+
[True, False]
|
|
1664
|
+
if domain_type == "bool"
|
|
1665
|
+
else cast(list[Any], domain_definition.get("values") or [])
|
|
1666
|
+
)
|
|
1667
|
+
aliases_by_option = _aliases_by_option(
|
|
1668
|
+
domain_name, option_values, normalization, migration_context
|
|
1669
|
+
)
|
|
1670
|
+
domain_rows = cast(list[dict[str, Any]], domain_definition.get("rows") or [])
|
|
1671
|
+
preview_options: list[dict[str, Any]] = []
|
|
1672
|
+
for option_value in option_values:
|
|
1673
|
+
option_label = (
|
|
1674
|
+
"Yes"
|
|
1675
|
+
if option_value is True
|
|
1676
|
+
else "No"
|
|
1677
|
+
if option_value is False
|
|
1678
|
+
else ""
|
|
1679
|
+
if option_value is None
|
|
1680
|
+
else str(option_value)
|
|
1681
|
+
)
|
|
1682
|
+
preview_option: dict[str, Any] = {"value": option_value, "label": option_label}
|
|
1683
|
+
option_aliases = aliases_by_option.get(_json_identity(option_value), [])
|
|
1684
|
+
if option_aliases:
|
|
1685
|
+
preview_option["aliases"] = sorted(option_aliases)
|
|
1686
|
+
matching_rows = [
|
|
1687
|
+
(row_index, domain_row)
|
|
1688
|
+
for row_index, domain_row in enumerate(domain_rows)
|
|
1689
|
+
if _same_json_scalar(domain_row["value"], option_value)
|
|
1690
|
+
]
|
|
1691
|
+
if matching_rows:
|
|
1692
|
+
selected_domain_row = matching_rows[-1][1]
|
|
1693
|
+
if "description" in selected_domain_row:
|
|
1694
|
+
preview_option["description"] = selected_domain_row["description"]
|
|
1695
|
+
for overridden_row_index, domain_row in matching_rows[:-1]:
|
|
1696
|
+
_record_loss(
|
|
1697
|
+
migration_context,
|
|
1698
|
+
V3PreviewLossCategory.DUPLICATE_DOMAIN_ROW,
|
|
1699
|
+
_json_pointer("domains", domain_name, "rows", overridden_row_index),
|
|
1700
|
+
domain_row,
|
|
1701
|
+
)
|
|
1702
|
+
preview_options.append(preview_option)
|
|
1703
|
+
|
|
1704
|
+
for row_index, domain_row in enumerate(domain_rows):
|
|
1705
|
+
if not any(
|
|
1706
|
+
_same_json_scalar(domain_row["value"], option_value) for option_value in option_values
|
|
1707
|
+
):
|
|
1708
|
+
_record_loss(
|
|
1709
|
+
migration_context,
|
|
1710
|
+
V3PreviewLossCategory.ORPHAN_DOMAIN_ROW,
|
|
1711
|
+
_json_pointer("domains", domain_name, "rows", row_index),
|
|
1712
|
+
domain_row,
|
|
1713
|
+
)
|
|
1714
|
+
return preview_options
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
def _aliases_by_option(
|
|
1718
|
+
domain_name: str,
|
|
1719
|
+
option_values: list[Any],
|
|
1720
|
+
normalization: dict[str, Any],
|
|
1721
|
+
migration_context: _MigrationContext,
|
|
1722
|
+
) -> dict[str, list[str]]:
|
|
1723
|
+
aliases_by_option: dict[str, list[str]] = {}
|
|
1724
|
+
synonyms = cast(dict[str, Any], normalization.get("synonyms") or {})
|
|
1725
|
+
for option_alias, synonym_target in synonyms.items():
|
|
1726
|
+
matched_option = next(
|
|
1727
|
+
(
|
|
1728
|
+
option_value
|
|
1729
|
+
for option_value in option_values
|
|
1730
|
+
if _same_json_scalar(option_value, synonym_target)
|
|
1731
|
+
),
|
|
1732
|
+
None,
|
|
1733
|
+
)
|
|
1734
|
+
matched_null = synonym_target is None and any(
|
|
1735
|
+
option_value is None for option_value in option_values
|
|
1736
|
+
)
|
|
1737
|
+
if matched_option is None and not matched_null:
|
|
1738
|
+
_record_loss(
|
|
1739
|
+
migration_context,
|
|
1740
|
+
V3PreviewLossCategory.INVALID_SYNONYM_TARGET,
|
|
1741
|
+
_json_pointer("domains", domain_name, "normalize", "synonyms", option_alias),
|
|
1742
|
+
synonym_target,
|
|
1743
|
+
)
|
|
1744
|
+
continue
|
|
1745
|
+
aliases_by_option.setdefault(_json_identity(matched_option), []).append(option_alias)
|
|
1746
|
+
return aliases_by_option
|
|
1747
|
+
|
|
1748
|
+
|
|
1749
|
+
def _migrate_path_presentation(
|
|
1750
|
+
path_step: dict[str, Any],
|
|
1751
|
+
path_index: int,
|
|
1752
|
+
preview_step: dict[str, Any],
|
|
1753
|
+
slot_name: str,
|
|
1754
|
+
migration_context: _MigrationContext,
|
|
1755
|
+
) -> None:
|
|
1756
|
+
if "prompt" in path_step:
|
|
1757
|
+
preview_step["prompt"] = _canonical_clone(path_step["prompt"])
|
|
1758
|
+
if "interactive" in path_step:
|
|
1759
|
+
slot_definition = migration_context.slots[slot_name]
|
|
1760
|
+
preview_step["ui"] = _migrate_ui(
|
|
1761
|
+
cast(dict[str, Any], path_step["interactive"]),
|
|
1762
|
+
_json_pointer("path", path_index, "interactive"),
|
|
1763
|
+
cast(str, slot_definition["domain"]),
|
|
1764
|
+
migration_context,
|
|
1765
|
+
)
|
|
1766
|
+
for predicate_key in ("ask_when", "skip_when"):
|
|
1767
|
+
if predicate_key in path_step:
|
|
1768
|
+
preview_step[predicate_key] = _migrate_predicate(
|
|
1769
|
+
cast(dict[str, Any], path_step[predicate_key]), migration_context
|
|
1770
|
+
)
|
|
1771
|
+
|
|
1772
|
+
|
|
1773
|
+
def _apply_step_gate(
|
|
1774
|
+
path_step: dict[str, Any],
|
|
1775
|
+
default_step_identifier: str,
|
|
1776
|
+
preview_step: dict[str, Any],
|
|
1777
|
+
migration_context: _MigrationContext,
|
|
1778
|
+
) -> None:
|
|
1779
|
+
step_identifier = cast(str, path_step.get("step", default_step_identifier))
|
|
1780
|
+
gate_predicate = migration_context.override_gates.get(step_identifier)
|
|
1781
|
+
if gate_predicate is not None:
|
|
1782
|
+
preview_step["gate"] = _migrate_predicate(gate_predicate, migration_context)
|
|
1783
|
+
migration_context.consumed_override_gate_names.add(step_identifier)
|
|
1784
|
+
|
|
1785
|
+
|
|
1786
|
+
def _merge_confirmation_definition(
|
|
1787
|
+
path_step: dict[str, Any],
|
|
1788
|
+
path_index: int,
|
|
1789
|
+
preview_step: dict[str, Any],
|
|
1790
|
+
migration_context: _MigrationContext,
|
|
1791
|
+
) -> None:
|
|
1792
|
+
confirmation_definition = migration_context.confirmation_definition
|
|
1793
|
+
if confirmation_definition is None:
|
|
1794
|
+
raise V3PreviewMigrationError(
|
|
1795
|
+
f"/path/{path_index}/correctable requires the top-level /confirm definition"
|
|
1796
|
+
)
|
|
1797
|
+
path_step_identifier = cast(str, path_step.get("step", path_step["confirm"]))
|
|
1798
|
+
if confirmation_definition["step"] != path_step_identifier:
|
|
1799
|
+
raise V3PreviewMigrationError(
|
|
1800
|
+
f"/confirm/step does not match /path/{path_index}: "
|
|
1801
|
+
f"{confirmation_definition['step']!r} != {path_step_identifier!r}"
|
|
1802
|
+
)
|
|
1803
|
+
if confirmation_definition["slot"] != path_step["confirm"]:
|
|
1804
|
+
raise V3PreviewMigrationError(f"/confirm/slot does not match /path/{path_index}/confirm")
|
|
1805
|
+
for presentation_key, preview_key in (("prompt", "prompt"), ("interactive", "ui")):
|
|
1806
|
+
confirmation_presentation = confirmation_definition.get(presentation_key)
|
|
1807
|
+
path_presentation = path_step.get(presentation_key)
|
|
1808
|
+
if confirmation_presentation is None:
|
|
1809
|
+
continue
|
|
1810
|
+
if path_presentation is not None and path_presentation != confirmation_presentation:
|
|
1811
|
+
raise V3PreviewMigrationError(
|
|
1812
|
+
f"/confirm/{presentation_key} conflicts with /path/{path_index}/{presentation_key}"
|
|
1813
|
+
)
|
|
1814
|
+
if path_presentation is None:
|
|
1815
|
+
if presentation_key == "interactive":
|
|
1816
|
+
slot_definition = migration_context.slots[cast(str, path_step["confirm"])]
|
|
1817
|
+
preview_step[preview_key] = _migrate_ui(
|
|
1818
|
+
cast(dict[str, Any], confirmation_presentation),
|
|
1819
|
+
_json_pointer("confirm", "interactive"),
|
|
1820
|
+
cast(str, slot_definition["domain"]),
|
|
1821
|
+
migration_context,
|
|
1822
|
+
)
|
|
1823
|
+
else:
|
|
1824
|
+
preview_step[preview_key] = _canonical_clone(confirmation_presentation)
|
|
1825
|
+
preview_step["correctable"] = [
|
|
1826
|
+
_slot_reference(correctable_slot_name)
|
|
1827
|
+
for correctable_slot_name in cast(list[str], confirmation_definition["correctable"])
|
|
1828
|
+
]
|
|
1829
|
+
confirmation_target = confirmation_definition.get("on_confirm")
|
|
1830
|
+
if confirmation_target is None:
|
|
1831
|
+
terminal_definition = migration_context.terminal_definition
|
|
1832
|
+
if terminal_definition is None:
|
|
1833
|
+
raise V3PreviewMigrationError(
|
|
1834
|
+
f"/path/{path_index}/correctable has no explicit or terminal confirmation target"
|
|
1835
|
+
)
|
|
1836
|
+
confirmation_target = terminal_definition.get("step", "terminal")
|
|
1837
|
+
preview_step["on_confirm"] = _step_reference(cast(str, confirmation_target))
|
|
1838
|
+
migration_context.confirmation_consumed = True
|
|
1839
|
+
|
|
1840
|
+
|
|
1841
|
+
def _migrate_use_step(
|
|
1842
|
+
path_step: dict[str, Any], migration_context: _MigrationContext
|
|
1843
|
+
) -> dict[str, Any]:
|
|
1844
|
+
subflow_reference = cast(str, path_step["use"])
|
|
1845
|
+
preview_step: dict[str, Any] = {"use": subflow_reference}
|
|
1846
|
+
declaration_match = next(
|
|
1847
|
+
(
|
|
1848
|
+
(declaration_index, use_declaration)
|
|
1849
|
+
for declaration_index, use_declaration in enumerate(migration_context.use_declarations)
|
|
1850
|
+
if declaration_index not in migration_context.consumed_use_indexes
|
|
1851
|
+
and use_declaration["ref"] == subflow_reference
|
|
1852
|
+
),
|
|
1853
|
+
None,
|
|
1854
|
+
)
|
|
1855
|
+
if declaration_match is not None:
|
|
1856
|
+
declaration_index, use_declaration = declaration_match
|
|
1857
|
+
migration_context.consumed_use_indexes.add(declaration_index)
|
|
1858
|
+
if "with" in use_declaration:
|
|
1859
|
+
preview_step["with"] = _canonical_clone(use_declaration["with"])
|
|
1860
|
+
return preview_step
|
|
1861
|
+
|
|
1862
|
+
|
|
1863
|
+
def _migrate_explicit_derive_step(
|
|
1864
|
+
path_step: dict[str, Any],
|
|
1865
|
+
path_index: int,
|
|
1866
|
+
migration_context: _MigrationContext,
|
|
1867
|
+
) -> dict[str, Any]:
|
|
1868
|
+
derive_target = cast(str, path_step["derive"])
|
|
1869
|
+
definition_match = next(
|
|
1870
|
+
(
|
|
1871
|
+
(derive_index, derive_definition)
|
|
1872
|
+
for derive_index, derive_definition in enumerate(migration_context.derive_definitions)
|
|
1873
|
+
if derive_index not in migration_context.consumed_derive_indexes
|
|
1874
|
+
and derive_definition["writes"] == derive_target
|
|
1875
|
+
),
|
|
1876
|
+
None,
|
|
1877
|
+
)
|
|
1878
|
+
if definition_match is None:
|
|
1879
|
+
raise V3PreviewMigrationError(
|
|
1880
|
+
f"/path/{path_index}/derive references missing definition {derive_target!r}"
|
|
1881
|
+
)
|
|
1882
|
+
derive_index, derive_definition = definition_match
|
|
1883
|
+
migration_context.consumed_derive_indexes.add(derive_index)
|
|
1884
|
+
preview_step = _migrate_derive_definition(derive_definition, migration_context)
|
|
1885
|
+
if "step" in path_step:
|
|
1886
|
+
preview_step["id"] = path_step["step"]
|
|
1887
|
+
return preview_step
|
|
1888
|
+
|
|
1889
|
+
|
|
1890
|
+
def _migrate_derive_definition(
|
|
1891
|
+
derive_definition: dict[str, Any], migration_context: _MigrationContext
|
|
1892
|
+
) -> dict[str, Any]:
|
|
1893
|
+
source_slots = cast(list[str], derive_definition["from"])
|
|
1894
|
+
preview_step: dict[str, Any] = {
|
|
1895
|
+
"derive": derive_definition["writes"],
|
|
1896
|
+
"from": [_source_reference(source_slot, migration_context) for source_slot in source_slots],
|
|
1897
|
+
"lookup": _canonical_clone(derive_definition["lookup"]),
|
|
1898
|
+
}
|
|
1899
|
+
if "default" in derive_definition:
|
|
1900
|
+
derive_default = derive_definition["default"]
|
|
1901
|
+
if isinstance(derive_default, str) and derive_default.startswith("$from["):
|
|
1902
|
+
try:
|
|
1903
|
+
source_index = int(derive_default.removeprefix("$from[").removesuffix("]"))
|
|
1904
|
+
preview_step["default"] = _source_reference(
|
|
1905
|
+
source_slots[source_index], migration_context
|
|
1906
|
+
)
|
|
1907
|
+
except (ValueError, IndexError) as migration_error:
|
|
1908
|
+
raise V3PreviewMigrationError(
|
|
1909
|
+
f"invalid derive fallback reference {derive_default!r}"
|
|
1910
|
+
) from migration_error
|
|
1911
|
+
else:
|
|
1912
|
+
preview_step["default"] = _canonical_clone(derive_default)
|
|
1913
|
+
return preview_step
|
|
1914
|
+
|
|
1915
|
+
|
|
1916
|
+
def _insert_unanchored_derives(
|
|
1917
|
+
preview_steps: list[dict[str, Any]], migration_context: _MigrationContext
|
|
1918
|
+
) -> None:
|
|
1919
|
+
last_inserted_identifier_by_anchor: dict[str, str] = {}
|
|
1920
|
+
for derive_index, derive_definition in enumerate(migration_context.derive_definitions):
|
|
1921
|
+
if derive_index in migration_context.consumed_derive_indexes:
|
|
1922
|
+
continue
|
|
1923
|
+
preview_derive_step = _migrate_derive_definition(derive_definition, migration_context)
|
|
1924
|
+
migration_context.consumed_derive_indexes.add(derive_index)
|
|
1925
|
+
after_identifier = derive_definition.get("after")
|
|
1926
|
+
if after_identifier is None:
|
|
1927
|
+
preview_steps.append(preview_derive_step)
|
|
1928
|
+
continue
|
|
1929
|
+
effective_anchor = last_inserted_identifier_by_anchor.get(
|
|
1930
|
+
cast(str, after_identifier), cast(str, after_identifier)
|
|
1931
|
+
)
|
|
1932
|
+
anchor_index = next(
|
|
1933
|
+
(
|
|
1934
|
+
step_index
|
|
1935
|
+
for step_index, preview_step in enumerate(preview_steps)
|
|
1936
|
+
if _preview_step_identifier(preview_step) == effective_anchor
|
|
1937
|
+
),
|
|
1938
|
+
None,
|
|
1939
|
+
)
|
|
1940
|
+
if anchor_index is None:
|
|
1941
|
+
preview_steps.append(preview_derive_step)
|
|
1942
|
+
_record_loss(
|
|
1943
|
+
migration_context,
|
|
1944
|
+
V3PreviewLossCategory.UNKNOWN_DERIVE_ANCHOR,
|
|
1945
|
+
_json_pointer("derive", derive_index, "after"),
|
|
1946
|
+
after_identifier,
|
|
1947
|
+
)
|
|
1948
|
+
else:
|
|
1949
|
+
preview_steps.insert(anchor_index + 1, preview_derive_step)
|
|
1950
|
+
inserted_identifier = _preview_step_identifier(preview_derive_step)
|
|
1951
|
+
if inserted_identifier is None:
|
|
1952
|
+
raise V3PreviewMigrationError("migrated derive has no stable identifier")
|
|
1953
|
+
last_inserted_identifier_by_anchor[cast(str, after_identifier)] = inserted_identifier
|
|
1954
|
+
|
|
1955
|
+
|
|
1956
|
+
def _migrate_submit_step(path_index: int, migration_context: _MigrationContext) -> dict[str, Any]:
|
|
1957
|
+
terminal_definition = migration_context.terminal_definition
|
|
1958
|
+
if terminal_definition is None:
|
|
1959
|
+
raise V3PreviewMigrationError(
|
|
1960
|
+
f"/path/{path_index}/terminal has no top-level /terminal definition"
|
|
1961
|
+
)
|
|
1962
|
+
if migration_context.terminal_consumed:
|
|
1963
|
+
raise V3PreviewMigrationError("flowspec/3-draft preview supports a singular submit step")
|
|
1964
|
+
preview_inputs: dict[str, Any] = {}
|
|
1965
|
+
input_indexes_by_parameter: dict[str, list[int]] = {}
|
|
1966
|
+
for input_index, input_binding in enumerate(
|
|
1967
|
+
cast(list[dict[str, Any]], terminal_definition.get("input") or [])
|
|
1968
|
+
):
|
|
1969
|
+
parameter_name = cast(str, input_binding["param"])
|
|
1970
|
+
input_indexes_by_parameter.setdefault(parameter_name, []).append(input_index)
|
|
1971
|
+
preview_inputs[parameter_name] = _source_reference(
|
|
1972
|
+
cast(str, input_binding["slot"]), migration_context
|
|
1973
|
+
)
|
|
1974
|
+
for duplicate_indexes in input_indexes_by_parameter.values():
|
|
1975
|
+
for duplicate_index in duplicate_indexes[:-1]:
|
|
1976
|
+
_record_loss(
|
|
1977
|
+
migration_context,
|
|
1978
|
+
V3PreviewLossCategory.DUPLICATE_TERMINAL_INPUT_PARAMETER,
|
|
1979
|
+
_json_pointer("terminal", "input", duplicate_index),
|
|
1980
|
+
cast(list[dict[str, Any]], terminal_definition["input"])[duplicate_index],
|
|
1981
|
+
)
|
|
1982
|
+
|
|
1983
|
+
preview_outputs = {
|
|
1984
|
+
state_key: _result_reference(cast(str, result_path))
|
|
1985
|
+
for state_key, result_path in cast(
|
|
1986
|
+
dict[str, Any], terminal_definition.get("outputs") or {}
|
|
1987
|
+
).items()
|
|
1988
|
+
}
|
|
1989
|
+
preview_step: dict[str, Any] = {
|
|
1990
|
+
"submit": terminal_definition["tool"],
|
|
1991
|
+
"id": terminal_definition["step"],
|
|
1992
|
+
"idempotent": terminal_definition["idempotent"],
|
|
1993
|
+
"outcomes": _migrate_outcomes(cast(dict[str, Any], terminal_definition["outcomes"])),
|
|
1994
|
+
}
|
|
1995
|
+
if preview_inputs:
|
|
1996
|
+
preview_step["input"] = preview_inputs
|
|
1997
|
+
if preview_outputs:
|
|
1998
|
+
preview_step["outputs"] = preview_outputs
|
|
1999
|
+
if "empty_payload" in terminal_definition:
|
|
2000
|
+
preview_step["empty_payload"] = _canonical_clone(terminal_definition["empty_payload"])
|
|
2001
|
+
migration_context.terminal_consumed = True
|
|
2002
|
+
return preview_step
|
|
2003
|
+
|
|
2004
|
+
|
|
2005
|
+
def _migrate_outcomes(outcomes: dict[str, Any]) -> dict[str, Any]:
|
|
2006
|
+
return cast(dict[str, Any], _canonical_clone(outcomes))
|
|
2007
|
+
|
|
2008
|
+
|
|
2009
|
+
def _migrate_await_step(
|
|
2010
|
+
path_step: dict[str, Any],
|
|
2011
|
+
path_index: int,
|
|
2012
|
+
migration_context: _MigrationContext,
|
|
2013
|
+
) -> dict[str, Any]:
|
|
2014
|
+
await_capability = migration_context.await_capability
|
|
2015
|
+
if await_capability is None:
|
|
2016
|
+
raise V3PreviewMigrationError(
|
|
2017
|
+
f"/path/{path_index}/await_external has no /capabilities/await_external definition"
|
|
2018
|
+
)
|
|
2019
|
+
if migration_context.await_capability_consumed:
|
|
2020
|
+
raise V3PreviewMigrationError("flowspec/3-draft preview supports a singular await step")
|
|
2021
|
+
path_identifier = path_step.get("step")
|
|
2022
|
+
capability_identifier = await_capability.get("step")
|
|
2023
|
+
if (
|
|
2024
|
+
path_identifier is not None
|
|
2025
|
+
and capability_identifier is not None
|
|
2026
|
+
and path_identifier != capability_identifier
|
|
2027
|
+
):
|
|
2028
|
+
raise V3PreviewMigrationError(
|
|
2029
|
+
f"/path/{path_index}/step conflicts with /capabilities/await_external/step"
|
|
2030
|
+
)
|
|
2031
|
+
step_identifier = cast(str, path_identifier or capability_identifier or "await_external")
|
|
2032
|
+
preview_step: dict[str, Any] = {
|
|
2033
|
+
"await": await_capability["kind"],
|
|
2034
|
+
"id": step_identifier,
|
|
2035
|
+
"resume_on": await_capability["resume_on"],
|
|
2036
|
+
}
|
|
2037
|
+
_merge_await_presentation(
|
|
2038
|
+
"prompt", "prompt", path_step, path_index, await_capability, preview_step, migration_context
|
|
2039
|
+
)
|
|
2040
|
+
_merge_await_presentation(
|
|
2041
|
+
"interactive",
|
|
2042
|
+
"ui",
|
|
2043
|
+
path_step,
|
|
2044
|
+
path_index,
|
|
2045
|
+
await_capability,
|
|
2046
|
+
preview_step,
|
|
2047
|
+
migration_context,
|
|
2048
|
+
)
|
|
2049
|
+
if "ui" not in preview_step:
|
|
2050
|
+
raise V3PreviewMigrationError(
|
|
2051
|
+
f"/path/{path_index}/await_external has no out-of-band CTA contract"
|
|
2052
|
+
)
|
|
2053
|
+
if "on_resume" in await_capability:
|
|
2054
|
+
preview_on_resume = _migrate_on_resume(
|
|
2055
|
+
cast(dict[str, Any], await_capability["on_resume"]), migration_context
|
|
2056
|
+
)
|
|
2057
|
+
if preview_on_resume:
|
|
2058
|
+
preview_step["on_resume"] = preview_on_resume
|
|
2059
|
+
if "resume" in await_capability:
|
|
2060
|
+
_record_loss(
|
|
2061
|
+
migration_context,
|
|
2062
|
+
V3PreviewLossCategory.AWAIT_RESUME_CONTRACT,
|
|
2063
|
+
_json_pointer("capabilities", "await_external", "resume"),
|
|
2064
|
+
await_capability["resume"],
|
|
2065
|
+
)
|
|
2066
|
+
if "timeout_seconds" in await_capability:
|
|
2067
|
+
_record_loss(
|
|
2068
|
+
migration_context,
|
|
2069
|
+
V3PreviewLossCategory.AWAIT_TIMEOUT_DURATION,
|
|
2070
|
+
_json_pointer("capabilities", "await_external", "timeout_seconds"),
|
|
2071
|
+
await_capability["timeout_seconds"],
|
|
2072
|
+
)
|
|
2073
|
+
if "timeout" in await_capability:
|
|
2074
|
+
preview_step["timeout"] = _migrate_transition(
|
|
2075
|
+
cast(dict[str, Any], await_capability["timeout"])
|
|
2076
|
+
)
|
|
2077
|
+
if "recovery" in await_capability:
|
|
2078
|
+
preview_step["recovery"] = {
|
|
2079
|
+
recovery_event: _migrate_transition(cast(dict[str, Any], transition))
|
|
2080
|
+
for recovery_event, transition in cast(
|
|
2081
|
+
dict[str, Any], await_capability["recovery"]
|
|
2082
|
+
).items()
|
|
2083
|
+
}
|
|
2084
|
+
migration_context.await_capability_consumed = True
|
|
2085
|
+
return preview_step
|
|
2086
|
+
|
|
2087
|
+
|
|
2088
|
+
def _merge_await_presentation(
|
|
2089
|
+
source_key: str,
|
|
2090
|
+
preview_key: str,
|
|
2091
|
+
path_step: dict[str, Any],
|
|
2092
|
+
path_index: int,
|
|
2093
|
+
await_capability: dict[str, Any],
|
|
2094
|
+
preview_step: dict[str, Any],
|
|
2095
|
+
migration_context: _MigrationContext,
|
|
2096
|
+
) -> None:
|
|
2097
|
+
path_presentation = path_step.get(source_key)
|
|
2098
|
+
capability_presentation = await_capability.get(source_key)
|
|
2099
|
+
selected_presentation = path_presentation or capability_presentation
|
|
2100
|
+
if selected_presentation is None:
|
|
2101
|
+
return
|
|
2102
|
+
if (
|
|
2103
|
+
path_presentation is not None
|
|
2104
|
+
and capability_presentation is not None
|
|
2105
|
+
and capability_presentation != path_presentation
|
|
2106
|
+
):
|
|
2107
|
+
_record_loss(
|
|
2108
|
+
migration_context,
|
|
2109
|
+
V3PreviewLossCategory.SHADOWED_AWAIT_PRESENTATION,
|
|
2110
|
+
_json_pointer("capabilities", "await_external", source_key),
|
|
2111
|
+
capability_presentation,
|
|
2112
|
+
)
|
|
2113
|
+
if source_key == "interactive":
|
|
2114
|
+
preview_step[preview_key] = _migrate_ui(
|
|
2115
|
+
cast(dict[str, Any], selected_presentation),
|
|
2116
|
+
_json_pointer("path", path_index, source_key)
|
|
2117
|
+
if path_presentation is not None
|
|
2118
|
+
else _json_pointer("capabilities", "await_external", source_key),
|
|
2119
|
+
None,
|
|
2120
|
+
migration_context,
|
|
2121
|
+
)
|
|
2122
|
+
else:
|
|
2123
|
+
preview_step[preview_key] = _canonical_clone(selected_presentation)
|
|
2124
|
+
|
|
2125
|
+
|
|
2126
|
+
def _migrate_on_resume(
|
|
2127
|
+
on_resume: dict[str, Any], migration_context: _MigrationContext
|
|
2128
|
+
) -> dict[str, Any]:
|
|
2129
|
+
preview_on_resume: dict[str, Any] = {}
|
|
2130
|
+
if "set" in on_resume:
|
|
2131
|
+
preview_on_resume["set"] = _migrate_binding_map(cast(dict[str, Any], on_resume["set"]))
|
|
2132
|
+
if "enrich" in on_resume:
|
|
2133
|
+
enrichment = on_resume["enrich"]
|
|
2134
|
+
if isinstance(enrichment, str):
|
|
2135
|
+
_record_loss(
|
|
2136
|
+
migration_context,
|
|
2137
|
+
V3PreviewLossCategory.LEGACY_AWAIT_ENRICHMENT,
|
|
2138
|
+
_json_pointer("capabilities", "await_external", "on_resume", "enrich"),
|
|
2139
|
+
enrichment,
|
|
2140
|
+
)
|
|
2141
|
+
else:
|
|
2142
|
+
enrichment_definition = cast(dict[str, Any], enrichment)
|
|
2143
|
+
preview_enrichment: dict[str, Any] = {"tool": enrichment_definition["tool"]}
|
|
2144
|
+
if "optional" in enrichment_definition:
|
|
2145
|
+
preview_enrichment["optional"] = enrichment_definition["optional"]
|
|
2146
|
+
if "input" in enrichment_definition:
|
|
2147
|
+
preview_enrichment["input"] = _migrate_binding_map(
|
|
2148
|
+
cast(dict[str, Any], enrichment_definition["input"])
|
|
2149
|
+
)
|
|
2150
|
+
if "set" in enrichment_definition:
|
|
2151
|
+
preview_enrichment["set"] = _migrate_binding_map(
|
|
2152
|
+
cast(dict[str, Any], enrichment_definition["set"])
|
|
2153
|
+
)
|
|
2154
|
+
preview_on_resume["enrich"] = preview_enrichment
|
|
2155
|
+
return preview_on_resume
|
|
2156
|
+
|
|
2157
|
+
|
|
2158
|
+
def _migrate_transition(transition: dict[str, Any]) -> dict[str, Any]:
|
|
2159
|
+
transition_target = cast(str, transition["goto"])
|
|
2160
|
+
preview_transition: dict[str, Any] = {
|
|
2161
|
+
"goto": "END" if transition_target == "END" else _step_reference(transition_target)
|
|
2162
|
+
}
|
|
2163
|
+
if "set" in transition:
|
|
2164
|
+
preview_transition["set"] = _canonical_clone(transition["set"])
|
|
2165
|
+
return preview_transition
|
|
2166
|
+
|
|
2167
|
+
|
|
2168
|
+
def _migrate_ui(
|
|
2169
|
+
interactive: dict[str, Any],
|
|
2170
|
+
source_pointer: str,
|
|
2171
|
+
expected_domain_name: str | None,
|
|
2172
|
+
migration_context: _MigrationContext,
|
|
2173
|
+
) -> dict[str, Any]:
|
|
2174
|
+
preview_ui: dict[str, Any] = {}
|
|
2175
|
+
for interactive_key, interactive_value in interactive.items():
|
|
2176
|
+
if interactive_key == "gate":
|
|
2177
|
+
_record_loss(
|
|
2178
|
+
migration_context,
|
|
2179
|
+
V3PreviewLossCategory.LEGACY_INTERACTIVE_GATE,
|
|
2180
|
+
f"{source_pointer}/gate",
|
|
2181
|
+
interactive_value,
|
|
2182
|
+
)
|
|
2183
|
+
continue
|
|
2184
|
+
if interactive_key == "from_domain":
|
|
2185
|
+
if expected_domain_name != interactive_value:
|
|
2186
|
+
_record_loss(
|
|
2187
|
+
migration_context,
|
|
2188
|
+
V3PreviewLossCategory.MISMATCHED_INTERACTIVE_DOMAIN,
|
|
2189
|
+
f"{source_pointer}/from_domain",
|
|
2190
|
+
interactive_value,
|
|
2191
|
+
)
|
|
2192
|
+
continue
|
|
2193
|
+
if interactive_key == "next_step":
|
|
2194
|
+
preview_ui[interactive_key] = _step_reference(cast(str, interactive_value))
|
|
2195
|
+
continue
|
|
2196
|
+
if interactive_key == "prefill_from":
|
|
2197
|
+
preview_ui[interactive_key] = [
|
|
2198
|
+
_slot_reference(prefill_slot_name)
|
|
2199
|
+
for prefill_slot_name in cast(list[str], interactive_value)
|
|
2200
|
+
]
|
|
2201
|
+
continue
|
|
2202
|
+
if interactive_key == "options_when":
|
|
2203
|
+
preview_ui[interactive_key] = [
|
|
2204
|
+
{
|
|
2205
|
+
"value": option_gate["value"],
|
|
2206
|
+
"gate": _migrate_predicate(
|
|
2207
|
+
cast(dict[str, Any], option_gate["gate"]), migration_context
|
|
2208
|
+
),
|
|
2209
|
+
}
|
|
2210
|
+
for option_gate in cast(list[dict[str, Any]], interactive_value)
|
|
2211
|
+
]
|
|
2212
|
+
continue
|
|
2213
|
+
preview_ui[interactive_key] = _canonical_clone(interactive_value)
|
|
2214
|
+
return preview_ui
|
|
2215
|
+
|
|
2216
|
+
|
|
2217
|
+
def _migrate_predicate(
|
|
2218
|
+
predicate: dict[str, Any], migration_context: _MigrationContext
|
|
2219
|
+
) -> dict[str, Any]:
|
|
2220
|
+
operator, operand = next(iter(predicate.items()))
|
|
2221
|
+
if operator in {"and", "or"}:
|
|
2222
|
+
return {
|
|
2223
|
+
operator: [
|
|
2224
|
+
_migrate_predicate(child_predicate, migration_context)
|
|
2225
|
+
for child_predicate in cast(list[dict[str, Any]], operand)
|
|
2226
|
+
]
|
|
2227
|
+
}
|
|
2228
|
+
if operator == "not":
|
|
2229
|
+
return {operator: _migrate_predicate(cast(dict[str, Any], operand), migration_context)}
|
|
2230
|
+
if operator == "is_present":
|
|
2231
|
+
return {operator: _migrate_predicate_reference(cast(str, operand), migration_context)}
|
|
2232
|
+
if operator == "in":
|
|
2233
|
+
membership_operands = cast(list[Any], operand)
|
|
2234
|
+
return {
|
|
2235
|
+
operator: [
|
|
2236
|
+
_migrate_predicate_reference(cast(str, membership_operands[0]), migration_context),
|
|
2237
|
+
_canonical_clone(membership_operands[1]),
|
|
2238
|
+
]
|
|
2239
|
+
}
|
|
2240
|
+
comparison_operands = cast(list[Any], operand)
|
|
2241
|
+
return {
|
|
2242
|
+
operator: [
|
|
2243
|
+
_migrate_predicate_operand(comparison_operands[0], migration_context),
|
|
2244
|
+
_migrate_predicate_operand(comparison_operands[1], migration_context),
|
|
2245
|
+
]
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
|
|
2249
|
+
def _migrate_predicate_operand(predicate_operand: Any, migration_context: _MigrationContext) -> Any:
|
|
2250
|
+
if isinstance(predicate_operand, dict) and set(predicate_operand) == {"literal"}:
|
|
2251
|
+
return _canonical_clone(predicate_operand["literal"])
|
|
2252
|
+
if isinstance(predicate_operand, str):
|
|
2253
|
+
namespace, separator, reference_path = predicate_operand.partition(".")
|
|
2254
|
+
if separator and namespace == "slots" and reference_path:
|
|
2255
|
+
return _source_reference(reference_path, migration_context)
|
|
2256
|
+
if separator and namespace in _PREDICATE_REFERENCE_NAMESPACES and reference_path:
|
|
2257
|
+
return {_PREDICATE_REFERENCE_NAMESPACES[namespace]: reference_path}
|
|
2258
|
+
return _canonical_clone(predicate_operand)
|
|
2259
|
+
|
|
2260
|
+
|
|
2261
|
+
def _migrate_predicate_reference(
|
|
2262
|
+
reference: str, migration_context: _MigrationContext
|
|
2263
|
+
) -> dict[str, str]:
|
|
2264
|
+
migrated_reference = _migrate_predicate_operand(reference, migration_context)
|
|
2265
|
+
if not isinstance(migrated_reference, dict):
|
|
2266
|
+
raise V3PreviewMigrationError(f"predicate reference is not namespaced: {reference!r}")
|
|
2267
|
+
return cast(dict[str, str], migrated_reference)
|
|
2268
|
+
|
|
2269
|
+
|
|
2270
|
+
def _migrate_binding_map(bindings: dict[str, Any]) -> dict[str, Any]:
|
|
2271
|
+
return {
|
|
2272
|
+
binding_target: _migrate_binding(binding_source)
|
|
2273
|
+
for binding_target, binding_source in bindings.items()
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
|
|
2277
|
+
def _migrate_binding(binding_source: Any) -> Any:
|
|
2278
|
+
if isinstance(binding_source, str):
|
|
2279
|
+
for source_prefix, reference_key in (
|
|
2280
|
+
("$token.", "$token"),
|
|
2281
|
+
("$result.", "$result"),
|
|
2282
|
+
):
|
|
2283
|
+
if binding_source.startswith(source_prefix):
|
|
2284
|
+
return {reference_key: binding_source.removeprefix(source_prefix)}
|
|
2285
|
+
return _canonical_clone(binding_source)
|
|
2286
|
+
|
|
2287
|
+
|
|
2288
|
+
def _collect_loss_entries(migration_context: _MigrationContext) -> None:
|
|
2289
|
+
source_document = migration_context.source_document
|
|
2290
|
+
if "entry" in source_document:
|
|
2291
|
+
_record_loss(
|
|
2292
|
+
migration_context,
|
|
2293
|
+
V3PreviewLossCategory.ENTRY_CONTRACT,
|
|
2294
|
+
_json_pointer("entry"),
|
|
2295
|
+
source_document["entry"],
|
|
2296
|
+
)
|
|
2297
|
+
if "auto_flow" in source_document:
|
|
2298
|
+
_record_loss(
|
|
2299
|
+
migration_context,
|
|
2300
|
+
V3PreviewLossCategory.AUTOMATIC_FLOW_CONTRACT,
|
|
2301
|
+
_json_pointer("auto_flow"),
|
|
2302
|
+
source_document["auto_flow"],
|
|
2303
|
+
)
|
|
2304
|
+
|
|
2305
|
+
capabilities = cast(dict[str, Any], source_document.get("capabilities") or {})
|
|
2306
|
+
for capability_name, capability_definition in capabilities.items():
|
|
2307
|
+
if capability_name == "await_external" and migration_context.await_capability_consumed:
|
|
2308
|
+
continue
|
|
2309
|
+
_record_loss(
|
|
2310
|
+
migration_context,
|
|
2311
|
+
V3PreviewLossCategory.IMPLICIT_AWAIT_CAPABILITY
|
|
2312
|
+
if capability_name == "await_external"
|
|
2313
|
+
else V3PreviewLossCategory.AGENT_CAPABILITY,
|
|
2314
|
+
_json_pointer("capabilities", capability_name),
|
|
2315
|
+
capability_definition,
|
|
2316
|
+
)
|
|
2317
|
+
|
|
2318
|
+
for domain_name, domain_definition in migration_context.domains.items():
|
|
2319
|
+
if domain_name not in migration_context.used_domain_names:
|
|
2320
|
+
_record_loss(
|
|
2321
|
+
migration_context,
|
|
2322
|
+
V3PreviewLossCategory.UNUSED_DOMAIN_DECLARATION,
|
|
2323
|
+
_json_pointer("domains", domain_name),
|
|
2324
|
+
domain_definition,
|
|
2325
|
+
)
|
|
2326
|
+
for slot_name, slot_definition in migration_context.slots.items():
|
|
2327
|
+
if slot_name not in migration_context.placed_slot_names:
|
|
2328
|
+
_record_loss(
|
|
2329
|
+
migration_context,
|
|
2330
|
+
V3PreviewLossCategory.UNUSED_SLOT_DECLARATION,
|
|
2331
|
+
_json_pointer("slots", slot_name),
|
|
2332
|
+
slot_definition,
|
|
2333
|
+
)
|
|
2334
|
+
for use_index, use_declaration in enumerate(migration_context.use_declarations):
|
|
2335
|
+
if use_index not in migration_context.consumed_use_indexes:
|
|
2336
|
+
_record_loss(
|
|
2337
|
+
migration_context,
|
|
2338
|
+
V3PreviewLossCategory.UNUSED_SUBFLOW_DECLARATION,
|
|
2339
|
+
_json_pointer("uses", use_index),
|
|
2340
|
+
use_declaration,
|
|
2341
|
+
)
|
|
2342
|
+
if (
|
|
2343
|
+
migration_context.confirmation_definition is not None
|
|
2344
|
+
and not migration_context.confirmation_consumed
|
|
2345
|
+
):
|
|
2346
|
+
_record_loss(
|
|
2347
|
+
migration_context,
|
|
2348
|
+
V3PreviewLossCategory.UNUSED_CONFIRMATION_CONTRACT,
|
|
2349
|
+
_json_pointer("confirm"),
|
|
2350
|
+
migration_context.confirmation_definition,
|
|
2351
|
+
)
|
|
2352
|
+
if (
|
|
2353
|
+
migration_context.terminal_definition is not None
|
|
2354
|
+
and not migration_context.terminal_consumed
|
|
2355
|
+
):
|
|
2356
|
+
_record_loss(
|
|
2357
|
+
migration_context,
|
|
2358
|
+
V3PreviewLossCategory.UNUSED_TERMINAL_CONTRACT,
|
|
2359
|
+
_json_pointer("terminal"),
|
|
2360
|
+
migration_context.terminal_definition,
|
|
2361
|
+
)
|
|
2362
|
+
for gate_name, gate_predicate in migration_context.override_gates.items():
|
|
2363
|
+
if gate_name not in migration_context.consumed_override_gate_names:
|
|
2364
|
+
_record_loss(
|
|
2365
|
+
migration_context,
|
|
2366
|
+
V3PreviewLossCategory.UNUSED_OVERRIDE_GATE,
|
|
2367
|
+
_json_pointer("overrides", "gates", gate_name),
|
|
2368
|
+
gate_predicate,
|
|
2369
|
+
)
|
|
2370
|
+
for top_level_key, top_level_fragment in source_document.items():
|
|
2371
|
+
if top_level_key not in _TOP_LEVEL_NATIVE_KEYS:
|
|
2372
|
+
_record_loss(
|
|
2373
|
+
migration_context,
|
|
2374
|
+
V3PreviewLossCategory.UNKNOWN_TOP_LEVEL_FRAGMENT,
|
|
2375
|
+
_json_pointer(top_level_key),
|
|
2376
|
+
top_level_fragment,
|
|
2377
|
+
)
|
|
2378
|
+
|
|
2379
|
+
|
|
2380
|
+
def _record_loss(
|
|
2381
|
+
migration_context: _MigrationContext,
|
|
2382
|
+
category: V3PreviewLossCategory,
|
|
2383
|
+
source_path: str,
|
|
2384
|
+
source_fragment: Any,
|
|
2385
|
+
) -> None:
|
|
2386
|
+
if source_path in migration_context.loss_entries_by_path:
|
|
2387
|
+
raise V3PreviewMigrationError(f"source path {source_path!r} was classified more than once")
|
|
2388
|
+
migration_context.loss_entries_by_path[source_path] = V3PreviewLossEntry(
|
|
2389
|
+
source_path=source_path,
|
|
2390
|
+
category=category,
|
|
2391
|
+
disposition=_loss_policy_rules()[category].disposition,
|
|
2392
|
+
_source_fragment_json=_canonical_json(source_fragment),
|
|
2393
|
+
)
|
|
2394
|
+
|
|
2395
|
+
|
|
2396
|
+
def _preview_step_identifier(preview_step: dict[str, Any]) -> str | None:
|
|
2397
|
+
explicit_identifier = preview_step.get("id")
|
|
2398
|
+
if isinstance(explicit_identifier, str):
|
|
2399
|
+
return explicit_identifier
|
|
2400
|
+
for step_kind in ("collect", "confirm"):
|
|
2401
|
+
step_target = preview_step.get(step_kind)
|
|
2402
|
+
if isinstance(step_target, str):
|
|
2403
|
+
return step_target
|
|
2404
|
+
derive_target = preview_step.get("derive")
|
|
2405
|
+
if isinstance(derive_target, str):
|
|
2406
|
+
return f"derive_{derive_target}"
|
|
2407
|
+
return None
|
|
2408
|
+
|
|
2409
|
+
|
|
2410
|
+
def _slot_reference(slot_path: str) -> dict[str, str]:
|
|
2411
|
+
return {"$slot": slot_path}
|
|
2412
|
+
|
|
2413
|
+
|
|
2414
|
+
def _source_reference(source_name: str, migration_context: _MigrationContext) -> dict[str, str]:
|
|
2415
|
+
derive_targets = {
|
|
2416
|
+
cast(str, derive_definition["writes"])
|
|
2417
|
+
for derive_definition in migration_context.derive_definitions
|
|
2418
|
+
}
|
|
2419
|
+
return (
|
|
2420
|
+
{"$derive": source_name}
|
|
2421
|
+
if source_name in derive_targets and source_name not in migration_context.slots
|
|
2422
|
+
else _slot_reference(source_name)
|
|
2423
|
+
)
|
|
2424
|
+
|
|
2425
|
+
|
|
2426
|
+
def _step_reference(step_identifier: str) -> dict[str, str]:
|
|
2427
|
+
return {"$step": step_identifier}
|
|
2428
|
+
|
|
2429
|
+
|
|
2430
|
+
def _result_reference(result_path: str) -> dict[str, str]:
|
|
2431
|
+
return {"$result": result_path.removeprefix("result.") if result_path != "result" else "$"}
|
|
2432
|
+
|
|
2433
|
+
|
|
2434
|
+
def _same_json_scalar(left_scalar: Any, right_scalar: Any) -> bool:
|
|
2435
|
+
return type(left_scalar) is type(right_scalar) and left_scalar == right_scalar
|
|
2436
|
+
|
|
2437
|
+
|
|
2438
|
+
def _json_identity(json_scalar: Any) -> str:
|
|
2439
|
+
return json.dumps(json_scalar, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
2440
|
+
|
|
2441
|
+
|
|
2442
|
+
def _json_pointer(*path_segments: str | int) -> str:
|
|
2443
|
+
escaped_segments = [
|
|
2444
|
+
str(path_segment).replace("~", "~0").replace("/", "~1") for path_segment in path_segments
|
|
2445
|
+
]
|
|
2446
|
+
return "/" + "/".join(escaped_segments)
|
|
2447
|
+
|
|
2448
|
+
|
|
2449
|
+
def _canonical_json(json_value: Any) -> str:
|
|
2450
|
+
return json.dumps(
|
|
2451
|
+
json_value,
|
|
2452
|
+
ensure_ascii=False,
|
|
2453
|
+
sort_keys=True,
|
|
2454
|
+
separators=(",", ":"),
|
|
2455
|
+
allow_nan=False,
|
|
2456
|
+
)
|
|
2457
|
+
|
|
2458
|
+
|
|
2459
|
+
def _canonical_clone(json_value: Any) -> Any:
|
|
2460
|
+
return json.loads(_canonical_json(json_value))
|