@qingflow-tech/qingflow-cli 1.1.44 → 1.1.45-beta.1

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 CHANGED
@@ -7,7 +7,7 @@ Qingflow CLI 是轻流面向开发者和 AI Agent 的命令行工具。它把轻
7
7
  ## 安装
8
8
 
9
9
  ```bash
10
- npm install @qingflow-tech/qingflow-cli@1.1.44
10
+ npm install @qingflow-tech/qingflow-cli@1.1.45-beta.1
11
11
  ```
12
12
 
13
13
  推荐从官网复制这一条一键安装 Qingflow CLI:
@@ -21,7 +21,7 @@ npx qingflow-cli@latest install
21
21
  ## 运行
22
22
 
23
23
  ```bash
24
- npx -y -p @qingflow-tech/qingflow-cli@1.1.44 qingflow
24
+ npx -y -p @qingflow-tech/qingflow-cli@1.1.45-beta.1 qingflow
25
25
  ```
26
26
 
27
27
  全局安装后可以直接运行:
@@ -62,7 +62,7 @@ qingflow-skills install --agent codex --scope user
62
62
  如果你通过一次性 `npx -p` 执行 skills 安装,建议加 `--copy`,避免 symlink 指向 npm 临时执行缓存:
63
63
 
64
64
  ```bash
65
- npx -y -p @qingflow-tech/qingflow-cli@1.1.44 qingflow-skills install --agent codex --scope user --copy
65
+ npx -y -p @qingflow-tech/qingflow-cli@1.1.45-beta.1 qingflow-skills install --agent codex --scope user --copy
66
66
  ```
67
67
 
68
68
  `qingflow-skills install` 默认使用 symlink,且遇到同名 skill 不会覆盖;只有显式传入 `--force` 才会替换。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qingflow-tech/qingflow-cli",
3
- "version": "1.1.44",
3
+ "version": "1.1.45-beta.1",
4
4
  "description": "Human-friendly Qingflow command line interface for auth, record operations, import, tasks, and stable builder flows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "1.1.44"
7
+ version = "1.1.45b1"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -219,6 +219,8 @@ current CLI payload.
219
219
  | `data_relation` | 数据关联/引用填充 |
220
220
  | `subtable` | 子表(**`subfields[]`**) |
221
221
 
222
+ 历史表单中的后端 `queType=1` 是“描述文字”布局控件,不属于上述新建字段类型。`app-form get` 为了保持完整声明可校验,会将它兼容输出为 `long_text`;`app-form apply` 会根据已有字段模板恢复原始 `queType=1` 和 `queDefaultValue`,所以修改其他字段时不会丢失描述文字。新建可填写多行文本仍使用 `long_text`,不要用它创建描述文字控件。
223
+
222
224
  **`config.relationMode`**:仅 **`relation`** 使用,取值为 **`single`** 或 **`multiple`**。一个应用可以有多个 `relation` 字段;不要因为 relation 数量超过 1 就改成文本字段。
223
225
 
224
226
  ### 1.1 历史字段类型别名
@@ -246,6 +248,10 @@ current CLI payload.
246
248
  | `q_linker` | **`config.qLinkerBinding`**:`inputs`、`request`、**`outputs[]` 每项须含 `targetField`**(能解析到本表单字段) | `request.url` 等须符合**租户/后端远程查询策略**;以字段类型 Schema 返回的配置为准 |
247
249
  | `code_block` | **`config.codeBlockBinding`**:`inputs`、`code`、**`outputs[]` + `targetField`**;配置 outputs 时,代码必须显式写 **`qf_output = {...}`**,仅 `return {...}` 不会产生可回写结果,且每个 `outputs[].path` 必须从 `qf_output` 取值 | 以字段类型 Schema 返回的 camelCase 配置为准 |
248
250
 
251
+ 部门字段的 `defaultDepartments` 也遵循同一约束:`custom` 模式必须包含至少一个部门。读取历史后端数据时,如果默认部门值存在但部门列表为空,AppForm 归一化会将其视为“未配置默认部门”并省略 `defaultDepartments`;不要在新声明中写 `departments: []`。
252
+
253
+ 读取历史数据时,空的应用/段落/字段标题会按稳定 ID 生成兼容名称,空行和空筛选组会被忽略;选择项、子表列、序列号组件、Q-Linker 输出和代码源码等语义必需值无法安全推断时,读取会返回结构化 `UNSUPPORTED_FIELD_CONFIG`,不会伪造声明值。`validate/apply` 对新声明仍保持严格 Schema 约束。
254
+
249
255
  别名:契约会把 `title`/`label` 映射到 **`name`** 等;字段类型自然别名见 §1.1。
250
256
 
251
257
  ### 2.1 `relation` 选择器形态
@@ -24,6 +24,8 @@ _INTEGRATION_OUTPUT_TARGET_FIELD_TYPES = {
24
24
  }
25
25
  _Q_LINKER_TEMPLATE_RE = re.compile(r"\{\{(?P<key>[^{}\s]+)\}\}")
26
26
  _FORMULA_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
27
+ #: 公式字段引用正则(#queId_<id>$,支持表格嵌套 <id>.<id>)
28
+ _FORMULA_FIELD_REF_RE = re.compile(r"#queId_([^$]+)\$")
27
29
 
28
30
 
29
31
  class AppFormValidationError(ValueError):
@@ -460,3 +462,82 @@ def validate_declaration(
460
462
  issues = [{"pointer": pointer, "message": message} for pointer, message in sorted(found)]
461
463
  raise AppFormValidationError(issues, schema_version=pinned, api_version=api_version)
462
464
  return {"valid": True, "schemaVersion": pinned, "apiVersion": api_version, "fieldTypes": list(types)}
465
+
466
+
467
+ def validate_formula_expression(
468
+ formula: str,
469
+ fields: list[dict[str, Any]] | None = None,
470
+ ) -> list[dict[str, str]]:
471
+ """校验单条公式表达式:函数名合法 + 字段引用存在性。不依赖整份 AppForm,不调后端。
472
+
473
+ Args:
474
+ formula: 公式文本,如 ``CONCAT(#queId_101$, ":", #queId_102$)``。
475
+ fields: 字段清单 ``[{queId, name}]``(兼容 ``que_id``/``id``);提供时校验字段引用存在性,
476
+ 不提供时跳过字段引用校验。
477
+ Returns:
478
+ 错误列表 ``[{pointer, message}]``,空列表表示本地语法通过。
479
+ """
480
+ errors: list[dict[str, str]] = []
481
+ if not isinstance(formula, str) or not formula.strip():
482
+ errors.append({"pointer": "formula", "message": "formula is empty"})
483
+ return errors
484
+
485
+ # 函数名合法性(复用 AppForm formula Schema 的函数目录)
486
+ supported = _supported_formula_functions("v1alpha1")
487
+ unknown = sorted(
488
+ name for name in _formula_function_calls(formula) if name.upper() not in supported
489
+ )
490
+ for name in unknown:
491
+ errors.append({"pointer": "formula", "message": f"unsupported formula function '{name}'"})
492
+
493
+ # 字段引用存在性(#queId_<id>$,支持表格嵌套 <id>.<id>,按叶子 id 与原始 id 双重匹配)
494
+ if fields:
495
+ known_ids = {
496
+ str(identifier)
497
+ for field in fields
498
+ if isinstance(field, dict)
499
+ for identifier in (
500
+ field.get("queId"),
501
+ field.get("que_id"),
502
+ field.get("id"),
503
+ )
504
+ if identifier is not None and str(identifier) != ""
505
+ }
506
+ for match in _FORMULA_FIELD_REF_RE.finditer(formula):
507
+ raw_id = match.group(1)
508
+ leaf_id = raw_id.split(".")[-1]
509
+ if leaf_id not in known_ids and raw_id not in known_ids:
510
+ errors.append(
511
+ {
512
+ "pointer": "formula",
513
+ "message": f"field reference '{match.group(0)}' refers to unknown queId {raw_id}",
514
+ }
515
+ )
516
+ return errors
517
+
518
+
519
+ def validate_code_block_static(code: str) -> list[dict[str, str]]:
520
+ """校验代码块静态结构:qf_output 赋值 + 基本语法。不调后端。
521
+
522
+ Args:
523
+ code: 代码文本。
524
+ Returns:
525
+ 错误列表 ``[{pointer, message}]``,空列表表示本地静态校验通过。
526
+ """
527
+ errors: list[dict[str, str]] = []
528
+ if not isinstance(code, str) or not code.strip():
529
+ errors.append({"pointer": "code", "message": "code is empty"})
530
+ return errors
531
+
532
+ # qf_output 必须直接赋值(不能用 const/let 声明);仅当代码涉及 outputs 绑定时才提示
533
+ has_qf_output = bool(re.search(r"(^|[^A-Za-z0-9_$])qf_output\s*=(?!=)", code))
534
+ has_outputs_binding = "outputs" in code
535
+ if not has_qf_output and has_outputs_binding:
536
+ errors.append(
537
+ {
538
+ "pointer": "code",
539
+ "message": "code declares outputs but does not assign qf_output; "
540
+ "use 'qf_output = {...}' (not const/let)",
541
+ }
542
+ )
543
+ return errors
@@ -31,6 +31,10 @@ from .service import (
31
31
 
32
32
  API_VERSION = "builder.qingflow.com/v1alpha1"
33
33
  QUE_TYPE_TO_CANONICAL = {
34
+ # Historical description-text controls are read as long_text for the
35
+ # public declaration shape; the compiler restores queType=1 from the
36
+ # existing backend template when writing them back.
37
+ 1: "long_text",
34
38
  2: "text",
35
39
  3: "long_text",
36
40
  5: "member",
@@ -227,23 +231,33 @@ def _canonical_member_scope(scope: Any, scope_type: Any, *, department: bool = F
227
231
  }
228
232
  if department:
229
233
  result["departments"] = [
230
- _without_none({"deptId": _positive_int(item.get("deptId") or item.get("id")), "deptName": item.get("deptName") or item.get("name")})
234
+ _without_none(
235
+ {
236
+ "deptId": _positive_int(item.get("deptId") or item.get("id")),
237
+ "deptName": _compat_label(item.get("deptName") or item.get("name"), "Department", item.get("deptId") or item.get("id")),
238
+ }
239
+ )
231
240
  for item in _scope_items(raw, "depart")
232
241
  if _positive_int(item.get("deptId") or item.get("id")) is not None
233
242
  ]
234
243
  else:
235
244
  result["members"] = [
236
- _without_none({"uid": _positive_int(item.get("uid")), "nickName": item.get("nickName")})
245
+ _without_none({"uid": _positive_int(item.get("uid")), "nickName": _compat_label(item.get("nickName"), "Member", item.get("uid"))})
237
246
  for item in _scope_items(raw, "member")
238
247
  if _positive_int(item.get("uid")) is not None
239
248
  ]
240
249
  result["departments"] = [
241
- _without_none({"deptId": _positive_int(item.get("deptId")), "deptName": item.get("deptName")})
250
+ _without_none(
251
+ {
252
+ "deptId": _positive_int(item.get("deptId")),
253
+ "deptName": _compat_label(item.get("deptName"), "Department", item.get("deptId")),
254
+ }
255
+ )
242
256
  for item in _scope_items(raw, "depart")
243
257
  if _positive_int(item.get("deptId")) is not None
244
258
  ]
245
259
  result["roles"] = [
246
- _without_none({"roleId": _positive_int(item.get("roleId")), "roleName": item.get("roleName")})
260
+ _without_none({"roleId": _positive_int(item.get("roleId")), "roleName": _compat_label(item.get("roleName"), "Role", item.get("roleId"))})
247
261
  for item in _scope_items(raw, "role")
248
262
  if _positive_int(item.get("roleId")) is not None
249
263
  ]
@@ -281,11 +295,12 @@ def _canonical_scope_relation(relation: Any) -> JSONObject | None:
281
295
  return None
282
296
  displayed = relation.get("displayedQueInfo") if isinstance(relation.get("displayedQueInfo"), dict) else {}
283
297
  displayed_id = _positive_int(relation.get("displayedQueId") or displayed.get("queId"))
284
- if displayed_id is None or not displayed.get("appKey"):
298
+ target_app_key = str(displayed.get("appKey") or "")
299
+ if displayed_id is None or not target_app_key.strip():
285
300
  return None
286
301
  result: JSONObject = {
287
302
  "displayedField": _selector(displayed_id, displayed.get("queTitle")),
288
- "targetAppKey": displayed.get("appKey"),
303
+ "targetAppKey": target_app_key,
289
304
  "targetAppTitle": displayed.get("appTitle"),
290
305
  "displayedFieldType": displayed.get("queType"),
291
306
  "matchRuleType": relation.get("matchRuleType"),
@@ -334,6 +349,22 @@ def _selector(que_id: Any, name: Any) -> JSONObject:
334
349
  return result
335
350
 
336
351
 
352
+ def _compat_label(value: Any, kind: str, identity: Any) -> str:
353
+ """Give legacy records with an empty display label a stable, non-empty name."""
354
+ label = str(value or "")
355
+ if label.strip():
356
+ return label
357
+ normalized_identity = str(identity or "").strip()
358
+ return f"{kind} {normalized_identity}" if normalized_identity else f"Unnamed {kind}"
359
+
360
+
361
+ def _required_text(value: Any, *, message: str, details: JSONObject | None = None) -> str:
362
+ text = str(value or "")
363
+ if not text.strip():
364
+ raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", message, "NORMALIZE", details=details)
365
+ return text
366
+
367
+
337
368
  def _require_selector(que_id: Any, name: Any, *, message: str, details: JSONObject | None = None) -> JSONObject:
338
369
  result = _selector(que_id, name)
339
370
  if not result:
@@ -620,21 +651,40 @@ class AppFormNormalizer:
620
651
  section_id = _positive_int(section.get("queId")) or _positive_int(section.get("sectionId"))
621
652
  if section_id is None:
622
653
  raise AppFormFailure("UNSUPPORTED_LAYOUT", "existing section has no stable ID", "NORMALIZE")
623
- rows = [
624
- {"fields": [self._field(question, relations, select_scope_relations, field_names, supports_reference_default_formula) for question in inner if isinstance(question, dict)]}
625
- for inner in section.get("innerQuestions") or []
626
- if isinstance(inner, list) and inner
627
- ]
654
+ rows = []
655
+ for inner in section.get("innerQuestions") or []:
656
+ if not isinstance(inner, list) or not inner:
657
+ continue
658
+ fields = [
659
+ self._field(question, relations, select_scope_relations, field_names, supports_reference_default_formula)
660
+ for question in inner
661
+ if isinstance(question, dict)
662
+ ]
663
+ if fields:
664
+ rows.append({"fields": fields})
628
665
  if not rows:
629
666
  raise AppFormFailure("UNSUPPORTED_LAYOUT", "existing section is empty", "NORMALIZE")
630
- body.append({"kind": "section", "sectionId": str(section_id), "title": str(section.get("queTitle") or ""), "rows": rows})
667
+ body.append(
668
+ {
669
+ "kind": "section",
670
+ "sectionId": str(section_id),
671
+ "title": _compat_label(section.get("queTitle"), "Section", section_id),
672
+ "rows": rows,
673
+ }
674
+ )
631
675
  else:
632
- body.append({"kind": "row", "fields": [self._field(question, relations, select_scope_relations, field_names, supports_reference_default_formula) for question in row if isinstance(question, dict)]})
676
+ fields = [
677
+ self._field(question, relations, select_scope_relations, field_names, supports_reference_default_formula)
678
+ for question in row
679
+ if isinstance(question, dict)
680
+ ]
681
+ if fields:
682
+ body.append({"kind": "row", "fields": fields})
633
683
  resource = {
634
684
  "apiVersion": context.api_version,
635
685
  "appKey": app_key,
636
686
  "spec": {
637
- "appName": str(form.get("formTitle") or ""),
687
+ "appName": _compat_label(form.get("formTitle"), "App", app_key),
638
688
  "appDescription": form.get("formDesc") or None,
639
689
  "dataTitleField": _selector(title_id, field_names.get(title_id or 0)) if title_id is not None else None,
640
690
  "dataCoverField": _selector(cover_id, field_names.get(cover_id or 0)) if cover_id is not None else None,
@@ -676,18 +726,27 @@ class AppFormNormalizer:
676
726
  details={"queId": que_id, "queType": que_type},
677
727
  )
678
728
  field: JSONObject = {
679
- "name": str(question.get("queTitle") or ""),
729
+ "name": _compat_label(question.get("queTitle"), "Field", que_id),
680
730
  "questionType": field_type,
681
731
  "beingRequired": bool(question.get("required")),
682
732
  "questionDescription": question.get("queHint") or None,
683
733
  }
684
734
  default_type = _positive_int(question.get("queDefaultType"))
685
- if default_type is not None and (
735
+ raw_default_value = question.get("queDefaultValue")
736
+ decoded_default_value = (
737
+ _decode_formula_default(raw_default_value)
738
+ if isinstance(raw_default_value, (str, int, float, bool))
739
+ else raw_default_value
740
+ )
741
+ empty_formula_default = default_type == 3 and (
742
+ decoded_default_value is None
743
+ or (isinstance(decoded_default_value, str) and not decoded_default_value.strip())
744
+ )
745
+ if default_type is not None and not empty_formula_default and (
686
746
  question.get("queDefaultValue") is not None
687
747
  or (default_type != 1 and field_type != "relation")
688
748
  ):
689
749
  field["defaultType"] = default_type
690
- raw_default_value = question.get("queDefaultValue")
691
750
  if isinstance(raw_default_value, (dict, list)) and field_type not in {"address", "attachment", "relation", "subtable"}:
692
751
  raise AppFormFailure(
693
752
  "UNSUPPORTED_FIELD_CONFIG",
@@ -695,9 +754,14 @@ class AppFormNormalizer:
695
754
  "NORMALIZE",
696
755
  details={"queId": que_id, "type": field_type},
697
756
  )
698
- if "queDefaultValue" in question and raw_default_value is not None and not isinstance(raw_default_value, (dict, list)):
699
- default_value = question.get("queDefaultValue")
700
- field["defaultValue"] = _decode_formula_default(default_value)
757
+ if (
758
+ "queDefaultValue" in question
759
+ and raw_default_value is not None
760
+ and not isinstance(raw_default_value, (dict, list))
761
+ and not empty_formula_default
762
+ and not (default_type == 2 and isinstance(decoded_default_value, str) and not decoded_default_value.strip())
763
+ ):
764
+ field["defaultValue"] = decoded_default_value
701
765
  if que_id is not None:
702
766
  field["queId"] = que_id
703
767
  width = _positive_int(question.get("queWidth"))
@@ -735,9 +799,20 @@ class AppFormNormalizer:
735
799
  if config:
736
800
  field["config"] = config
737
801
  elif field_type in CHOICE_TYPES:
738
- options = [str(item.get("optValue")) for item in question.get("options") or [] if isinstance(item, dict) and item.get("optValue") is not None]
802
+ options = [
803
+ str(item.get("optValue"))
804
+ for item in question.get("options") or []
805
+ if isinstance(item, dict) and item.get("optValue") is not None and str(item.get("optValue")).strip()
806
+ ]
739
807
  if not options and field_type == "single_choice":
740
808
  options = ["是", "否"]
809
+ if not options:
810
+ raise AppFormFailure(
811
+ "UNSUPPORTED_FIELD_CONFIG",
812
+ "choice field has no options",
813
+ "NORMALIZE",
814
+ details={"queId": que_id, "type": field_type, "path": "/config/options"},
815
+ )
741
816
  field["config"] = _without_none({
742
817
  "options": options,
743
818
  "minOpts": _canonical_unlimited(question.get("minOpts")),
@@ -745,7 +820,14 @@ class AppFormNormalizer:
745
820
  "optDirection": question.get("optDirection") if question.get("optDirection") not in {None, 0} else None,
746
821
  "optSelectMode": question.get("optSelectMode"),
747
822
  "beingShowColor": question.get("beingShowColor"),
748
- "defaultOptions": [str(item.get("optValue")) for item in question.get("options") or [] if isinstance(item, dict) and item.get("beingDefault") is True],
823
+ "defaultOptions": [
824
+ str(item.get("optValue"))
825
+ for item in question.get("options") or []
826
+ if isinstance(item, dict)
827
+ and item.get("beingDefault") is True
828
+ and item.get("optValue") is not None
829
+ and str(item.get("optValue")).strip()
830
+ ],
749
831
  })
750
832
  if not field["config"].get("defaultOptions"):
751
833
  field["config"].pop("defaultOptions", None)
@@ -766,7 +848,12 @@ class AppFormNormalizer:
766
848
  scope = question.get("deptSelectScope") if isinstance(question.get("deptSelectScope"), dict) else {}
767
849
  departments = scope.get("depart") if isinstance(scope.get("depart"), list) else []
768
850
  canonical_departments = [
769
- _without_none({"deptId": _positive_int(item.get("deptId") or item.get("id")), "deptName": item.get("deptName") or item.get("name")})
851
+ _without_none(
852
+ {
853
+ "deptId": _positive_int(item.get("deptId") or item.get("id")),
854
+ "deptName": _compat_label(item.get("deptName") or item.get("name"), "Department", item.get("deptId") or item.get("id")),
855
+ }
856
+ )
770
857
  for item in departments
771
858
  if isinstance(item, dict) and _positive_int(item.get("deptId") or item.get("id")) is not None
772
859
  ]
@@ -791,7 +878,10 @@ class AppFormNormalizer:
791
878
  else:
792
879
  field["config"] = {"departmentScope": {"mode": "all", "includeSubDeparts": bool(scope.get("includeSubDeparts"))}}
793
880
  if question.get("deptDftValue") is not None:
794
- field["config"]["defaultDepartments"] = _canonical_member_scope(question.get("deptDftValue"), 2, department=True)
881
+ default_scope = _canonical_member_scope(question.get("deptDftValue"), 2, department=True)
882
+ # Older backend responses may include an empty default-value container when no default department is set.
883
+ if default_scope.get("departments"):
884
+ field["config"]["defaultDepartments"] = default_scope
795
885
  relation = next((item for item in select_scope_relations if _positive_int(item.get("queId")) == que_id), None)
796
886
  scope_relation = _canonical_scope_relation(relation)
797
887
  if scope_relation is not None:
@@ -800,9 +890,14 @@ class AppFormNormalizer:
800
890
  if question.get(key) is not None:
801
891
  field["config"][key] = question[key]
802
892
  elif field_type == "attachment":
893
+ file_types = question.get("fileType")
894
+ if isinstance(file_types, list):
895
+ file_types = [item for item in file_types if str(item or "").strip()]
896
+ elif isinstance(file_types, str) and not file_types.strip():
897
+ file_types = None
803
898
  field["config"] = _without_none({
804
899
  "fileSize": question.get("fileSize"),
805
- "fileType": question.get("fileType"),
900
+ "fileType": file_types,
806
901
  "fileBeingCaptureOnly": question.get("fileBeingCaptureOnly"),
807
902
  "imageCompress": question.get("imageCompress"),
808
903
  "pluginStatus": question.get("pluginStatus"),
@@ -826,14 +921,22 @@ class AppFormNormalizer:
826
921
  display_id = _positive_int(reference.get("referQueId"))
827
922
  display = next((item for item in refer_questions if _positive_int(item.get("queId")) == display_id), None)
828
923
  visible = [item for item in refer_questions if _positive_int(item.get("queAuth")) != 2]
924
+ target_app_key = str(reference.get("referAppKey") or "")
925
+ if not target_app_key.strip():
926
+ raise AppFormFailure(
927
+ "UNSUPPORTED_FIELD_CONFIG",
928
+ "relation target app cannot be normalized",
929
+ "NORMALIZE",
930
+ details={"queId": que_id, "path": "/config/targetAppKey"},
931
+ )
829
932
  relation_config: JSONObject = {
830
- "targetAppKey": reference.get("referAppKey"),
933
+ "targetAppKey": target_app_key,
831
934
  "relationMode": "multiple" if _positive_int(reference.get("optionalDataNum")) not in {None, 1} else "single",
832
935
  "displayField": _require_selector(
833
936
  display_id,
834
937
  (display or {}).get("queTitle"),
835
938
  message="relation display field cannot be normalized",
836
- details={"queId": que_id},
939
+ details={"queId": que_id, "path": "/config/displayField"},
837
940
  ),
838
941
  "visibleFields": [
839
942
  _require_selector(
@@ -849,7 +952,8 @@ class AppFormNormalizer:
849
952
  "defaultValues": question.get("queDefaultValues") if isinstance(question.get("queDefaultValues"), dict) else None,
850
953
  }
851
954
  if supports_reference_default_formula:
852
- relation_config["defaultValueFormula"] = reference.get("defaultValueFormula")
955
+ formula = reference.get("defaultValueFormula")
956
+ relation_config["defaultValueFormula"] = formula if not isinstance(formula, str) or formula.strip() else None
853
957
  field["config"] = _without_none(relation_config)
854
958
  elif field_type == "q_linker":
855
959
  field["config"] = {"qLinkerBinding": self._q_linker_binding(question, relations, field_names)}
@@ -858,34 +962,54 @@ class AppFormNormalizer:
858
962
  elif field_type == "data_relation":
859
963
  field["config"] = self._data_relation_config(question, relations)
860
964
  elif field_type == "subtable":
861
- field["subfields"] = [
965
+ subfields = [
862
966
  self._field(item, relations, select_scope_relations, field_names, supports_reference_default_formula)
863
967
  for item in question.get("subQuestions") or []
864
968
  if isinstance(item, dict)
865
969
  ]
970
+ if not subfields:
971
+ raise AppFormFailure(
972
+ "UNSUPPORTED_FIELD_CONFIG",
973
+ "subtable has no subfields",
974
+ "NORMALIZE",
975
+ details={"queId": que_id, "path": "/subfields"},
976
+ )
977
+ field["subfields"] = subfields
866
978
  for subfield in field["subfields"]:
867
979
  subfield.pop("width", None)
980
+ row_height = question.get("tableRowHeight")
981
+ if isinstance(row_height, str) and not row_height.strip():
982
+ row_height = None
868
983
  field["config"] = _without_none({
869
984
  "tableRowsLimit": question.get("tableRowsLimit"),
870
985
  "tableNoRepeatStatus": question.get("tableNoRepeatStatus"),
871
986
  "tableNoRepeatQuestion": [_selector(item, field_names.get(_positive_int(item))) for item in question.get("tableNoRepeatQuestion") or [] if _positive_int(item) is not None],
872
- "tableRowHeight": question.get("tableRowHeight"),
987
+ "tableRowHeight": row_height,
873
988
  "defaultValues": question.get("queDefaultValues") if isinstance(question.get("queDefaultValues"), dict) and (question.get("queDefaultValues", {}).get("values") or question.get("queDefaultValues", {}).get("tableValues")) else None,
874
989
  })
875
990
  return field
876
991
 
877
992
  @staticmethod
878
993
  def _attachments(value: Any) -> list[JSONObject]:
994
+ def attachment_text(item: JSONObject, *keys: str) -> Any:
995
+ for key in keys:
996
+ candidate = item.get(key)
997
+ if candidate:
998
+ return candidate
999
+ return None
1000
+
879
1001
  result = [
880
1002
  _without_none(
881
1003
  {
882
1004
  "attachId": _positive_int(item.get("attachId") or item.get("id")),
883
- "fileName": item.get("fileName") or item.get("name"),
884
- "fileUrl": item.get("fileUrl") or item.get("url"),
1005
+ "fileName": attachment_text(item, "fileName", "name"),
1006
+ "fileUrl": attachment_text(item, "fileUrl", "url"),
885
1007
  }
886
1008
  )
887
1009
  for item in value or []
888
- if isinstance(item, dict) and (item.get("fileName") or item.get("name")) and (item.get("fileUrl") or item.get("url"))
1010
+ if isinstance(item, dict)
1011
+ and str(attachment_text(item, "fileName", "name") or "").strip()
1012
+ and str(attachment_text(item, "fileUrl", "url") or "").strip()
889
1013
  ]
890
1014
  return sorted(result, key=lambda item: (item.get("attachId", 0), item["fileName"], item["fileUrl"]))
891
1015
 
@@ -907,12 +1031,28 @@ class AppFormNormalizer:
907
1031
  elif kind == "submit_datetime":
908
1032
  component["dateFormat"] = date_names.get(item.get("dateFormat"), "year_month_day")
909
1033
  elif kind == "fixed_text":
910
- component["value"] = str(item.get("customTitle") or "")
1034
+ component["value"] = _required_text(
1035
+ item.get("customTitle"),
1036
+ message="custom serial number fixed text is empty",
1037
+ details={"componentId": _positive_int(item.get("id")), "path": "/spec/serialNumber/components/value"},
1038
+ )
911
1039
  else:
912
- component["field"] = _selector(item.get("queId"), item.get("queTitle"))
1040
+ component["field"] = _require_selector(
1041
+ item.get("queId"),
1042
+ item.get("queTitle"),
1043
+ message="custom serial number field cannot be normalized",
1044
+ details={"componentId": _positive_int(item.get("id")), "path": "/spec/serialNumber/components/field"},
1045
+ )
913
1046
  if item.get("dateFormat") is not None:
914
1047
  component["dateFormat"] = date_names.get(item.get("dateFormat"), "year_month_day")
915
1048
  components.append(component)
1049
+ if not components:
1050
+ raise AppFormFailure(
1051
+ "UNSUPPORTED_FIELD_CONFIG",
1052
+ "custom serial number has no components",
1053
+ "NORMALIZE",
1054
+ details={"path": "/spec/serialNumber/components"},
1055
+ )
916
1056
  return {"mode": "custom", "components": components}
917
1057
 
918
1058
  @staticmethod
@@ -926,9 +1066,11 @@ class AppFormNormalizer:
926
1066
  for inner in item.get("innerQuestions") or []:
927
1067
  for nested in inner if isinstance(inner, list) else []:
928
1068
  if isinstance(nested, dict) and _positive_int(nested.get("queId")):
929
- result[_positive_int(nested.get("queId")) or 0] = str(nested.get("queTitle") or "")
1069
+ nested_id = _positive_int(nested.get("queId")) or 0
1070
+ result[nested_id] = _compat_label(nested.get("queTitle"), "Field", nested_id)
930
1071
  elif _positive_int(item.get("queId")):
931
- result[_positive_int(item.get("queId")) or 0] = str(item.get("queTitle") or "")
1072
+ item_id = _positive_int(item.get("queId")) or 0
1073
+ result[item_id] = _compat_label(item.get("queTitle"), "Field", item_id)
932
1074
  return result
933
1075
 
934
1076
  @staticmethod
@@ -939,7 +1081,16 @@ class AppFormNormalizer:
939
1081
  for alias in config.get("resultFormatPath") or []:
940
1082
  if not isinstance(alias, dict):
941
1083
  continue
942
- name = str(alias.get("aliasName") or "")
1084
+ name = _required_text(
1085
+ alias.get("aliasName"),
1086
+ message="Q-Linker output alias is empty",
1087
+ details={"queId": source_id, "path": "/config/qLinkerBinding/outputs/alias"},
1088
+ )
1089
+ path = _required_text(
1090
+ alias.get("aliasPath"),
1091
+ message="Q-Linker output path is empty",
1092
+ details={"queId": source_id, "alias": name, "path": "/config/qLinkerBinding/outputs/path"},
1093
+ )
943
1094
  relation = next(
944
1095
  (
945
1096
  item
@@ -953,7 +1104,7 @@ class AppFormNormalizer:
953
1104
  _without_none(
954
1105
  {
955
1106
  "alias": name,
956
- "path": str(alias.get("aliasPath") or ""),
1107
+ "path": path,
957
1108
  "aliasId": _positive_int(alias.get("aliasId")) or _positive_int((relation.get("aliasConfig") or {}).get("aliasId")),
958
1109
  "targetField": _require_selector(
959
1110
  relation.get("queId"),
@@ -973,15 +1124,30 @@ class AppFormNormalizer:
973
1124
  continue
974
1125
  if _positive_int(item.get("type")) == 2:
975
1126
  input_que_id = _positive_int(item.get("value"))
1127
+ input_key = _required_text(
1128
+ item.get("key"),
1129
+ message="Q-Linker input key is empty",
1130
+ details={"queId": source_id, "source": source, "path": "/config/qLinkerBinding/inputs/key"},
1131
+ )
976
1132
  inputs.append(
977
1133
  {
978
- "field": _selector(input_que_id, field_names.get(input_que_id or -1)),
979
- "key": str(item.get("key") or ""),
1134
+ "field": _require_selector(
1135
+ input_que_id,
1136
+ field_names.get(input_que_id or -1),
1137
+ message="Q-Linker input field cannot be normalized",
1138
+ details={"queId": source_id, "key": input_key},
1139
+ ),
1140
+ "key": input_key,
980
1141
  "source": source,
981
1142
  }
982
1143
  )
983
1144
  else:
984
- static[key].append({"key": str(item.get("key") or ""), "value": item.get("value")})
1145
+ static_key = _required_text(
1146
+ item.get("key"),
1147
+ message="Q-Linker request key is empty",
1148
+ details={"queId": source_id, "source": source, "path": f"/config/qLinkerBinding/request/{source}/key"},
1149
+ )
1150
+ static[key].append({"key": static_key, "value": item.get("value")})
985
1151
  request_values = {
986
1152
  "url": config.get("url"),
987
1153
  "jsonValue": config.get("jsonValue"),
@@ -1013,9 +1179,14 @@ class AppFormNormalizer:
1013
1179
 
1014
1180
  request_values[request_key] = Q_LINKER_VALUE_REFERENCE_RE.sub(replace_reference, raw_value)
1015
1181
  inputs.extend(json_path_inputs.values())
1182
+ request_url = _required_text(
1183
+ request_values["url"],
1184
+ message="Q-Linker request URL is empty",
1185
+ details={"queId": source_id, "path": "/config/qLinkerBinding/request/url"},
1186
+ )
1016
1187
  request = _without_none(
1017
1188
  {
1018
- "url": request_values["url"],
1189
+ "url": request_url,
1019
1190
  "method": config.get("method") or "GET",
1020
1191
  **static,
1021
1192
  "bodyType": {1: "url_encoded", 2: "json", 3: "xml"}.get(_positive_int(config.get("bodyType")) or 1, "url_encoded"),
@@ -1029,6 +1200,13 @@ class AppFormNormalizer:
1029
1200
  "hideOnForm": bool(config.get("beingHideOnForm")),
1030
1201
  }
1031
1202
  )
1203
+ if not outputs:
1204
+ raise AppFormFailure(
1205
+ "UNSUPPORTED_FIELD_CONFIG",
1206
+ "Q-Linker has no output mapping",
1207
+ "NORMALIZE",
1208
+ details={"queId": source_id, "path": "/config/qLinkerBinding/outputs"},
1209
+ )
1032
1210
  return {"inputs": inputs, "request": request, "outputs": outputs}
1033
1211
 
1034
1212
  @staticmethod
@@ -1039,7 +1217,16 @@ class AppFormNormalizer:
1039
1217
  for alias in config.get("resultAliasPath") or []:
1040
1218
  if not isinstance(alias, dict):
1041
1219
  continue
1042
- name = str(alias.get("aliasName") or "")
1220
+ name = _required_text(
1221
+ alias.get("aliasName"),
1222
+ message="code block output alias is empty",
1223
+ details={"queId": source_id, "path": "/config/codeBlockBinding/outputs/alias"},
1224
+ )
1225
+ path = _required_text(
1226
+ alias.get("aliasPath"),
1227
+ message="code block output path is empty",
1228
+ details={"queId": source_id, "alias": name, "path": "/config/codeBlockBinding/outputs/path"},
1229
+ )
1043
1230
  relation = next(
1044
1231
  (
1045
1232
  item
@@ -1051,7 +1238,7 @@ class AppFormNormalizer:
1051
1238
  )
1052
1239
  output = {
1053
1240
  "alias": name,
1054
- "path": str(alias.get("aliasPath") or ""),
1241
+ "path": path,
1055
1242
  "targetField": _require_selector(
1056
1243
  relation.get("queId"),
1057
1244
  relation.get("queTitle") or field_names.get(_positive_int(relation.get("queId")) or -1),
@@ -1069,11 +1256,29 @@ class AppFormNormalizer:
1069
1256
  line = lines.pop(0).strip()
1070
1257
  var = line[6:].split("=", 1)[0].strip()
1071
1258
  name = line.split("qf_field.{", 1)[1].split("$$", 1)[0]
1072
- inputs.append({"field": {"name": name}, "var": var})
1259
+ input_name = _required_text(
1260
+ name,
1261
+ message="code block input field is empty",
1262
+ details={"queId": source_id, "path": "/config/codeBlockBinding/inputs/field"},
1263
+ )
1264
+ inputs.append(
1265
+ {
1266
+ "field": {"name": input_name},
1267
+ "var": var,
1268
+ }
1269
+ )
1270
+ code = "\n".join(lines).lstrip("\n")
1271
+ if not code.strip():
1272
+ raise AppFormFailure(
1273
+ "UNSUPPORTED_FIELD_CONFIG",
1274
+ "code block has no code",
1275
+ "NORMALIZE",
1276
+ details={"queId": source_id, "path": "/config/codeBlockBinding/code"},
1277
+ )
1073
1278
  return _without_none(
1074
1279
  {
1075
1280
  "inputs": inputs,
1076
- "code": "\n".join(lines).lstrip("\n"),
1281
+ "code": code,
1077
1282
  "autoTrigger": question.get("autoTrigger"),
1078
1283
  "customButtonTextEnabled": question.get("customBtnTextStatus"),
1079
1284
  "customButtonText": question.get("customBtnText"),
@@ -1092,7 +1297,8 @@ class AppFormNormalizer:
1092
1297
  displayed = relation.get("displayedQueInfo") if isinstance(relation.get("displayedQueInfo"), dict) else {}
1093
1298
  alias = relation.get("aliasConfig") if isinstance(relation.get("aliasConfig"), dict) else {}
1094
1299
  if relation_type == 1:
1095
- if not str(displayed.get("appKey") or "").strip():
1300
+ source_app_key = str(displayed.get("appKey") or "")
1301
+ if not source_app_key.strip():
1096
1302
  raise AppFormFailure(
1097
1303
  "UNSUPPORTED_FIELD_CONFIG",
1098
1304
  "data_relation source app cannot be normalized",
@@ -1101,7 +1307,7 @@ class AppFormNormalizer:
1101
1307
  )
1102
1308
  source = {
1103
1309
  "kind": "app",
1104
- "appKey": displayed.get("appKey"),
1310
+ "appKey": source_app_key,
1105
1311
  "field": _require_selector(
1106
1312
  relation.get("displayedQueId"),
1107
1313
  displayed.get("queTitle"),
@@ -1143,7 +1349,9 @@ class AppFormNormalizer:
1143
1349
  for group in relation.get("matchRules") or []:
1144
1350
  if not isinstance(group, list):
1145
1351
  raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "data_relation match rules are malformed", "NORMALIZE", details={"queId": que_id})
1146
- groups.append([AppFormNormalizer._data_relation_condition(item, que_id=que_id) for item in group if isinstance(item, dict)])
1352
+ conditions = [AppFormNormalizer._data_relation_condition(item, que_id=que_id) for item in group if isinstance(item, dict)]
1353
+ if conditions:
1354
+ groups.append(conditions)
1147
1355
  result["filters"] = {"mode": "simple", "groups": groups}
1148
1356
  sort = relation.get("sortConfig") if isinstance(relation.get("sortConfig"), dict) else None
1149
1357
  if sort is not None:
@@ -2173,6 +2381,11 @@ class AppFormCompiler:
2173
2381
  "queHint": field.get("questionDescription") or "",
2174
2382
  }
2175
2383
  )
2384
+ if _positive_int((template or {}).get("queType")) == 1 and field_type == "long_text":
2385
+ # queType=1 is a non-data description control. Keep its backend
2386
+ # type and template-only settings while allowing the declaration
2387
+ # to carry its stable identity and default content.
2388
+ question["queType"] = 1
2176
2389
  config = field.get("config") if isinstance(field.get("config"), dict) else {}
2177
2390
  if field_type in {"text", "long_text", "link", "phone", "email"}:
2178
2391
  for key in ("minWordCount", "maxWordCount", "noRepeat", "canScan", "scanType"):
@@ -2,9 +2,15 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  from copy import deepcopy
5
+ from pathlib import Path
5
6
 
6
7
  from ..context import CliContext
7
- from ...app_form_validator import AppFormValidationError, validate_declaration
8
+ from ...app_form_validator import (
9
+ AppFormValidationError,
10
+ validate_code_block_static,
11
+ validate_declaration,
12
+ validate_formula_expression,
13
+ )
8
14
  from ..json_io import load_json_value
9
15
  from .common import load_list_arg, load_object_arg, parse_bool_text, raise_config_error, require_list_arg
10
16
 
@@ -123,6 +129,41 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
123
129
  app_form_validate.add_argument("--schema-version", help="固定使用的 AppForm Schema 版本")
124
130
  app_form_validate.set_defaults(handler=_handle_app_form_validate, format_hint="builder_summary", force_json_output=True)
125
131
 
132
+ app_form_validate_formula = app_form_subparsers.add_parser(
133
+ "validate-formula", help="校验公式表达式(本地语法 + 后端求值)"
134
+ )
135
+ app_form_validate_formula.add_argument("--app-key", required=True, help="目标应用 key")
136
+ app_form_validate_formula.add_argument("--formula", required=True, help="公式文本")
137
+ app_form_validate_formula.add_argument(
138
+ "--test-case",
139
+ required=True,
140
+ help="测试用例 JSON 文件路径,- 表示 stdin;格式 {queId: value}",
141
+ )
142
+ app_form_validate_formula.add_argument(
143
+ "--fields",
144
+ required=False,
145
+ help="字段清单 JSON 文件路径(用于本地字段引用校验);缺省时从后端拉取",
146
+ )
147
+ app_form_validate_formula.set_defaults(
148
+ handler=_handle_app_form_validate_formula, format_hint="builder_summary", force_json_output=True
149
+ )
150
+
151
+ app_form_validate_code_block = app_form_subparsers.add_parser(
152
+ "validate-code-block", help="校验代码块(本地静态 + 后端真跑)"
153
+ )
154
+ app_form_validate_code_block.add_argument("--app-key", required=True, help="目标应用 key")
155
+ app_form_validate_code_block.add_argument(
156
+ "--code", required=True, help="代码文本(或 @文件路径读取)"
157
+ )
158
+ app_form_validate_code_block.add_argument(
159
+ "--test-case",
160
+ required=True,
161
+ help="测试用例 JSON 文件路径,- 表示 stdin;格式 [{keyQueId, ordinal?, values}]",
162
+ )
163
+ app_form_validate_code_block.set_defaults(
164
+ handler=_handle_app_form_validate_code_block, format_hint="builder_summary", force_json_output=True
165
+ )
166
+
126
167
  app = builder_subparsers.add_parser("app", help="应用")
127
168
  app_subparsers = app.add_subparsers(dest="builder_app_command", required=True)
128
169
 
@@ -390,6 +431,168 @@ def _handle_app_form_validate(args: argparse.Namespace, _context: CliContext) ->
390
431
  return {"category": "validation", "status": "success", "ok": True, **result, "message": "AppForm declaration is valid"}
391
432
 
392
433
 
434
+ def _handle_app_form_validate_formula(args: argparse.Namespace, context: CliContext) -> dict:
435
+ formula = args.formula
436
+ test_case = load_json_value(
437
+ args.test_case, option_name="--test-case", max_bytes=1 * 1024 * 1024, max_depth=32
438
+ )
439
+ if not isinstance(test_case, dict):
440
+ raise_config_error(
441
+ "--test-case must be a JSON object {queId: value}", error_code="INVALID_TEST_CASE"
442
+ )
443
+
444
+ # ① 本地语法校验(函数名 + 字段引用存在性)
445
+ fields = _load_fields_for_validation(args, context)
446
+ syntax_errors = validate_formula_expression(formula, fields)
447
+
448
+ # ② 后端求值校验(语法有错时不浪费后端调用)
449
+ evaluation = None
450
+ evaluation_error = None
451
+ if not syntax_errors:
452
+ result = context.builder.app_form_evaluate_formula(
453
+ profile=args.profile, app_key=args.app_key, formula=formula, test_case=test_case
454
+ )
455
+ evaluation = result.get("evaluation")
456
+ evaluation_error = result.get("evaluationError")
457
+
458
+ ok = bool(
459
+ not syntax_errors
460
+ and isinstance(evaluation, dict)
461
+ and evaluation.get("valid") is True
462
+ )
463
+ return {
464
+ "category": "validation",
465
+ "status": "success" if ok else "failed",
466
+ "ok": ok,
467
+ "syntaxErrors": syntax_errors,
468
+ "evaluation": evaluation,
469
+ "evaluationError": evaluation_error,
470
+ "message": "formula is valid" if ok else "formula validation failed",
471
+ }
472
+
473
+
474
+ def _handle_app_form_validate_code_block(args: argparse.Namespace, context: CliContext) -> dict:
475
+ code = _read_code_arg(args.code)
476
+ test_case = load_json_value(
477
+ args.test_case, option_name="--test-case", max_bytes=1 * 1024 * 1024, max_depth=32
478
+ )
479
+ if not isinstance(test_case, list):
480
+ raise_config_error(
481
+ "--test-case must be a JSON array [{keyQueId, ordinal?, values}]",
482
+ error_code="INVALID_TEST_CASE",
483
+ )
484
+
485
+ # ① 本地静态校验(qf_output 赋值 + 基本语法)
486
+ static_errors = validate_code_block_static(code)
487
+
488
+ # ② 后端真跑校验(静态有错时不浪费后端调用)
489
+ key_que_values = _normalize_code_block_test_case(test_case)
490
+ run_result = None
491
+ run_error = None
492
+ if not static_errors:
493
+ result = context.builder.app_form_run_code_block(
494
+ profile=args.profile, app_key=args.app_key, code=code, key_que_values=key_que_values
495
+ )
496
+ run_result = result.get("runResult")
497
+ run_error = result.get("runError")
498
+
499
+ ok = not static_errors and run_result is not None
500
+ return {
501
+ "category": "validation",
502
+ "status": "success" if ok else "failed",
503
+ "ok": ok,
504
+ "staticErrors": static_errors,
505
+ "runResult": run_result,
506
+ "runError": run_error,
507
+ "message": "code block is valid" if ok else "code block validation failed",
508
+ }
509
+
510
+
511
+ def _load_fields_for_validation(args: argparse.Namespace, context: CliContext) -> list[dict] | None:
512
+ """加载字段清单:优先 --fields 参数,缺省时从后端拉取应用字段。
513
+
514
+ 后端拉取失败时返回 None(跳过字段引用校验,不阻断流程)。
515
+ """
516
+ fields_path = getattr(args, "fields", None)
517
+ if fields_path:
518
+ fields = load_json_value(
519
+ fields_path, option_name="--fields", max_bytes=2 * 1024 * 1024, max_depth=64
520
+ )
521
+ if not isinstance(fields, list):
522
+ raise_config_error(
523
+ "--fields must be a JSON array of field objects", error_code="INVALID_FIELDS"
524
+ )
525
+ return fields
526
+ fetched = context.builder.app_get_fields(profile=args.profile, app_key=args.app_key)
527
+ if not isinstance(fetched, dict) or fetched.get("status") != "success":
528
+ return None
529
+ raw_fields = fetched.get("fields")
530
+ if not isinstance(raw_fields, list):
531
+ return None
532
+ return [
533
+ {"queId": field.get("que_id"), "name": field.get("name")}
534
+ for field in raw_fields
535
+ if isinstance(field, dict)
536
+ ]
537
+
538
+
539
+ def _read_code_arg(code_arg: str) -> str:
540
+ """读取 --code 参数:@ 前缀读文件内容,否则当文本。"""
541
+ if not isinstance(code_arg, str) or not code_arg:
542
+ raise_config_error("--code is required", error_code="INVALID_CODE")
543
+ if code_arg.startswith("@"):
544
+ path = code_arg[1:]
545
+ try:
546
+ return Path(path).expanduser().read_text(encoding="utf-8")
547
+ except OSError as exc:
548
+ raise_config_error(
549
+ f"failed to read --code from '{path}': {exc}", error_code="INVALID_CODE"
550
+ )
551
+ return code_arg
552
+
553
+
554
+ def _normalize_code_block_test_case(test_case: list) -> list[dict]:
555
+ """将 --test-case 规范化为后端 OpenMatchQueValue 结构 [{keyQueId, ordinal?, values}]。"""
556
+ normalized: list[dict] = []
557
+ for item in test_case:
558
+ if not isinstance(item, dict):
559
+ raise_config_error(
560
+ "--test-case items must be JSON objects {keyQueId, ordinal?, values}",
561
+ error_code="INVALID_TEST_CASE",
562
+ )
563
+ raw_que_id = item.get("keyQueId", item.get("key_que_id"))
564
+ if raw_que_id is None:
565
+ raise_config_error(
566
+ "--test-case items require keyQueId", error_code="INVALID_TEST_CASE"
567
+ )
568
+ try:
569
+ key_que_id = int(raw_que_id)
570
+ except (TypeError, ValueError):
571
+ raise_config_error(
572
+ f"--test-case keyQueId must be an integer, got {raw_que_id!r}",
573
+ error_code="INVALID_TEST_CASE",
574
+ )
575
+ entry: dict = {"keyQueId": key_que_id}
576
+ if item.get("ordinal") is not None:
577
+ try:
578
+ entry["ordinal"] = int(item.get("ordinal"))
579
+ except (TypeError, ValueError):
580
+ raise_config_error(
581
+ f"--test-case ordinal must be an integer, got {item.get('ordinal')!r}",
582
+ error_code="INVALID_TEST_CASE",
583
+ )
584
+ values = item.get("values", [])
585
+ if not isinstance(values, list):
586
+ raise_config_error(
587
+ "--test-case item values must be a JSON array", error_code="INVALID_TEST_CASE"
588
+ )
589
+ entry["values"] = [str(value) for value in values]
590
+ if item.get("tableValues") is not None:
591
+ entry["tableValues"] = item.get("tableValues")
592
+ normalized.append(entry)
593
+ return normalized
594
+
595
+
393
596
  def _handle_app_delete(args: argparse.Namespace, context: CliContext) -> dict:
394
597
  return context.builder.app_delete(profile=args.profile, app_key=args.app_key)
395
598
 
@@ -144,6 +144,8 @@ BUILDER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
144
144
  PublicToolSpec(BUILDER_DOMAIN, "app_resolve", ("app_resolve",), ("builder", "app", "resolve"), has_contract=True, cli_show_effective_context=True),
145
145
  PublicToolSpec(BUILDER_DOMAIN, "app_form_schema", ("app_form_schema",), ("builder", "app-form", "schema")),
146
146
  PublicToolSpec(BUILDER_DOMAIN, "app_form_validate", (), ("builder", "app-form", "validate"), mcp_public=False),
147
+ PublicToolSpec(BUILDER_DOMAIN, "app_form_validate_formula", (), ("builder", "app-form", "validate-formula"), mcp_public=False),
148
+ PublicToolSpec(BUILDER_DOMAIN, "app_form_validate_code_block", (), ("builder", "app-form", "validate-code-block"), mcp_public=False),
147
149
  PublicToolSpec(BUILDER_DOMAIN, "app_form_get", ("app_form_get",), ("builder", "app-form", "get"), cli_show_effective_context=True),
148
150
  PublicToolSpec(BUILDER_DOMAIN, "app_form_apply", ("app_form_apply",), ("builder", "app-form", "apply"), cli_show_effective_context=True, cli_context_write=True),
149
151
  PublicToolSpec(BUILDER_DOMAIN, "app_delete", ("app_delete",), ("builder", "app", "delete"), cli_show_effective_context=True, cli_context_write=True),
@@ -51,6 +51,10 @@ COMMON_ERROR_DROP_TOP = {
51
51
 
52
52
  SUCCESS_POLICY_BY_TOOL: dict[str, TransformFn] = {}
53
53
 
54
+ #: 这两个校验命令的产出契约明确包含 ok 字段(供阶段 2 SKILL.md 引用),
55
+ #: 需在 success 裁剪时保留 ok(默认 COMMON_SUCCESS_DROP_TOP 会丢弃 ok)。
56
+ _KEEP_OK_TOOLS = {"builder:app_form_validate_formula", "builder:app_form_validate_code_block"}
57
+
54
58
 
55
59
  def trim_public_response(tool_name: str | None, payload: dict[str, Any]) -> dict[str, Any]:
56
60
  if not isinstance(payload, dict):
@@ -85,6 +89,8 @@ def trim_success_response(tool_name: str | None, payload: dict[str, Any]) -> dic
85
89
  drop_keys = COMMON_SUCCESS_DROP_TOP - {"output_profile"}
86
90
  if tool_name in {"user:record_insert", "user:record_update", "user:record_delete"} and payload.get("ok") is False:
87
91
  drop_keys = drop_keys - {"ok"}
92
+ if tool_name in _KEEP_OK_TOOLS:
93
+ drop_keys = drop_keys - {"ok"}
88
94
  if tool_name == "builder:app_form_apply" and payload.get("mustReadCurrentStateBeforeRepair") is True:
89
95
  drop_keys = drop_keys - {"suggested_next_call"}
90
96
  _drop_top_keys(trimmed, drop_keys)
@@ -852,6 +852,64 @@ class AiBuilderTools(ToolBase):
852
852
  """Reconcile one complete declarative AppForm target."""
853
853
  return self._app_form.apply(profile=profile, spec=spec)
854
854
 
855
+ def app_form_evaluate_formula(
856
+ self,
857
+ *,
858
+ profile: str,
859
+ app_key: str,
860
+ formula: str,
861
+ test_case: dict[int, Any],
862
+ ) -> JSONObject:
863
+ """调用后端 POST /ai/coding/formula/validate 对公式求值。
864
+
865
+ 本地语法校验由 CLI handler 负责;本方法只做后端求值,捕获后端错误为 evaluationError。
866
+ 鉴权/会话错误(未登录/未选工作区)按既有约定向上抛出 QingflowApiError。
867
+ """
868
+ _session_profile, _backend_session, context = self._require_context(profile, require_workspace=True)
869
+ evaluation_result: Any = None
870
+ evaluation_error: str | None = None
871
+ try:
872
+ evaluation_result = self.backend.request(
873
+ "POST",
874
+ context,
875
+ "/ai/coding/formula/validate",
876
+ json_body={"appKey": app_key, "formula": formula, "testCase": test_case},
877
+ )
878
+ except QingflowApiError as exc:
879
+ evaluation_error = exc.message
880
+ return {"evaluation": evaluation_result, "evaluationError": evaluation_error}
881
+
882
+ def app_form_run_code_block(
883
+ self,
884
+ *,
885
+ profile: str,
886
+ app_key: str,
887
+ code: str,
888
+ key_que_values: list[JSONObject],
889
+ ) -> JSONObject:
890
+ """调用后端 POST /data/{appKey}/codeBlock/detection 真跑代码块。
891
+
892
+ 本地静态校验由 CLI handler 负责;本方法只做后端真跑,捕获后端错误为 runError。
893
+ ``key_que_values`` 需符合后端 OpenMatchQueValue 结构 ``{keyQueId, ordinal?, values}``。
894
+
895
+ 传入的 ``code`` 是原始 JS(未经前端字段公式加密),必须声明 ``unEncrypted=True``;
896
+ 否则后端 ``DataSvcImpl.codeBlockTest`` 会走 ``FormulaDecoder.extractFormula``
897
+ 对原始 JS 解密并抛 ``QFRuntimeException``,导致后端真跑对所有真实代码必然失败。
898
+ """
899
+ _session_profile, _backend_session, context = self._require_context(profile, require_workspace=True)
900
+ run_result: Any = None
901
+ run_error: str | None = None
902
+ try:
903
+ run_result = self.backend.request(
904
+ "POST",
905
+ context,
906
+ f"/data/{app_key}/codeBlock/detection",
907
+ json_body={"code": code, "keyQueValues": key_que_values, "unEncrypted": True},
908
+ )
909
+ except QingflowApiError as exc:
910
+ run_error = exc.message
911
+ return {"runResult": run_result, "runError": run_error}
912
+
855
913
  def app_delete(self, *, profile: str, app_key: str) -> JSONObject:
856
914
  """Delete an application explicitly."""
857
915
  return self._app_form.delete(profile=profile, app_key=app_key)