@qingflow-tech/qingflow-cli 1.1.43-beta.3 → 1.1.43-beta.4

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 (35) 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 +7 -5
  5. package/skills/qingflow-cli/reference/builder/30-schema-fields.md +14 -13
  6. package/src/qingflow_mcp/app_form_compat.py +11 -27
  7. package/src/qingflow_mcp/app_form_validator.py +201 -35
  8. package/src/qingflow_mcp/builder_facade/app_form.py +762 -148
  9. package/src/qingflow_mcp/builder_facade/app_form_schema.py +114 -3
  10. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +310 -245
  11. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +82 -27
  12. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +85 -32
  13. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +82 -30
  14. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +113 -37
  15. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +87 -64
  16. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +82 -27
  17. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +82 -27
  18. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +83 -28
  19. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +82 -27
  20. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +82 -27
  21. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +82 -27
  22. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +83 -28
  23. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +82 -27
  24. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +85 -32
  25. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +82 -27
  26. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +95 -40
  27. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +100 -28
  28. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +82 -27
  29. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +82 -27
  30. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +212 -117
  31. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +82 -27
  32. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +143 -161
  33. package/src/qingflow_mcp/builder_facade/service.py +3 -2
  34. package/src/qingflow_mcp/response_trim.py +2 -0
  35. package/src/qingflow_mcp/server_app_builder.py +2 -2
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.43-beta.3
10
+ npm install @qingflow-tech/qingflow-cli@1.1.43-beta.4
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.43-beta.3 qingflow
24
+ npx -y -p @qingflow-tech/qingflow-cli@1.1.43-beta.4 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.43-beta.3 qingflow-skills install --agent codex --scope user --copy
65
+ npx -y -p @qingflow-tech/qingflow-cli@1.1.43-beta.4 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.43-beta.3",
3
+ "version": "1.1.43-beta.4",
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.43b3"
7
+ version = "1.1.43b4"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -15,13 +15,13 @@ AppForm is one shared declaration contract. Choose one execution surface and use
15
15
  | Apply | `qingflow --json builder app-form apply --file DECLARATION.json` | `app_form_apply(spec=DECLARATION)` |
16
16
  | Delete | `qingflow --json builder app delete --app-key APP_KEY` | `app_delete(app_key=APP_KEY)` |
17
17
 
18
- Before creating an app, use `qingflow --json builder icon catalog` in CLI or `workspace_icon_catalog_get` in MCP only to select its icon and color. `app_publish_verify` is retained only for an explicit final cross-resource verification; AppForm recovery never depends on it. For general surface selection, read [$qingflow-common](../qingflow-common/SKILL.md).
18
+ Before creating an app, let AppForm derive a stable default icon and color from `spec.appName`; do not add visual metadata to the declaration. `app_publish_verify` is retained only for an explicit final cross-resource verification; AppForm recovery never depends on it. For general surface selection, read [$qingflow-common](../qingflow-common/SKILL.md).
19
19
 
20
20
  ## Workflow
21
21
 
22
22
  1. Read the AppForm Schema and pin its `schemaVersion` and `apiVersion` for the entire operation.
23
23
  2. Read the pinned field Schema for every canonical field type used, including subtable child types.
24
- 3. For an update, read the draft form with `being_draft=true`. Preserve every field, row, section, and subfield that should remain. For a create, select an explicit non-`template` icon candidate, then build a declaration without `appKey` and with `packageId`, `spec.appName`, `spec.icon`, `spec.color`, and complete `spec.body`.
24
+ 3. For an update, read the draft form with `being_draft=true`. Preserve every field, row, section, and subfield that should remain. For a create, build a declaration without `appKey` and with `packageId`, `spec.appName`, and complete `spec.body`; AppForm derives the icon and color from `spec.appName`.
25
25
  4. Keep existing `queId`, `sectionId`, `attachId`, and `aliasId` values from get. Omit them only for new resources. Treat `body`, `subfields`, explicit `attachments`, and custom serial-number `components` as complete target state.
26
26
  5. Save the declaration as JSON. In CLI, run `qingflow --json builder app-form validate --schema-version PINNED --file DECLARATION.json` before every apply and fix every reported pointer. In MCP, call apply directly; it performs the same declaration validation internally. YAML is not accepted.
27
27
  6. Apply the declaration on the selected surface. Record a newly returned `appKey` immediately. On success, show only `operation`, the non-empty `changes/actions` summary, and warnings. On any non-success result, show the recovery diagnostics, especially deleted/changed field names in `diff/remainingDiff`, and the failed phase.
@@ -36,7 +36,7 @@ Before creating an app, use `qingflow --json builder icon catalog` in CLI or `wo
36
36
  ## Identity And Deletion
37
37
 
38
38
  - `sectionId` is a positive decimal string. `queId`, `attachId`, and `aliasId` are positive integers.
39
- - A declaration with `appKey` updates and must not contain `spec.icon` or `spec.color`. A declaration without `appKey` always creates a new app and must include `packageId`, `spec.appName`, `spec.icon`, and `spec.color`.
39
+ - A declaration with `appKey` updates. A declaration without `appKey` always creates a new app and must include `packageId` and `spec.appName`. Neither form accepts `spec.icon` or `spec.color`.
40
40
  - Repeating a create after losing its response can create a duplicate. Once an `appKey` is known, add it to the original declaration and recover as an update.
41
41
  - Update `packageId` only when the user wants to ensure that package references the app. It never removes other package references.
42
42
  - Removing an existing field from `body` or a child from `subfields` deletes it. Replace a field type in two explicit applies: delete, verify, then add the new type without the old `queId`.
@@ -56,8 +56,10 @@ Before creating an app, use `qingflow --json builder icon catalog` in CLI or `wo
56
56
 
57
57
  - Pin one Schema version; never combine details from different versions or silently upgrade recovery work.
58
58
  - Ordinary and section rows contain one to four fields. A section is its own outer block. Subtable columns use only the allowed subfield types returned by the subtable detail Schema.
59
- - Field declarations may set `defaultType` (`1` static, `2` relation/binding, `3` formula) and `defaultValue`; use the canonical value shape for the field type, and use `null` to clear a default. These properties also apply to subtable fields. Choice defaults belong in `config.defaultOptions`; address, attachment, subtable, and relation defaults belong in `config.defaultValues`. Omit a default when none is intended.
59
+ - Field declarations may set `defaultType` (`1` static, `2` relation/binding, `3` formula) and `defaultValue`; `defaultValue` is `string | null` because the backend `queDefaultValue` is a string, so numeric literals must be passed as strings. Use `null` to clear a default. These properties also apply to subtable fields. Choice defaults belong in `config.defaultOptions`; address, attachment, subtable, and relation defaults belong in `config.defaultValues`. Omit a default when none is intended.
60
+ - For `code_block`, when `config.codeBlockBinding.outputs` is non-empty, the `code` must explicitly assign `qf_output`, for example `qf_output = { score: input.score };`; `return {...}` alone does not satisfy output writeback, and each output `path` must point under `qf_output`.
61
+ - A relation formula default is `config.defaultValueFormula` and must satisfy the pinned `relation` field Schema's `FormulaExpression`; do not infer its syntax from another version or from backend DTO names.
60
62
  - If widths are present, every field in that row must have one and the total must equal 100.
61
63
  - Keep declarations at or below 2 MiB and 64 object/array nesting levels. For a Q-Linker `json_path` input, set `key` to the selected field's exact name so get can reconstruct the placeholder losslessly.
62
- - Do not call CLI apply when the validation command fails. The command validates format only; it is not a dry run and does not read or modify Qingflow. For MCP, resolve validation errors returned by `app_form_apply` before retrying.
64
+ - Do not call CLI apply when the validation command fails. The command validates the local Schema plus declaration-only cross-field rules, including resolvable integration selectors, supported output target types, and globally unique Q-Linker/code-block output targets. It is not a dry run and does not read or modify Qingflow; checks that need current or external app state remain in apply. For MCP, resolve validation errors returned by `app_form_apply` before retrying.
63
65
  - Delete an application only after explicit user intent through the selected CLI or MCP delete operation.
@@ -34,15 +34,14 @@ The current creation shape is a complete AppForm declaration: `spec.body` contai
34
34
  "packageId": 123456,
35
35
  "spec": {
36
36
  "appName": "生产工单",
37
- "icon": "clipboard-check",
38
- "color": "green",
37
+ "dataTitleField": {"name": "工单编号"},
39
38
  "body": [
40
39
  {
41
40
  "kind": "section",
42
41
  "title": "基础信息",
43
42
  "rows": [
44
43
  {"fields": [
45
- {"name": "工单编号", "type": "text", "dataTitle": true},
44
+ {"name": "工单编号", "type": "text"},
46
45
  {"name": "产品", "type": "text"},
47
46
  {"name": "状态", "type": "single_select", "config": {"options": ["待排产", "生产中", "已完工"]}}
48
47
  ]},
@@ -165,9 +164,9 @@ current CLI payload.
165
164
 
166
165
  ## Field type reference
167
166
 
168
- > **口径**:`field.type` 枚举和字段形状以固定版本的 **`qingflow --json builder app-form schema`** 返回结果为准;每个 canonical 类型还可用 `--field-type TYPE` 获取细节。不要从已退休的 `app_schema_apply` contract 推断当前 AppForm payload。
167
+ > **口径**:`field.questionType` 枚举和字段形状以固定版本的 **`qingflow --json builder app-form schema`** 返回结果为准;每个 canonical 类型还可用 `--field-type TYPE` 获取细节。不要从已退休的 `app_schema_apply` contract 推断当前 AppForm payload。
169
168
  > **CLI**:表单写入使用 `qingflow [--profile NAME] --json builder app-form schema/get/validate/apply`;`validate/apply` 接受 `--file`,`get` 接受 `--app-key`。旧 `builder schema apply` 及 `builder layout apply` 不在当前 parser 的公开命令集合中。
170
- > **新建应用主规则**:`spec.body` 里必须有且仅有一个顶层字段标记 `dataTitle: true`;数据封面可选,只能在顶层 `attachment` 字段标记 `dataCover: true`。
169
+ > **新建应用主规则**:`spec.body` 非空时必须用 `spec.dataTitleField` 唯一选择一个顶层字段作为数据标题;数据封面可选,只能用 `spec.dataCoverField` 选择顶层 `attachment` 字段。
171
170
  > **权限**:包内新建应用按后端 `CreateAppBean` 链路只预检目标包 **AddAppAuth**;已有应用的字段/基础信息变更才走应用自身 **EditAppAuth**。
172
171
 
173
172
  ## 0. 字段建模一眼决策表
@@ -176,7 +175,7 @@ current CLI payload.
176
175
 
177
176
  | 业务语义 | 推荐写法 | 注意 |
178
177
  | --- | --- | --- |
179
- | 名称、标题、编号、短文本 | `text` | 新建应用必须且只能有一个顶层可读字段标记 `dataTitle: true` |
178
+ | 名称、标题、编号、短文本 | `text` | 非空表单必须通过应用级 `dataTitleField` 唯一选择一个顶层字段 |
180
179
  | 说明、备注、描述、长文本 | `long_text` | 多行文本 |
181
180
  | 状态、类型、等级、阶段、是否枚举 | `single_select` | 必须在 `config.options` 定义非空字符串数组 |
182
181
  | 标签、多选项、多个类别 | `multi_select` | 必须在 `config.options` 定义非空字符串数组 |
@@ -185,7 +184,7 @@ current CLI payload.
185
184
  | 日期、计划日、截止日 | `date` | 同时需要时分秒才用 `datetime` |
186
185
  | 负责人、审批人、参与人 | `member` | 不要创建“申请人/提交人”等平台系统字段 |
187
186
  | 部门、组织 | `department` | 可按需要补 `config.departmentScope` |
188
- | 图片、文件、附件 | `attachment` | 只有顶层 `attachment` 可标记 `dataCover: true` |
187
+ | 图片、文件、附件 | `attachment` | 只有顶层 `attachment` 可被应用级 `dataCoverField` 选择 |
189
188
  | 跨应用业务对象关系 | `relation` | 使用已确认的 `targetAppKey`;`displayField` / `visibleFields` 必须写对象选择器 |
190
189
 
191
190
  禁止创建平台系统字段:`数据ID`、`编号`、`申请人`、`申请时间`、`创建人`、`创建时间`、`提交人`、`提交时间`、`更新时间`、`更新人`、`当前流程状态`、`当前处理人`、`当前处理节点`、`流程标题`。需要引用系统字段时,只在工具明确支持的位置引用,例如按钮 `source_field: "数据ID"`。
@@ -196,7 +195,7 @@ current CLI payload.
196
195
 
197
196
  固定 AppForm Schema 中可用的字段类型为:
198
197
 
199
- | `type` | 说明(搭建语义) |
198
+ | `questionType` | 说明(搭建语义) |
200
199
  |--------|------------------|
201
200
  | `text` | 单行文本 |
202
201
  | `long_text` | 多行文本 |
@@ -238,14 +237,14 @@ current CLI payload.
238
237
 
239
238
  ## 2. 按类型的必填 / 常用可选(写意图)
240
239
 
241
- | `type` | 通常必填 | 常见可选 / 约束 |
240
+ | `questionType` | 通常必填 | 常见可选 / 约束 |
242
241
  |--------|----------|-----------------|
243
- | 标量与选择器 | `name` + `type`(+ 业务 `required` 等) | `description`、`required`;新建应用时其中一个顶层可读字段须 `dataTitle: true`;**`single_select` / `multi_select` 的 `config.options` 是非空字符串数组**,如 `["A", "B"]` |
242
+ | 标量与选择器 | `name` + `questionType`(+ 业务 `beingRequired` 等) | `questionDescription`、`beingRequired`;数据标题和封面在应用级 `dataTitleField/dataCoverField` 中选择;**`single_select` / `multi_select` 的 `config.options` 是非空字符串数组**,如 `["A", "B"]` |
244
243
  | `department` | 同左 | **`config.departmentScope`**:`mode: all \| custom`;`custom` 时 **`departments[]`**(含 `deptId` 等) |
245
244
  | `relation` | **`targetAppKey`**;另需 **`displayField`**、**`visibleFields[]`** | **`relationMode`**;目标应用元数据不可读时先读已发布表单,不要猜目标 key |
246
245
  | `subtable` | **`subfields[]` 非空** | 子列递归同父级规则;使用 Schema 返回的 camelCase 子字段配置 |
247
246
  | `q_linker` | **`config.qLinkerBinding`**:`inputs`、`request`、**`outputs[]` 每项须含 `targetField`**(能解析到本表单字段) | `request.url` 等须符合**租户/后端远程查询策略**;以字段类型 Schema 返回的配置为准 |
248
- | `code_block` | **`config.codeBlockBinding`**:`inputs`、`code`、**`outputs[]` + `targetField`**;代码体须能被规范识别 **`qf_output` 赋值** | 以字段类型 Schema 返回的 camelCase 配置为准 |
247
+ | `code_block` | **`config.codeBlockBinding`**:`inputs`、`code`、**`outputs[]` + `targetField`**;配置 outputs 时,代码必须显式写 **`qf_output = {...}`**,仅 `return {...}` 不会产生可回写结果,且每个 `outputs[].path` 必须从 `qf_output` 取值 | 以字段类型 Schema 返回的 camelCase 配置为准 |
249
248
 
250
249
  别名:契约会把 `title`/`label` 映射到 **`name`** 等;字段类型自然别名见 §1.1。
251
250
 
@@ -268,6 +267,8 @@ current CLI payload.
268
267
 
269
268
  只在目标应用已经创建并通过 readback 确认后填写 `targetAppKey`。如果返回选择器校验错误,修复 `config.displayField` / `config.visibleFields`,不要重建应用或改 `appKey`。
270
269
 
270
+ 引用字段的公式默认值写在 `config.defaultValueFormula`,表达式必须符合固定版本 `relation` 字段详情中的 `FormulaExpression`。不要把后端 `referenceConfig` 字段名或其他 Schema 版本的公式写法直接放进声明。
271
+
271
272
  ### 2.2 工作流状态字段
272
273
 
273
274
  要启用工作流的应用,字段阶段就创建一个明确的业务状态单选字段,例如 `状态`、`处理状态`、`审批状态`、`工单状态`、`计划状态`、`报工状态`、`单据状态`。领域结果字段不等价于流程状态;例如质量场景的 `检验结论` 只能表达结果,仍建议另建 `处理状态` 用于流程节点、视图和报表。
@@ -279,7 +280,7 @@ current CLI payload.
279
280
  | 场景 | 当前入口 | 说明 |
280
281
  |------|----------------|------|
281
282
  | 编辑已有应用 | `qingflow --json builder app-form get --app-key APP_KEY --being-draft` -> `qingflow --json builder app-form validate --schema-version VERSION --file DECLARATION.json` -> `qingflow --json builder app-form apply --file DECLARATION.json` | 保留完整 `body` 和现有 IDs;不要提交部分字段 patch |
282
- | 包内新建应用 | `qingflow --json builder app-form schema` -> declaration without `appKey` -> `qingflow --json builder app-form validate --schema-version VERSION --file DECLARATION.json` -> `qingflow --json builder app-form apply --file DECLARATION.json` | 须含 `packageId`、`spec.appName`、显式 icon/color 和完整 `body` |
283
+ | 包内新建应用 | `qingflow --json builder app-form schema` -> declaration without `appKey` -> `qingflow --json builder app-form validate --schema-version VERSION --file DECLARATION.json` -> `qingflow --json builder app-form apply --file DECLARATION.json` | 须含 `packageId`、`spec.appName` 和完整 `body`;图标与颜色按应用名生成 |
283
284
  | 多应用系统 | 每个 app 独立走 AppForm;先创建无关系 app,再用已确认 `appKey` 更新关系 | 不使用旧 `apps-file` 批量 schema 路径 |
284
285
  | 删除应用 | 显式用户意图 -> `qingflow --json builder app delete --app-key APP_KEY` | 不通过字段删除 |
285
286
 
@@ -291,7 +292,7 @@ current CLI payload.
291
292
  |------|------|
292
293
  | **当前完整系统主入口** | [20-build-complete-system.md](./20-build-complete-system.md) |
293
294
  | **历史字段类型能力探针(不是新建主模板)** | [schema_apply_add_fields_all_types.json](../examples/schema/schema_apply_add_fields_all_types.json)(仅用于旧字段能力覆盖测试;当前新建和更新统一使用上文 AppForm) |
294
- | **`q_linker` / `code_block`**(契约内置形状) | `qingflow --json builder app-form schema --field-type TYPE` 返回的当前 AppForm 类型详情 |
295
+ | **`q_linker` / `code_block` / relation 公式**(契约内置形状) | `qingflow --json builder app-form schema --schema-version VERSION --field-type TYPE` 返回的固定版本字段详情 |
295
296
  | 最小新建字段(1 个 text 标题字段) | [schema_add_fields_minimal.example.json](../examples/schema/schema_add_fields_minimal.example.json) |
296
297
  | 历史 scalar / complex 批测(可选) | [_batch_schema_scalar.json](../examples/schema/_batch_schema_scalar.json)、[_batch_schema_complex.json](../examples/schema/_batch_schema_complex.json) |
297
298
 
@@ -5,7 +5,6 @@ from typing import Any
5
5
 
6
6
  from .builder_facade.app_form_schema import AppFormContractError
7
7
  from .json_types import JSONObject
8
- from .solution.compiler.icon_utils import validate_workspace_icon_choice
9
8
 
10
9
 
11
10
  TYPE_ALIASES = {
@@ -40,18 +39,25 @@ def normalize_app_form_declaration(payload: JSONObject) -> tuple[JSONObject, lis
40
39
  warnings: list[JSONObject] = []
41
40
 
42
41
  def visit(field: JSONObject, path: str) -> None:
43
- raw_type = str(field.get("type") or "").strip()
42
+ raw_type = str(field.get("questionType") or field.get("type") or "").strip()
44
43
  canonical_type = TYPE_ALIASES.get(raw_type, raw_type)
45
44
  if canonical_type:
46
- field["type"] = canonical_type
45
+ field["questionType"] = canonical_type
46
+ if "type" in field:
47
+ if "questionType" in field and str(field.get("type") or "").strip() != raw_type:
48
+ raise AppFormContractError("INVALID_SPEC", "conflicting aliases: type and questionType", path=path)
49
+ field.pop("type", None)
47
50
  if canonical_type != raw_type and raw_type == "boolean":
48
51
  warnings.append({"code": "LEGACY_BOOLEAN_NORMALIZED", "path": f"{path}/type"})
49
52
 
50
53
  for old, new in (
51
- ("as_data_title", "dataTitle"),
52
- ("as_data_cover", "dataCover"),
54
+ ("required", "beingRequired"),
55
+ ("description", "questionDescription"),
53
56
  ("default_type", "defaultType"),
54
57
  ("default_value", "defaultValue"),
58
+ ("question_description", "questionDescription"),
59
+ ("question_type", "questionType"),
60
+ ("being_required", "beingRequired"),
55
61
  ):
56
62
  if old not in field:
57
63
  continue
@@ -96,25 +102,3 @@ def normalize_app_form_declaration(payload: JSONObject) -> tuple[JSONObject, lis
96
102
  if isinstance(field, dict):
97
103
  visit(field, f"/spec/body/{block_index}/rows/{row_index}/fields/{field_index}")
98
104
  return result, warnings
99
-
100
-
101
- def validate_app_form_create_icon(payload: JSONObject) -> None:
102
- if "appKey" in payload:
103
- return
104
- spec = payload.get("spec")
105
- if not isinstance(spec, dict):
106
- return
107
- ok, error_code, message, details = validate_workspace_icon_choice(
108
- icon=str(spec.get("icon") or "") or None,
109
- color=str(spec.get("color") or "") or None,
110
- require_explicit=True,
111
- disallow_generic=True,
112
- )
113
- if not ok:
114
- color_errors = {"WORKSPACE_ICON_COLOR_REQUIRED", "WORKSPACE_ICON_COLOR_NOT_FOUND"}
115
- raise AppFormContractError(
116
- error_code or "WORKSPACE_ICON_INVALID",
117
- message or "invalid workspace icon configuration",
118
- path="/spec/color" if error_code in color_errors else "/spec/icon",
119
- details=details,
120
- )
@@ -2,12 +2,27 @@ 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]+)\}\}")
11
26
 
12
27
 
13
28
  class AppFormValidationError(ValueError):
@@ -20,13 +35,17 @@ class AppFormValidationError(ValueError):
20
35
  self.api_version = api_version
21
36
 
22
37
 
38
+ def _field_type(field: dict[str, Any]) -> str:
39
+ return str(field.get("questionType") or field.get("type") or "").strip()
40
+
41
+
23
42
  def _field_types(payload: dict[str, Any]) -> tuple[str, ...]:
24
43
  found: set[str] = set()
25
44
 
26
45
  def visit(field: Any) -> None:
27
46
  if not isinstance(field, dict):
28
47
  return
29
- field_type = str(field.get("type") or "").strip()
48
+ field_type = _field_type(field)
30
49
  if field_type:
31
50
  found.add(field_type)
32
51
  for child in field.get("subfields") or []:
@@ -77,14 +96,6 @@ def _compose(summary: dict[str, Any], details: list[dict[str, Any]]) -> dict[str
77
96
  return schema
78
97
 
79
98
 
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
99
  def _pointer(parts: Iterable[Any]) -> str:
89
100
  encoded = [str(item).replace("~", "~0").replace("/", "~1") for item in parts]
90
101
  return "/" + "/".join(encoded) if encoded else "/"
@@ -116,38 +127,198 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
116
127
  for field_index, field in enumerate(fields):
117
128
  if isinstance(field, dict):
118
129
  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"))
130
+ def resolve_local_selector(selector: Any) -> tuple[str, tuple[str, dict[str, Any]] | None]:
131
+ """Resolve against the declared body, preserving backend-dependent ID-only cases."""
132
+ if not isinstance(selector, dict):
133
+ return "schema", None
134
+ raw_que_id = selector.get("queId")
135
+ que_id = raw_que_id if isinstance(raw_que_id, int) and not isinstance(raw_que_id, bool) else None
136
+ raw_name = selector.get("name")
137
+ name = raw_name if isinstance(raw_name, str) and raw_name else ""
138
+ if raw_que_id is not None and que_id is None:
139
+ return "schema", None
140
+ if raw_name is not None and not name:
141
+ return "schema", None
142
+ if que_id is not None:
143
+ matches = [(path, field) for path, field in top_fields if field.get("queId") == que_id]
144
+ if len(matches) != 1:
145
+ return "invalid", None
146
+ declared_name = str(matches[0][1].get("name") or "")
147
+ if name and declared_name and name != declared_name:
148
+ return "invalid", None
149
+ return "resolved", matches[0]
150
+ if name:
151
+ matches = [(path, field) for path, field in top_fields if str(field.get("name") or "") == name]
152
+ if len(matches) == 1:
153
+ return "resolved", matches[0]
154
+ if len(matches) > 1:
155
+ return "invalid", None
156
+ if any(not str(field.get("name") or "") for _, field in top_fields):
157
+ return "unknown", None
158
+ return "invalid", None
159
+ return "schema", None
160
+
161
+ title_selector = spec.get("dataTitleField")
162
+ if top_fields:
163
+ if "dataTitleField" not in spec and "appKey" in payload:
164
+ pass
165
+ elif title_selector is None or resolve_local_selector(title_selector)[0] == "invalid":
166
+ errors.append(("/spec/dataTitleField", "dataTitleField must resolve to exactly one top-level field"))
167
+ elif title_selector is not None:
168
+ errors.append(("/spec/dataTitleField", "dataTitleField must be null when body is empty"))
169
+
170
+ cover_selector = spec.get("dataCoverField")
171
+ if cover_selector is not None:
172
+ cover_resolution, cover = resolve_local_selector(cover_selector)
173
+ if cover_resolution == "invalid":
174
+ errors.append(("/spec/dataCoverField", "dataCoverField must resolve to exactly one top-level field"))
175
+ elif cover_resolution == "resolved" and cover is not None and _field_type(cover[1]) != "attachment":
176
+ errors.append(("/spec/dataCoverField", "dataCoverField must select a top-level attachment field"))
127
177
  names: dict[str, str] = {}
128
178
  fields_by_id = {
129
179
  field.get("queId"): field
130
180
  for _, field in top_fields
131
181
  if isinstance(field.get("queId"), int) and not isinstance(field.get("queId"), bool)
132
182
  }
183
+ output_targets: dict[str, tuple[str, str]] = {}
133
184
  for path, field in top_fields:
134
185
  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]}"))
186
+ if name:
187
+ if name in names:
188
+ errors.append((f"{path}/name", f"duplicate top-level field name; first declared at {names[name]}"))
189
+ else:
190
+ names[name] = f"{path}/name"
191
+ field_type = _field_type(field)
192
+ if field_type == "data_relation":
193
+ config = field.get("config") if isinstance(field.get("config"), dict) else {}
194
+ filters = config.get("filters") if isinstance(config.get("filters"), dict) else {}
195
+ if filters.get("mode") == "simple":
196
+ for group_index, group in enumerate(filters.get("groups") or []):
197
+ for condition_index, condition in enumerate(group if isinstance(group, list) else []):
198
+ if not isinstance(condition, dict):
199
+ continue
200
+ operator = condition.get("operator")
201
+ operand = condition.get("operand") if isinstance(condition.get("operand"), dict) else {}
202
+ values = operand.get("values") if isinstance(operand.get("values"), list) else None
203
+ operand_path = (
204
+ f"{path}/config/filters/groups/{group_index}/{condition_index}/operand"
205
+ )
206
+ if operator in {"is_empty", "is_not_empty"}:
207
+ if operand.get("kind") != "literal" or values != []:
208
+ errors.append(
209
+ (
210
+ operand_path,
211
+ "is_empty and is_not_empty require a literal operand with values: []",
212
+ )
213
+ )
214
+ elif operand.get("kind") == "literal" and not values:
215
+ errors.append(
216
+ (
217
+ f"{operand_path}/values",
218
+ "literal operands for non-empty operators must contain at least one value",
219
+ )
220
+ )
221
+ if field_type == "q_linker":
222
+ binding_name = "qLinkerBinding"
223
+ elif field_type == "code_block":
224
+ binding_name = "codeBlockBinding"
137
225
  else:
138
- names[name] = f"{path}/name"
139
- if field.get("type") != "q_linker":
140
226
  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":
227
+ binding = (field.get("config") or {}).get(binding_name) if isinstance(field.get("config"), dict) else None
228
+ if not isinstance(binding, dict):
229
+ continue
230
+ for index, item in enumerate(binding.get("inputs") or []):
231
+ if not isinstance(item, dict):
144
232
  continue
145
233
  selector = item.get("field") if isinstance(item.get("field"), dict) else {}
146
- selected_name = selector.get("name")
234
+ input_path = f"{path}/config/{binding_name}/inputs/{index}"
235
+ resolution, selected = resolve_local_selector(selector)
236
+ if resolution == "invalid":
237
+ errors.append((f"{input_path}/field", "input field must resolve to exactly one top-level field"))
238
+ continue
239
+ if field_type != "q_linker" or item.get("source") != "json_path":
240
+ continue
241
+ selected_name = selected[1].get("name") if selected is not None else selector.get("name")
147
242
  if not selected_name and selector.get("queId") in fields_by_id:
148
243
  selected_name = fields_by_id[selector["queId"]].get("name")
149
244
  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"))
245
+ errors.append((f"{input_path}/key", "json_path key must equal the selected field name"))
246
+
247
+ if field_type == "q_linker":
248
+ request = binding.get("request") if isinstance(binding.get("request"), dict) else {}
249
+ template_paths: dict[str, list[str]] = {}
250
+ for property_name in ("url", "jsonValue", "xmlValue"):
251
+ raw_template = request.get(property_name)
252
+ if not isinstance(raw_template, str):
253
+ continue
254
+ for match in _Q_LINKER_TEMPLATE_RE.finditer(raw_template):
255
+ template_paths.setdefault(match.group("key"), []).append(
256
+ f"{path}/config/{binding_name}/request/{property_name}"
257
+ )
258
+ input_items = [item for item in binding.get("inputs") or [] if isinstance(item, dict)]
259
+ json_path_inputs: dict[str, list[int]] = {}
260
+ for index, item in enumerate(input_items):
261
+ key = str(item.get("key") or "")
262
+ if key and item.get("source") == "json_path":
263
+ json_path_inputs.setdefault(key, []).append(index)
264
+ for key, request_paths in template_paths.items():
265
+ providers = json_path_inputs.get(key) or []
266
+ if len(providers) == 1:
267
+ continue
268
+ if not providers:
269
+ message = (
270
+ f"placeholder '{{{{{key}}}}}' requires an input with source 'json_path' and the same key; "
271
+ "query_param, header, and url_encoded inputs do not replace request templates"
272
+ )
273
+ else:
274
+ message = f"placeholder '{{{{{key}}}}}' must have exactly one json_path input"
275
+ errors.append((request_paths[0], message))
276
+ for key, indexes in json_path_inputs.items():
277
+ if key in template_paths:
278
+ continue
279
+ for index in indexes:
280
+ errors.append(
281
+ (
282
+ f"{path}/config/{binding_name}/inputs/{index}/source",
283
+ f"json_path input key '{key}' must appear as '{{{{{key}}}}}' in request.url, request.jsonValue, or request.xmlValue",
284
+ )
285
+ )
286
+
287
+ producer = name or str(field.get("queId") or field_type)
288
+ for index, item in enumerate(binding.get("outputs") or []):
289
+ if not isinstance(item, dict):
290
+ continue
291
+ selector = item.get("targetField")
292
+ target_path = f"{path}/config/{binding_name}/outputs/{index}/targetField"
293
+ resolution, selected = resolve_local_selector(selector)
294
+ if resolution == "invalid":
295
+ errors.append((target_path, "targetField must resolve to exactly one top-level field"))
296
+ continue
297
+ if resolution != "resolved" or selected is None:
298
+ continue
299
+ selected_path, target_field = selected
300
+ target_name = str(target_field.get("name") or target_field.get("queId") or "")
301
+ target_type = _field_type(target_field)
302
+ if target_type and target_type not in _INTEGRATION_OUTPUT_TARGET_FIELD_TYPES:
303
+ allowed = ", ".join(sorted(_INTEGRATION_OUTPUT_TARGET_FIELD_TYPES))
304
+ errors.append(
305
+ (
306
+ target_path,
307
+ f"output target field '{target_name}' uses unsupported questionType '{target_type}'; allowed types: {allowed}",
308
+ )
309
+ )
310
+ previous = output_targets.get(selected_path)
311
+ if previous is None:
312
+ output_targets[selected_path] = (producer, target_path)
313
+ continue
314
+ previous_producer, previous_path = previous
315
+ errors.append(
316
+ (
317
+ target_path,
318
+ f"field '{target_name}' is already targeted by output from '{previous_producer}' at {previous_path}; "
319
+ "each field can receive output from only one Q-Linker or code block relation",
320
+ )
321
+ )
151
322
  return errors
152
323
 
153
324
 
@@ -184,15 +355,10 @@ def validate_declaration(
184
355
  Draft202012Validator.check_schema(schema)
185
356
  validator = Draft202012Validator(schema, format_checker=FormatChecker())
186
357
  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
358
+ for error in compact_schema_errors(validator.iter_errors(declaration), schema):
359
+ found[(_pointer(error.absolute_path), f"{error.validator}: {schema_error_message(error)}")] = None
190
360
  for pointer, message in _static_errors(declaration):
191
361
  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
196
362
  if found:
197
363
  issues = [{"pointer": pointer, "message": message} for pointer, message in sorted(found)]
198
364
  raise AppFormValidationError(issues, schema_version=pinned, api_version=api_version)