@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.
Files changed (39) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/skills/qingflow-app-form/SKILL.md +12 -9
  5. package/skills/qingflow-cli/reference/builder/30-schema-fields.md +15 -13
  6. package/src/qingflow_mcp/app_form_compat.py +11 -27
  7. package/src/qingflow_mcp/app_form_validator.py +298 -35
  8. package/src/qingflow_mcp/builder_facade/app_form.py +777 -150
  9. package/src/qingflow_mcp/builder_facade/app_form_schema.py +164 -8
  10. package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +5 -0
  11. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +299 -239
  12. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +82 -27
  13. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +82 -27
  14. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +82 -30
  15. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +113 -37
  16. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +87 -64
  17. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +82 -27
  18. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +82 -27
  19. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +83 -28
  20. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +82 -27
  21. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +82 -27
  22. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +82 -27
  23. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +83 -28
  24. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +82 -27
  25. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +82 -27
  26. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +82 -27
  27. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +95 -40
  28. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +100 -28
  29. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +82 -27
  30. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +82 -27
  31. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +201 -111
  32. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +82 -27
  33. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/formula.json +1594 -0
  34. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +143 -161
  35. package/src/qingflow_mcp/builder_facade/service.py +3 -2
  36. package/src/qingflow_mcp/cli/commands/builder.py +2 -0
  37. package/src/qingflow_mcp/response_trim.py +2 -0
  38. package/src/qingflow_mcp/server_app_builder.py +9 -4
  39. 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, validate_app_form_create_icon
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 = str(field.get("type") or "").strip()
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
- if top_fields and sum(field.get("dataTitle") is True for _, field in top_fields) != 1:
120
- errors.append(("/spec/body", "a non-empty body must contain exactly one dataTitle field"))
121
- covers = [(path, field) for path, field in top_fields if field.get("dataCover") is True]
122
- if len(covers) > 1:
123
- errors.append(("/spec/body", "body may contain at most one dataCover field"))
124
- for path, field in covers:
125
- if field.get("type") != "attachment":
126
- errors.append((f"{path}/dataCover", "dataCover is only valid on attachment fields"))
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 in names:
136
- errors.append((f"{path}/name", f"duplicate top-level field name; first declared at {names[name]}"))
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("qLinkerBinding") if isinstance(field.get("config"), dict) else None
142
- for index, item in enumerate((binding or {}).get("inputs") or [] if isinstance(binding, dict) else []):
143
- if not isinstance(item, dict) or item.get("source") != "json_path":
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
- selected_name = selector.get("name")
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"{path}/config/qLinkerBinding/inputs/{index}/key", "json_path key must equal the selected field name"))
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
- for leaf in _leaf_errors(error):
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
- try:
193
- validate_app_form_create_icon(declaration)
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)