@qingflow-tech/qingflow-cli 1.1.44 → 1.1.45

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
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 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 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",
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.45"
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` 选择器形态
@@ -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"):