@qingflow-tech/qingflow-cli 1.1.43-beta.3 → 1.1.43-beta.5
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.
- package/README.md +3 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-app-form/SKILL.md +12 -9
- package/skills/qingflow-cli/reference/builder/30-schema-fields.md +15 -13
- package/src/qingflow_mcp/app_form_compat.py +11 -27
- package/src/qingflow_mcp/app_form_validator.py +298 -35
- package/src/qingflow_mcp/builder_facade/app_form.py +777 -150
- package/src/qingflow_mcp/builder_facade/app_form_schema.py +164 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +5 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +299 -239
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +82 -30
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +113 -37
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +87 -64
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +83 -28
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +83 -28
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +95 -40
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +100 -28
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +201 -111
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/formula.json +1594 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +143 -161
- package/src/qingflow_mcp/builder_facade/service.py +3 -2
- package/src/qingflow_mcp/cli/commands/builder.py +2 -0
- package/src/qingflow_mcp/response_trim.py +2 -0
- package/src/qingflow_mcp/server_app_builder.py +9 -4
- package/src/qingflow_mcp/tools/ai_builder_tools.py +13 -3
|
@@ -2,12 +2,28 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from copy import deepcopy
|
|
4
4
|
import json
|
|
5
|
+
import re
|
|
5
6
|
from typing import Any, Iterable
|
|
6
7
|
|
|
7
8
|
from jsonschema import Draft202012Validator, FormatChecker
|
|
8
9
|
|
|
9
|
-
from .app_form_compat import normalize_app_form_declaration
|
|
10
|
-
from .builder_facade.app_form_schema import AppFormContractError
|
|
10
|
+
from .app_form_compat import normalize_app_form_declaration
|
|
11
|
+
from .builder_facade.app_form_schema import AppFormContractError, compact_schema_errors, schema_error_message
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_INTEGRATION_OUTPUT_TARGET_FIELD_TYPES = {
|
|
15
|
+
"text",
|
|
16
|
+
"long_text",
|
|
17
|
+
"number",
|
|
18
|
+
"amount",
|
|
19
|
+
"date",
|
|
20
|
+
"datetime",
|
|
21
|
+
"single_choice",
|
|
22
|
+
"single_select",
|
|
23
|
+
"multi_select",
|
|
24
|
+
}
|
|
25
|
+
_Q_LINKER_TEMPLATE_RE = re.compile(r"\{\{(?P<key>[^{}\s]+)\}\}")
|
|
26
|
+
_FORMULA_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
11
27
|
|
|
12
28
|
|
|
13
29
|
class AppFormValidationError(ValueError):
|
|
@@ -20,13 +36,17 @@ class AppFormValidationError(ValueError):
|
|
|
20
36
|
self.api_version = api_version
|
|
21
37
|
|
|
22
38
|
|
|
39
|
+
def _field_type(field: dict[str, Any]) -> str:
|
|
40
|
+
return str(field.get("questionType") or field.get("type") or "").strip()
|
|
41
|
+
|
|
42
|
+
|
|
23
43
|
def _field_types(payload: dict[str, Any]) -> tuple[str, ...]:
|
|
24
44
|
found: set[str] = set()
|
|
25
45
|
|
|
26
46
|
def visit(field: Any) -> None:
|
|
27
47
|
if not isinstance(field, dict):
|
|
28
48
|
return
|
|
29
|
-
field_type =
|
|
49
|
+
field_type = _field_type(field)
|
|
30
50
|
if field_type:
|
|
31
51
|
found.add(field_type)
|
|
32
52
|
for child in field.get("subfields") or []:
|
|
@@ -49,6 +69,18 @@ def _local_schema_call(schema_version: str | None, field_type: str | None) -> di
|
|
|
49
69
|
return {"status": "success", **app_form_schema_registry().schema(schema_version=schema_version, field_type=field_type)}
|
|
50
70
|
|
|
51
71
|
|
|
72
|
+
def _supported_formula_functions(schema_version: str) -> frozenset[str]:
|
|
73
|
+
"""Load the version-pinned formula function catalogue used by local validation."""
|
|
74
|
+
from .builder_facade.app_form_schema import app_form_schema_registry
|
|
75
|
+
|
|
76
|
+
response = app_form_schema_registry().schema(schema_version=schema_version, schema_kind="formula")
|
|
77
|
+
schema = response.get("schema") if isinstance(response, dict) else None
|
|
78
|
+
functions = schema.get("x-qingflow-functions") if isinstance(schema, dict) else None
|
|
79
|
+
if not isinstance(functions, dict) or not functions:
|
|
80
|
+
raise RuntimeError(f"/: AppForm formula Schema has no function catalogue for {schema_version}")
|
|
81
|
+
return frozenset(str(name).upper() for name in functions if isinstance(name, str) and name)
|
|
82
|
+
|
|
83
|
+
|
|
52
84
|
def _compose(summary: dict[str, Any], details: list[dict[str, Any]]) -> dict[str, Any]:
|
|
53
85
|
if not details:
|
|
54
86
|
raise RuntimeError("at least one field Schema is required to compose AppForm validation")
|
|
@@ -77,19 +109,92 @@ def _compose(summary: dict[str, Any], details: list[dict[str, Any]]) -> dict[str
|
|
|
77
109
|
return schema
|
|
78
110
|
|
|
79
111
|
|
|
80
|
-
def _leaf_errors(error: Any) -> Iterable[Any]:
|
|
81
|
-
if not error.context:
|
|
82
|
-
yield error
|
|
83
|
-
return
|
|
84
|
-
for child in error.context:
|
|
85
|
-
yield from _leaf_errors(child)
|
|
86
|
-
|
|
87
|
-
|
|
88
112
|
def _pointer(parts: Iterable[Any]) -> str:
|
|
89
113
|
encoded = [str(item).replace("~", "~0").replace("/", "~1") for item in parts]
|
|
90
114
|
return "/" + "/".join(encoded) if encoded else "/"
|
|
91
115
|
|
|
92
116
|
|
|
117
|
+
def _formula_function_calls(expression: str) -> set[str]:
|
|
118
|
+
"""Extract calls outside quoted string literals without parsing the full formula grammar."""
|
|
119
|
+
calls: set[str] = set()
|
|
120
|
+
index = 0
|
|
121
|
+
while index < len(expression):
|
|
122
|
+
character = expression[index]
|
|
123
|
+
if character in {"'", '"'}:
|
|
124
|
+
quote = character
|
|
125
|
+
index += 1
|
|
126
|
+
while index < len(expression):
|
|
127
|
+
if expression[index] == "\\":
|
|
128
|
+
index += 2
|
|
129
|
+
continue
|
|
130
|
+
if expression[index] == quote:
|
|
131
|
+
index += 1
|
|
132
|
+
break
|
|
133
|
+
index += 1
|
|
134
|
+
continue
|
|
135
|
+
match = _FORMULA_IDENTIFIER_RE.match(expression, index)
|
|
136
|
+
if match is None:
|
|
137
|
+
index += 1
|
|
138
|
+
continue
|
|
139
|
+
name = match.group(0)
|
|
140
|
+
cursor = match.end()
|
|
141
|
+
while cursor < len(expression) and expression[cursor].isspace():
|
|
142
|
+
cursor += 1
|
|
143
|
+
if cursor < len(expression) and expression[cursor] in {"(", "("}:
|
|
144
|
+
calls.add(name)
|
|
145
|
+
index = match.end()
|
|
146
|
+
return calls
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _iter_formula_expressions(payload: dict[str, Any]) -> Iterable[tuple[str, str]]:
|
|
150
|
+
spec = payload.get("spec")
|
|
151
|
+
body = spec.get("body") if isinstance(spec, dict) else None
|
|
152
|
+
if not isinstance(body, list):
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
def visit(field: Any, path: str) -> Iterable[tuple[str, str]]:
|
|
156
|
+
if not isinstance(field, dict):
|
|
157
|
+
return
|
|
158
|
+
if field.get("defaultType") == 3 and isinstance(field.get("defaultValue"), str):
|
|
159
|
+
yield f"{path}/defaultValue", field["defaultValue"]
|
|
160
|
+
config = field.get("config") if isinstance(field.get("config"), dict) else {}
|
|
161
|
+
default_value_formula = config.get("defaultValueFormula")
|
|
162
|
+
if isinstance(default_value_formula, str):
|
|
163
|
+
yield f"{path}/config/defaultValueFormula", default_value_formula
|
|
164
|
+
filters = config.get("filters") if isinstance(config.get("filters"), dict) else {}
|
|
165
|
+
if filters.get("mode") == "advanced" and isinstance(filters.get("formula"), str):
|
|
166
|
+
yield f"{path}/config/filters/formula", filters["formula"]
|
|
167
|
+
for child_index, child in enumerate(field.get("subfields") or []):
|
|
168
|
+
yield from visit(child, f"{path}/subfields/{child_index}")
|
|
169
|
+
|
|
170
|
+
for block_index, block in enumerate(body):
|
|
171
|
+
if not isinstance(block, dict):
|
|
172
|
+
continue
|
|
173
|
+
rows = [(block, f"/spec/body/{block_index}")] if block.get("kind") == "row" else [
|
|
174
|
+
(row, f"/spec/body/{block_index}/rows/{row_index}")
|
|
175
|
+
for row_index, row in enumerate(block.get("rows") or [])
|
|
176
|
+
]
|
|
177
|
+
for row, row_path in rows:
|
|
178
|
+
if not isinstance(row, dict):
|
|
179
|
+
continue
|
|
180
|
+
for field_index, field in enumerate(row.get("fields") or []):
|
|
181
|
+
yield from visit(field, f"{row_path}/fields/{field_index}")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _formula_errors(payload: dict[str, Any], supported_functions: frozenset[str]) -> list[tuple[str, str]]:
|
|
185
|
+
errors: list[tuple[str, str]] = []
|
|
186
|
+
for pointer, expression in _iter_formula_expressions(payload):
|
|
187
|
+
unknown = sorted(name for name in _formula_function_calls(expression) if name.upper() not in supported_functions)
|
|
188
|
+
for name in unknown:
|
|
189
|
+
errors.append(
|
|
190
|
+
(
|
|
191
|
+
pointer,
|
|
192
|
+
f"unsupported formula function '{name}'; use a function declared by builder app-form schema --schema-kind formula",
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
return errors
|
|
196
|
+
|
|
197
|
+
|
|
93
198
|
def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
|
|
94
199
|
errors: list[tuple[str, str]] = []
|
|
95
200
|
spec = payload.get("spec")
|
|
@@ -116,38 +221,198 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
|
|
|
116
221
|
for field_index, field in enumerate(fields):
|
|
117
222
|
if isinstance(field, dict):
|
|
118
223
|
top_fields.append((f"{row_path}/{field_index}", field))
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
224
|
+
def resolve_local_selector(selector: Any) -> tuple[str, tuple[str, dict[str, Any]] | None]:
|
|
225
|
+
"""Resolve against the declared body, preserving backend-dependent ID-only cases."""
|
|
226
|
+
if not isinstance(selector, dict):
|
|
227
|
+
return "schema", None
|
|
228
|
+
raw_que_id = selector.get("queId")
|
|
229
|
+
que_id = raw_que_id if isinstance(raw_que_id, int) and not isinstance(raw_que_id, bool) else None
|
|
230
|
+
raw_name = selector.get("name")
|
|
231
|
+
name = raw_name if isinstance(raw_name, str) and raw_name else ""
|
|
232
|
+
if raw_que_id is not None and que_id is None:
|
|
233
|
+
return "schema", None
|
|
234
|
+
if raw_name is not None and not name:
|
|
235
|
+
return "schema", None
|
|
236
|
+
if que_id is not None:
|
|
237
|
+
matches = [(path, field) for path, field in top_fields if field.get("queId") == que_id]
|
|
238
|
+
if len(matches) != 1:
|
|
239
|
+
return "invalid", None
|
|
240
|
+
declared_name = str(matches[0][1].get("name") or "")
|
|
241
|
+
if name and declared_name and name != declared_name:
|
|
242
|
+
return "invalid", None
|
|
243
|
+
return "resolved", matches[0]
|
|
244
|
+
if name:
|
|
245
|
+
matches = [(path, field) for path, field in top_fields if str(field.get("name") or "") == name]
|
|
246
|
+
if len(matches) == 1:
|
|
247
|
+
return "resolved", matches[0]
|
|
248
|
+
if len(matches) > 1:
|
|
249
|
+
return "invalid", None
|
|
250
|
+
if any(not str(field.get("name") or "") for _, field in top_fields):
|
|
251
|
+
return "unknown", None
|
|
252
|
+
return "invalid", None
|
|
253
|
+
return "schema", None
|
|
254
|
+
|
|
255
|
+
title_selector = spec.get("dataTitleField")
|
|
256
|
+
if top_fields:
|
|
257
|
+
if "dataTitleField" not in spec and "appKey" in payload:
|
|
258
|
+
pass
|
|
259
|
+
elif title_selector is None or resolve_local_selector(title_selector)[0] == "invalid":
|
|
260
|
+
errors.append(("/spec/dataTitleField", "dataTitleField must resolve to exactly one top-level field"))
|
|
261
|
+
elif title_selector is not None:
|
|
262
|
+
errors.append(("/spec/dataTitleField", "dataTitleField must be null when body is empty"))
|
|
263
|
+
|
|
264
|
+
cover_selector = spec.get("dataCoverField")
|
|
265
|
+
if cover_selector is not None:
|
|
266
|
+
cover_resolution, cover = resolve_local_selector(cover_selector)
|
|
267
|
+
if cover_resolution == "invalid":
|
|
268
|
+
errors.append(("/spec/dataCoverField", "dataCoverField must resolve to exactly one top-level field"))
|
|
269
|
+
elif cover_resolution == "resolved" and cover is not None and _field_type(cover[1]) != "attachment":
|
|
270
|
+
errors.append(("/spec/dataCoverField", "dataCoverField must select a top-level attachment field"))
|
|
127
271
|
names: dict[str, str] = {}
|
|
128
272
|
fields_by_id = {
|
|
129
273
|
field.get("queId"): field
|
|
130
274
|
for _, field in top_fields
|
|
131
275
|
if isinstance(field.get("queId"), int) and not isinstance(field.get("queId"), bool)
|
|
132
276
|
}
|
|
277
|
+
output_targets: dict[str, tuple[str, str]] = {}
|
|
133
278
|
for path, field in top_fields:
|
|
134
279
|
name = str(field.get("name") or "")
|
|
135
|
-
if name
|
|
136
|
-
|
|
280
|
+
if name:
|
|
281
|
+
if name in names:
|
|
282
|
+
errors.append((f"{path}/name", f"duplicate top-level field name; first declared at {names[name]}"))
|
|
283
|
+
else:
|
|
284
|
+
names[name] = f"{path}/name"
|
|
285
|
+
field_type = _field_type(field)
|
|
286
|
+
if field_type == "data_relation":
|
|
287
|
+
config = field.get("config") if isinstance(field.get("config"), dict) else {}
|
|
288
|
+
filters = config.get("filters") if isinstance(config.get("filters"), dict) else {}
|
|
289
|
+
if filters.get("mode") == "simple":
|
|
290
|
+
for group_index, group in enumerate(filters.get("groups") or []):
|
|
291
|
+
for condition_index, condition in enumerate(group if isinstance(group, list) else []):
|
|
292
|
+
if not isinstance(condition, dict):
|
|
293
|
+
continue
|
|
294
|
+
operator = condition.get("operator")
|
|
295
|
+
operand = condition.get("operand") if isinstance(condition.get("operand"), dict) else {}
|
|
296
|
+
values = operand.get("values") if isinstance(operand.get("values"), list) else None
|
|
297
|
+
operand_path = (
|
|
298
|
+
f"{path}/config/filters/groups/{group_index}/{condition_index}/operand"
|
|
299
|
+
)
|
|
300
|
+
if operator in {"is_empty", "is_not_empty"}:
|
|
301
|
+
if operand.get("kind") != "literal" or values != []:
|
|
302
|
+
errors.append(
|
|
303
|
+
(
|
|
304
|
+
operand_path,
|
|
305
|
+
"is_empty and is_not_empty require a literal operand with values: []",
|
|
306
|
+
)
|
|
307
|
+
)
|
|
308
|
+
elif operand.get("kind") == "literal" and not values:
|
|
309
|
+
errors.append(
|
|
310
|
+
(
|
|
311
|
+
f"{operand_path}/values",
|
|
312
|
+
"literal operands for non-empty operators must contain at least one value",
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
if field_type == "q_linker":
|
|
316
|
+
binding_name = "qLinkerBinding"
|
|
317
|
+
elif field_type == "code_block":
|
|
318
|
+
binding_name = "codeBlockBinding"
|
|
137
319
|
else:
|
|
138
|
-
names[name] = f"{path}/name"
|
|
139
|
-
if field.get("type") != "q_linker":
|
|
140
320
|
continue
|
|
141
|
-
binding = (field.get("config") or {}).get(
|
|
142
|
-
|
|
143
|
-
|
|
321
|
+
binding = (field.get("config") or {}).get(binding_name) if isinstance(field.get("config"), dict) else None
|
|
322
|
+
if not isinstance(binding, dict):
|
|
323
|
+
continue
|
|
324
|
+
for index, item in enumerate(binding.get("inputs") or []):
|
|
325
|
+
if not isinstance(item, dict):
|
|
144
326
|
continue
|
|
145
327
|
selector = item.get("field") if isinstance(item.get("field"), dict) else {}
|
|
146
|
-
|
|
328
|
+
input_path = f"{path}/config/{binding_name}/inputs/{index}"
|
|
329
|
+
resolution, selected = resolve_local_selector(selector)
|
|
330
|
+
if resolution == "invalid":
|
|
331
|
+
errors.append((f"{input_path}/field", "input field must resolve to exactly one top-level field"))
|
|
332
|
+
continue
|
|
333
|
+
if field_type != "q_linker" or item.get("source") != "json_path":
|
|
334
|
+
continue
|
|
335
|
+
selected_name = selected[1].get("name") if selected is not None else selector.get("name")
|
|
147
336
|
if not selected_name and selector.get("queId") in fields_by_id:
|
|
148
337
|
selected_name = fields_by_id[selector["queId"]].get("name")
|
|
149
338
|
if selected_name and item.get("key") != selected_name:
|
|
150
|
-
errors.append((f"{
|
|
339
|
+
errors.append((f"{input_path}/key", "json_path key must equal the selected field name"))
|
|
340
|
+
|
|
341
|
+
if field_type == "q_linker":
|
|
342
|
+
request = binding.get("request") if isinstance(binding.get("request"), dict) else {}
|
|
343
|
+
template_paths: dict[str, list[str]] = {}
|
|
344
|
+
for property_name in ("url", "jsonValue", "xmlValue"):
|
|
345
|
+
raw_template = request.get(property_name)
|
|
346
|
+
if not isinstance(raw_template, str):
|
|
347
|
+
continue
|
|
348
|
+
for match in _Q_LINKER_TEMPLATE_RE.finditer(raw_template):
|
|
349
|
+
template_paths.setdefault(match.group("key"), []).append(
|
|
350
|
+
f"{path}/config/{binding_name}/request/{property_name}"
|
|
351
|
+
)
|
|
352
|
+
input_items = [item for item in binding.get("inputs") or [] if isinstance(item, dict)]
|
|
353
|
+
json_path_inputs: dict[str, list[int]] = {}
|
|
354
|
+
for index, item in enumerate(input_items):
|
|
355
|
+
key = str(item.get("key") or "")
|
|
356
|
+
if key and item.get("source") == "json_path":
|
|
357
|
+
json_path_inputs.setdefault(key, []).append(index)
|
|
358
|
+
for key, request_paths in template_paths.items():
|
|
359
|
+
providers = json_path_inputs.get(key) or []
|
|
360
|
+
if len(providers) == 1:
|
|
361
|
+
continue
|
|
362
|
+
if not providers:
|
|
363
|
+
message = (
|
|
364
|
+
f"placeholder '{{{{{key}}}}}' requires an input with source 'json_path' and the same key; "
|
|
365
|
+
"query_param, header, and url_encoded inputs do not replace request templates"
|
|
366
|
+
)
|
|
367
|
+
else:
|
|
368
|
+
message = f"placeholder '{{{{{key}}}}}' must have exactly one json_path input"
|
|
369
|
+
errors.append((request_paths[0], message))
|
|
370
|
+
for key, indexes in json_path_inputs.items():
|
|
371
|
+
if key in template_paths:
|
|
372
|
+
continue
|
|
373
|
+
for index in indexes:
|
|
374
|
+
errors.append(
|
|
375
|
+
(
|
|
376
|
+
f"{path}/config/{binding_name}/inputs/{index}/source",
|
|
377
|
+
f"json_path input key '{key}' must appear as '{{{{{key}}}}}' in request.url, request.jsonValue, or request.xmlValue",
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
producer = name or str(field.get("queId") or field_type)
|
|
382
|
+
for index, item in enumerate(binding.get("outputs") or []):
|
|
383
|
+
if not isinstance(item, dict):
|
|
384
|
+
continue
|
|
385
|
+
selector = item.get("targetField")
|
|
386
|
+
target_path = f"{path}/config/{binding_name}/outputs/{index}/targetField"
|
|
387
|
+
resolution, selected = resolve_local_selector(selector)
|
|
388
|
+
if resolution == "invalid":
|
|
389
|
+
errors.append((target_path, "targetField must resolve to exactly one top-level field"))
|
|
390
|
+
continue
|
|
391
|
+
if resolution != "resolved" or selected is None:
|
|
392
|
+
continue
|
|
393
|
+
selected_path, target_field = selected
|
|
394
|
+
target_name = str(target_field.get("name") or target_field.get("queId") or "")
|
|
395
|
+
target_type = _field_type(target_field)
|
|
396
|
+
if target_type and target_type not in _INTEGRATION_OUTPUT_TARGET_FIELD_TYPES:
|
|
397
|
+
allowed = ", ".join(sorted(_INTEGRATION_OUTPUT_TARGET_FIELD_TYPES))
|
|
398
|
+
errors.append(
|
|
399
|
+
(
|
|
400
|
+
target_path,
|
|
401
|
+
f"output target field '{target_name}' uses unsupported questionType '{target_type}'; allowed types: {allowed}",
|
|
402
|
+
)
|
|
403
|
+
)
|
|
404
|
+
previous = output_targets.get(selected_path)
|
|
405
|
+
if previous is None:
|
|
406
|
+
output_targets[selected_path] = (producer, target_path)
|
|
407
|
+
continue
|
|
408
|
+
previous_producer, previous_path = previous
|
|
409
|
+
errors.append(
|
|
410
|
+
(
|
|
411
|
+
target_path,
|
|
412
|
+
f"field '{target_name}' is already targeted by output from '{previous_producer}' at {previous_path}; "
|
|
413
|
+
"each field can receive output from only one Q-Linker or code block relation",
|
|
414
|
+
)
|
|
415
|
+
)
|
|
151
416
|
return errors
|
|
152
417
|
|
|
153
418
|
|
|
@@ -175,6 +440,7 @@ def validate_declaration(
|
|
|
175
440
|
) from error
|
|
176
441
|
if declaration.get("apiVersion") != api_version:
|
|
177
442
|
raise ValueError(f"/apiVersion: expected {api_version!r} for schemaVersion {pinned!r}")
|
|
443
|
+
supported_formula_functions = _supported_formula_functions(pinned)
|
|
178
444
|
types = _field_types(declaration)
|
|
179
445
|
details = [schema_call(pinned, field_type) for field_type in (types or ("text",))]
|
|
180
446
|
for detail in details:
|
|
@@ -184,15 +450,12 @@ def validate_declaration(
|
|
|
184
450
|
Draft202012Validator.check_schema(schema)
|
|
185
451
|
validator = Draft202012Validator(schema, format_checker=FormatChecker())
|
|
186
452
|
found: dict[tuple[str, str], None] = {}
|
|
187
|
-
for error in validator.iter_errors(declaration):
|
|
188
|
-
|
|
189
|
-
found[(_pointer(leaf.absolute_path), f"{leaf.validator}: {leaf.message}")] = None
|
|
453
|
+
for error in compact_schema_errors(validator.iter_errors(declaration), schema):
|
|
454
|
+
found[(_pointer(error.absolute_path), f"{error.validator}: {schema_error_message(error)}")] = None
|
|
190
455
|
for pointer, message in _static_errors(declaration):
|
|
191
456
|
found[(pointer, message)] = None
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
except AppFormContractError as error:
|
|
195
|
-
found[(error.path or "/", error.message)] = None
|
|
457
|
+
for pointer, message in _formula_errors(declaration, supported_formula_functions):
|
|
458
|
+
found[(pointer, message)] = None
|
|
196
459
|
if found:
|
|
197
460
|
issues = [{"pointer": pointer, "message": message} for pointer, message in sorted(found)]
|
|
198
461
|
raise AppFormValidationError(issues, schema_version=pinned, api_version=api_version)
|