@qingflow-tech/qingflow-cli 1.1.50-beta.4 → 1.1.50-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/skills/qingflow-cli/SKILL.md +37 -0
  5. package/skills/qingflow-cli/reference/builder/50-views.md +12 -0
  6. package/skills/qingflow-cli/reference/builder/80-buttons-associated-resources.md +2 -0
  7. package/src/qingflow_mcp/app_form_validator.py +53 -14
  8. package/src/qingflow_mcp/builder_facade/app_form.py +152 -47
  9. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +3 -3
  10. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +1 -1
  11. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +1 -1
  12. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +1 -1
  13. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +3 -3
  14. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +3 -3
  15. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +1 -1
  16. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +1 -1
  17. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +3 -3
  18. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +1 -1
  19. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +1 -1
  20. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +1 -1
  21. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +3 -3
  22. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +1 -1
  23. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +1 -1
  24. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +1 -1
  25. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +3 -3
  26. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +3 -3
  27. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/rich_text.json +1 -1
  28. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +1 -1
  29. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +1 -1
  30. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +3 -3
  31. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +1 -1
  32. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/formula.json +35 -0
  33. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +2 -2
  34. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/complete.json +1 -1
  35. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/button-config.json +3 -3
  36. package/src/qingflow_mcp/builder_facade/service.py +170 -88
  37. package/src/qingflow_mcp/cli/commands/builder.py +4 -2
  38. package/src/qingflow_mcp/server_app_builder.py +2 -2
  39. package/src/qingflow_mcp/tools/ai_builder_tools.py +7 -5
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.50-beta.4
10
+ npm install @qingflow-tech/qingflow-cli@1.1.50-beta.5
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.50-beta.4 qingflow
24
+ npx -y -p @qingflow-tech/qingflow-cli@1.1.50-beta.5 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.50-beta.4 qingflow-skills install --agent codex --scope user --copy
65
+ npx -y -p @qingflow-tech/qingflow-cli@1.1.50-beta.5 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.50-beta.4",
3
+ "version": "1.1.50-beta.5",
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.50b4"
7
+ version = "1.1.50b5"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -14,6 +14,43 @@ description: |
14
14
 
15
15
  本 Skill 负责 CLI 入口、文件约定和业务分流。已知 `app_key` 时直接执行 `data query/get`。只有应用名称时,先用 `qingflow --json app list --query "应用名称"` 查找当前工作区的可见应用,并核对返回项的 `app_name/title`;目标明确且唯一时取其 `app_key` 再查询,否则请用户确认。不要把应用名称直接传给 `--app-key`,也不要为定位名称转向 builder 或切换工作区。字段能力、权限范围与下一步动作以当前工具 schema、data 响应和 `hints` 为准。
16
16
 
17
+ ## 关联文件
18
+
19
+
20
+ | 文件 | 说明 |
21
+ | ------------------------------------------------------------------------------------------------ |---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
22
+ | `manifest.yaml` | 技能清单 |
23
+ | [reference/00-INDEX.md](./reference/00-INDEX.md) | **总导航**:按用户目标选择 record / task / builder 文档;复杂任务先读 |
24
+ | [reference/QINGFLOW_CLI_MEMBER_CHEATSHEET.md](./reference/core/QINGFLOW_CLI_MEMBER_CHEATSHEET.md) | **普通成员**主线:最短路径、`view_id`、与管理命令的差异预期 |
25
+ | [reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md](./reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md) | **字段数据类型**:`record schema …` 的 `fields[]`(`field_id`、`title`、`kind`、`options`…);`record list/get/access` 与写入 payload 的形态对照 |
26
+ | [reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md](./reference/core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md) | **应用记录获取数据**:`app get`(`accessible_views`)→ `record schema browse` → `record list(query)` 模糊定位 / `record get` 详情页首屏上下文 / `record logs` 全量日志;分析取数转 `record_access` |
27
+ | [reference/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md](./reference/task/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md) | **任务(待办已办)上下文 SOP**:`task list` → `task get` → `task log` → `task report` → `task action`;只把 `task_id` 作为主定位参数 |
28
+ | [reference/QINGFLOW_CLI_RECORD_IMPORT_WORKFLOW.md](./reference/record/QINGFLOW_CLI_RECORD_IMPORT_WORKFLOW.md) | **批量导入 SOP**:`app_get`(**`import_capability`**)→ `record_import_template_get` → `record_import_verify` →(可选)`record_import_repair_local` → `record_import_start` → `record_import_status_get`;CLI `import` / `record schema import` |
29
+ | [reference/builder/README.md](./reference/builder/README.md) | **Builder 总导航**:完整系统、单应用、字段、布局、视图、报表、门户、按钮/关联资源、流程、发布校验 |
30
+ | [reference/builder/10-build-single-app.md](./reference/builder/10-build-single-app.md) | **单应用搭建主入口**:一个应用端到端交付顺序 |
31
+ | [reference/builder/20-build-complete-system.md](./reference/builder/20-build-complete-system.md) | **完整系统/应用包主入口**:多应用、跨应用 relation、门户闭环 |
32
+ | [reference/builder/30-schema-fields.md](./reference/builder/30-schema-fields.md) | **字段 / schema**:字段类型、直觉别名、数据标题/封面、schema 创建与更新 |
33
+ | [reference/builder/40-layout.md](./reference/builder/40-layout.md) | **布局维护**:仅用于已有应用的分组、`rows` 矩阵、布局修复;新建应用布局写在 `form` 内 |
34
+ | [reference/builder/50-views.md](./reference/builder/50-views.md) | **视图**:表格/卡片/看板/甘特、固定筛选、查询面板、视图 patch |
35
+ | [reference/builder/60-charts.md](./reference/builder/60-charts.md) | **报表 / QingBI**:指标卡、图表、语义聚合、筛选、排序、更新 |
36
+ | [reference/builder/70-portal.md](./reference/builder/70-portal.md) | **门户**:标准工作台、业务入口、指标、图表、视图组件、发布 |
37
+ | [reference/builder/80-buttons-associated-resources.md](./reference/builder/80-buttons-associated-resources.md) | **关联资源 / 非主链路按钮维护**:关联视图/报表、`match_mappings`、上下文传参;按钮配置看视图文档 `action_buttons`;创建自定义按钮前先 `button get --button-text` 查重复用 |
38
+ | [reference/builder/90-workflow.md](./reference/builder/90-workflow.md) | **流程**:WorkflowSpec、`patch_nodes`、角色/成员、流程回读 |
39
+ | [reference/builder/99-publish-verify.md](./reference/builder/99-publish-verify.md) | **发布与回读**:`publish verify`、partial/readback 处理、最终汇报口径 |
40
+ | [reference/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md](./reference/record/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md) | **新建记录**:`record schema insert -> record insert --items-file`;成员/部门/关联优先自然语言输入;细节见 [record/insert](./reference/record/insert/README.md) |
41
+ | [reference/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md](./reference/record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md) | **更新记录**:主链路是 `record get` → 智能体按 `fields[]` 组 key/value → **`record update`**;`record schema update` 仅用于失败后诊断可写字段/路径 |
42
+ | [reference/QINGFLOW_CLI_RECORD_DELETE_WORKFLOW.md](./reference/record/QINGFLOW_CLI_RECORD_DELETE_WORKFLOW.md) | **删除记录**:`record list/get` 精确定位 → `record delete --app-key --record-id --view-id system:*`;批量用 `--record-ids-file` |
43
+ | [reference/record/analysis/README.md](./reference/record/analysis/README.md) | **数据分析**:最终统计结论、分析报告、趋势/排名/比例/分布统一走 `record access -> Python/pandas` |
44
+ | [reference/builder/reference/app-delivery-sop.md](./reference/builder/reference/app-delivery-sop.md) | **底层交付参考**:历史完整 SOP、权限、系统列和工具细节;不是主入口 |
45
+ | [reference/builder/reference/workspace-icons.md](./reference/builder/reference/workspace-icons.md) | **图标参考**:`builder icon catalog`;新建应用包、应用、门户必须显式选择非 `template` 的 `icon + color` |
46
+ | [reference/builder/reference/match-rules.md](./reference/builder/reference/match-rules.md) | **统一筛选 / 条件写法**:`filters`、`visible_when`、QingBI 图表筛选、工作流条件、`match_mappings`、`field_mappings` 的 `operator/value` 规则 |
47
+ | [reference/builder/workflow/README.md](./reference/builder/workflow/README.md) | **工作流搭建**:已有应用的流程建模、节点配置、`builder flow apply` 与 `patch_nodes` |
48
+ | [reference/builder/code-integrations/README.md](./reference/builder/code-integrations/README.md) | **代码块 / Q-Linker 配置**:字段别名、绑定、输入字段插入和配置排障 |
49
+ | [reference/QINGFLOW_CLI_ADMIN_CHEATSHEET.md](./reference/core/QINGFLOW_CLI_ADMIN_CHEATSHEET.md) | **管理员**:检索、导入等与成员差异(主技能正文不展开) |
50
+ | [reference/QINGFLOW_CLI_EXPLORATION_REPORT.md](./reference/core/QINGFLOW_CLI_EXPLORATION_REPORT.md) | CLI 实测与 argparse/服务端对齐备忘 |
51
+ | [reference/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md](./reference/core/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md) | **可选**:全角色「一次可复制」分项模板;默认执行技能**不必**打开 |
52
+ | [scripts/find-app-by-keyword.py](./scripts/find-app-by-keyword.py) | **旧版 CLI 兼容脚本**:当 `app list --query` 不可用时,用 `app list` 本地解析;默认优先使用 `qingflow --json app list --query <关键词>` |
53
+ | [scripts/builder-package-from-app-list.py](./scripts/builder-package-from-app-list.py) | **旧版 CLI 兼容脚本**:当 `builder package list` 不可用时,才从 `app list` 曲线反查 **`package_name` / `resolve-id`**;新版默认直接用 `qingflow --json builder package list --query <关键词>` |
17
54
  ## CLI 执行与 JSON 落盘
18
55
 
19
56
  1. 已知业务目标时直接执行目标命令,不固定前置 `auth whoami`、`workspace get` 或 `workspace select`。只有业务命令明确返回未登录、会话失效或工作区错误后,才恢复上下文并重试原命令。
@@ -377,6 +377,16 @@ CLI 将参数与文件内容拼装为服务层载荷,**顶层键**如下:
377
377
  - `card`:必须配置 `detail`,并在卡片行操作明确时配置 `list`;核心操作视图不要只给 `header`。
378
378
  - `gantt`:不适合作为唯一核心操作视图;需要快捷按钮时同时提供配套 table/board 视图,在该操作视图中配置 `list` 和 `detail`。
379
379
 
380
+ ### 6.4.1 绑定已有自定义按钮(`buttons`,查重优先)
381
+
382
+ 视图声明里的 `buttons[]` 只做绑定(引用应用级自定义按钮的 `button_id`),不会创建按钮本体。绑定前必须查重,不要重复创建同名按钮:
383
+
384
+ 1. **查询**:`qingflow --json builder button get --app-key APP --button-text 按钮文案`(MCP `app_get_buttons` 传 `button_text`),按文案精确匹配。
385
+ 2. **有则复用**:唯一命中 → 取返回的 `button_id` 直接写进 `buttons[]`;同名多个 → 用 `button_id` 精确指定(避免歧义)。
386
+ 3. **没有再创建**:`builder button apply` 创建(其自身也按文案去重),拿到 `button_id` 后回到视图声明绑定。
387
+
388
+ 按钮本体维护(样式/跨视图复用/删除本体)见 [80-buttons-associated-resources.md](./80-buttons-associated-resources.md)。
389
+
380
390
  ### 6.5 关联视图/报表
381
391
 
382
392
  新版默认不要通过 `app_views_apply.associated_resources` 配置关联资源。应用级关联资源池和视图展示开关统一走 **`app_associated_resources_apply`** / CLI **`builder associated-resource apply`**。
@@ -510,6 +520,8 @@ qingflow builder views apply \
510
520
  | 现象 | 处理 |
511
521
  |------|------|
512
522
  | 列校验失败 / 无有效业务列 | **`app get fields`** 核对 **`name`**;避免 `columns` 仅含 **§7.1 系统列** |
523
+ | 响应含 **`STALE_FIELD_REFERENCE`** / **`STALE_OPTION_REFERENCE`** 警告 | 声明引用了表单中已删除的字段/选项(后端保存视图本就原样透传);apply 已照写并在 `warnings` 留痕,重放/迁移场景属预期,人工整理时再清理死引用 |
524
+ | 系统字段作标题/时间轴 | `title_que_id`/甘特 `start_que_id` 等可直接用系统字段 id(如 2=提交时间),与 `columns` 系统列同规则 |
513
525
  | 重名视图 / 删错 | **`app get views`**;更新时带 **`view_key`** |
514
526
  | 过滤器未生效疑虑 | 看响应 **`verification.view_filters_verified`**;必要时再 **`app get views`** |
515
527
  | 查询条件未生效疑虑 | 看响应 **`verification.view_query_conditions_verified`**;不要把 `query_conditions` 当 `filters` |
@@ -23,6 +23,7 @@ app get -> app get views/charts/buttons/associated resources -> apply button or
23
23
 
24
24
  ## Custom buttons
25
25
 
26
+ - **查重优先(硬规则)**:创建任何自定义按钮前,先 `builder button get --app-key APP --button-text 文案`(MCP `app_get_buttons` 传 `button_text`)按文案精确查重。唯一命中 → 直接复用其 `button_id`,不要创建;未命中 → 再走 `builder button apply` 创建(apply 本身也按 `button_text` 去重:无 `button_id` 时唯一同名匹配会转为更新,双保险);同名多个 → 必须传 `button_id` 精确指定。禁止每次都新建同名按钮。
26
27
  - For normal view-level business actions, prefer `builder views apply` with `action_buttons`.
27
28
  - Use this standalone button tool for advanced cases: deleting button bodies, reusing the same button across many views, bulk reordering bindings, changing style independently, or configuring qRobot/wings when the user provides exact config.
28
29
  - Use `upsert_buttons` for create/update and `patch_buttons` for small changes.
@@ -44,6 +45,7 @@ Start from user intent:
44
45
  | Need | Preferred path |
45
46
  | --- | --- |
46
47
  | Create or patch a normal business button on one view | `builder views apply` with `action_buttons` |
48
+ | 查询/查重已有自定义按钮 | `builder button get --app-key APP --button-text 文案`(唯一命中复用其 `button_id`) |
47
49
  | Set style, icon, cross-view reuse, remove button body, reorder bindings | `builder button apply` |
48
50
  | Add qRobot/wings behavior | `builder button apply`, only with user-provided exact config |
49
51
  | Clear a view's custom button bindings | `builder button apply` with `view_configs[].mode="replace"` and `buttons: []` |
@@ -11,9 +11,15 @@ from .app_form_compat import normalize_app_form_declaration
11
11
  from .builder_facade.app_form_schema import AppFormContractError, compact_schema_errors, schema_error_message
12
12
 
13
13
 
14
+ #: code_block / q_linker 输出目标字段类型。后端写链无类型白名单(FormSvcImpl
15
+ #: 原样落库),运行时 DataSvcImpl.getAnswerValueByString:选项/数字/日期有专门
16
+ #: 分支;attachment 按拆分 URL 写入(专门分支);subtable 整表组装(专门分支);
17
+ #: rich_text/data_relation 走 Question.canImport 兜底按字符串写入(不报错)。
18
+ #: 四者为存量合法配置(提取读得出、apply 必须写回,两闸门同口径)。
14
19
  _INTEGRATION_OUTPUT_TARGET_FIELD_TYPES = {
15
20
  "text",
16
21
  "long_text",
22
+ "rich_text",
17
23
  "number",
18
24
  "amount",
19
25
  "date",
@@ -21,6 +27,9 @@ _INTEGRATION_OUTPUT_TARGET_FIELD_TYPES = {
21
27
  "single_choice",
22
28
  "single_select",
23
29
  "multi_select",
30
+ "attachment",
31
+ "data_relation",
32
+ "subtable",
24
33
  }
25
34
  _Q_LINKER_TEMPLATE_RE = re.compile(r"\{\{(?P<key>[^{}\s]+)\}\}")
26
35
  _FORMULA_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
@@ -230,13 +239,20 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
230
239
  if isinstance(field, dict):
231
240
  top_fields.append((f"{row_path}/{field_index}", field))
232
241
  def resolve_local_selector(selector: Any) -> tuple[str, tuple[str, dict[str, Any]] | None]:
233
- """Resolve against the declared body, preserving backend-dependent ID-only cases."""
242
+ """Resolve against the declared body, preserving backend-dependent ID-only cases.
243
+
244
+ 返回态:resolved(唯一定位)/ invalid(定位失败)/ ambiguous(方案 A:name-only
245
+ 命中多个同名字段,调用方须报 AMBIGUOUS_FIELD_SELECTOR)/ unknown(存在未命名
246
+ 字段,后端侧再判)/ schema(结构不完整,交给 Schema 校验)。
247
+ """
234
248
  if not isinstance(selector, dict):
235
249
  return "schema", None
236
250
  raw_que_id = selector.get("queId")
237
251
  que_id = raw_que_id if isinstance(raw_que_id, int) and not isinstance(raw_que_id, bool) else None
238
252
  raw_name = selector.get("name")
239
- name = raw_name if isinstance(raw_name, str) and raw_name else ""
253
+ # name 两侧 strip:存量字段名带前导/尾随空格,queId 已同时给出不构成歧义
254
+ # (与 compiler 侧三个 resolve 函数同口径)。
255
+ name = raw_name.strip() if isinstance(raw_name, str) and raw_name.strip() else ""
240
256
  if raw_que_id is not None and que_id is None:
241
257
  return "schema", None
242
258
  if raw_name is not None and not name:
@@ -245,21 +261,38 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
245
261
  matches = [(path, field) for path, field in top_fields if field.get("queId") == que_id]
246
262
  if len(matches) != 1:
247
263
  return "invalid", None
248
- declared_name = str(matches[0][1].get("name") or "")
264
+ declared_name = str(matches[0][1].get("name") or "").strip()
249
265
  if name and declared_name and name != declared_name:
250
266
  return "invalid", None
251
267
  return "resolved", matches[0]
252
268
  if name:
253
- matches = [(path, field) for path, field in top_fields if str(field.get("name") or "") == name]
269
+ matches = [(path, field) for path, field in top_fields if str(field.get("name") or "").strip() == name]
254
270
  if len(matches) == 1:
255
271
  return "resolved", matches[0]
256
272
  if len(matches) > 1:
257
- return "invalid", None
273
+ return "ambiguous", None
258
274
  if any(not str(field.get("name") or "") for _, field in top_fields):
259
275
  return "unknown", None
260
276
  return "invalid", None
261
277
  return "schema", None
262
278
 
279
+ def ambiguity_message(selector: Any, subject: str) -> str:
280
+ name = str((selector or {}).get("name") or "").strip()
281
+ candidate_ids = sorted(
282
+ {
283
+ field.get("queId")
284
+ for _, field in top_fields
285
+ if str(field.get("name") or "").strip() == name
286
+ and isinstance(field.get("queId"), int)
287
+ and not isinstance(field.get("queId"), bool)
288
+ }
289
+ )
290
+ return (
291
+ f"AMBIGUOUS_FIELD_SELECTOR: {subject} name {name!r} matches multiple fields "
292
+ f"(candidate queIds: {candidate_ids}); duplicate names are allowed but "
293
+ "selectors must use queId to disambiguate"
294
+ )
295
+
263
296
  title_selector = spec.get("dataTitleField")
264
297
  if top_fields:
265
298
  if "dataTitleField" not in spec and "appKey" in payload:
@@ -271,6 +304,8 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
271
304
  # 显式 selector。
272
305
  if "appKey" not in payload:
273
306
  errors.append(("/spec/dataTitleField", "dataTitleField must resolve to exactly one top-level field"))
307
+ elif resolve_local_selector(title_selector)[0] == "ambiguous":
308
+ errors.append(("/spec/dataTitleField", ambiguity_message(title_selector, "dataTitleField")))
274
309
  elif resolve_local_selector(title_selector)[0] == "invalid":
275
310
  errors.append(("/spec/dataTitleField", "dataTitleField must resolve to exactly one top-level field"))
276
311
  elif title_selector is not None:
@@ -279,11 +314,15 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
279
314
  cover_selector = spec.get("dataCoverField")
280
315
  if cover_selector is not None:
281
316
  cover_resolution, cover = resolve_local_selector(cover_selector)
282
- if cover_resolution == "invalid":
317
+ if cover_resolution == "ambiguous":
318
+ errors.append(("/spec/dataCoverField", ambiguity_message(cover_selector, "dataCoverField")))
319
+ elif cover_resolution == "invalid":
283
320
  errors.append(("/spec/dataCoverField", "dataCoverField must resolve to exactly one top-level field"))
284
321
  elif cover_resolution == "resolved" and cover is not None and _field_type(cover[1]) != "attachment":
285
322
  errors.append(("/spec/dataCoverField", "dataCoverField must select a top-level attachment field"))
286
- names: dict[str, str] = {}
323
+ # 方案 A(duplicate-field-name):同父级允许同名(后端以 queId 为身份,40013
324
+ # 实测 1624 组),不再报 duplicate name;歧义在 selector 解析处以
325
+ # AMBIGUOUS_FIELD_SELECTOR 显式拒绝。
287
326
  fields_by_id = {
288
327
  field.get("queId"): field
289
328
  for _, field in top_fields
@@ -291,12 +330,6 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
291
330
  }
292
331
  output_targets: dict[str, tuple[str, str]] = {}
293
332
  for path, field in top_fields:
294
- name = str(field.get("name") or "")
295
- if name:
296
- if name in names:
297
- errors.append((f"{path}/name", f"duplicate top-level field name; first declared at {names[name]}"))
298
- else:
299
- names[name] = f"{path}/name"
300
333
  field_type = _field_type(field)
301
334
  if field_type == "data_relation":
302
335
  config = field.get("config") if isinstance(field.get("config"), dict) else {}
@@ -342,6 +375,9 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
342
375
  selector = item.get("field") if isinstance(item.get("field"), dict) else {}
343
376
  input_path = f"{path}/config/{binding_name}/inputs/{index}"
344
377
  resolution, selected = resolve_local_selector(selector)
378
+ if resolution == "ambiguous":
379
+ errors.append((f"{input_path}/field", ambiguity_message(selector, f"{binding_name}.inputs[{index}].field")))
380
+ continue
345
381
  if resolution == "invalid":
346
382
  errors.append((f"{input_path}/field", "input field must resolve to exactly one top-level field"))
347
383
  continue
@@ -393,13 +429,16 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
393
429
  )
394
430
  )
395
431
 
396
- producer = name or str(field.get("queId") or field_type)
432
+ producer = str(field.get("name") or "") or str(field.get("queId") or field_type)
397
433
  for index, item in enumerate(binding.get("outputs") or []):
398
434
  if not isinstance(item, dict):
399
435
  continue
400
436
  selector = item.get("targetField")
401
437
  target_path = f"{path}/config/{binding_name}/outputs/{index}/targetField"
402
438
  resolution, selected = resolve_local_selector(selector)
439
+ if resolution == "ambiguous":
440
+ errors.append((target_path, ambiguity_message(selector, f"{binding_name}.outputs[{index}].targetField")))
441
+ continue
403
442
  if resolution == "invalid":
404
443
  errors.append((target_path, "targetField must resolve to exactly one top-level field"))
405
444
  continue
@@ -18,6 +18,7 @@ from ..tools.app_tools import AppTools
18
18
  from ..tools.package_tools import PackageTools
19
19
  from .app_form_schema import AppFormContractError, AppFormSchemaContext, AppFormSchemaRegistry
20
20
  from .service import (
21
+ AmbiguousFieldSelector,
21
22
  _coerce_any_int,
22
23
  _compile_code_block_binding_fields,
23
24
  _compile_q_linker_binding_fields,
@@ -722,14 +723,26 @@ class AppFormNormalizer:
722
723
  ]
723
724
  if fields:
724
725
  body.append({"kind": "row", "fields": fields})
726
+ # 空表单 + 悬空 dataTitleQueId/dataCoverQueId(模板安装出的空壳应用常见:
727
+ # 后端 GET 原样吐出指向已不存在字段的 id)读面 canonical 化为 null——
728
+ # 写回 null 后端 normalizeDataTitleQueId 兜底、空表单落库等价,避免
729
+ # validate/apply 的 INVALID_DATA_TITLE_FIELD 拒掉自家写面(round-trip 断裂)。
730
+ has_fields = any(
731
+ bool(block.get("fields")) for block in body if block.get("kind") == "row"
732
+ ) or any(
733
+ bool(row.get("fields"))
734
+ for block in body
735
+ if block.get("kind") == "section"
736
+ for row in block.get("rows") or []
737
+ )
725
738
  resource = {
726
739
  "apiVersion": context.api_version,
727
740
  "appKey": app_key,
728
741
  "spec": {
729
742
  "appName": _compat_label(form.get("formTitle"), "App", app_key),
730
743
  "appDescription": form.get("formDesc") or None,
731
- "dataTitleField": _selector(title_id, field_names.get(title_id or 0)) if title_id is not None else None,
732
- "dataCoverField": _selector(cover_id, field_names.get(cover_id or 0)) if cover_id is not None else None,
744
+ "dataTitleField": _selector(title_id, field_names.get(title_id or 0)) if title_id is not None and has_fields else None,
745
+ "dataCoverField": _selector(cover_id, field_names.get(cover_id or 0)) if cover_id is not None and has_fields else None,
733
746
  "attachments": self._attachments(form.get("formAttach")),
734
747
  "serialNumber": self._serial_number(form.get("serialNumType"), form.get("serialNumConfig")),
735
748
  "body": body,
@@ -1743,6 +1756,8 @@ class AppFormCompiler:
1743
1756
  relation_context["questionRelations"] = relations
1744
1757
  internal_fields, relations = _compile_q_linker_binding_fields(fields=internal_fields, current_schema=relation_context)
1745
1758
  self._validate_unique_relation_targets(relations, internal_fields)
1759
+ except AmbiguousFieldSelector as error:
1760
+ raise AppFormFailure("AMBIGUOUS_FIELD_SELECTOR", str(error), "CONTRACT_PRECHECK", details={"candidateQueIds": error.candidates}) from error
1746
1761
  except (KeyError, ValueError) as error:
1747
1762
  raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", str(error), "CONTRACT_PRECHECK") from error
1748
1763
  self._assign_subfield_temp_ids(internal_fields)
@@ -2073,20 +2088,15 @@ class AppFormCompiler:
2073
2088
  ) -> tuple[JSONObject | None, JSONObject | None]:
2074
2089
  body = spec.get("body") or []
2075
2090
  fields = list(_iter_fields(body))
2076
- names: set[str] = set()
2091
+ # 方案 A(duplicate-field-name):同父级允许同名——后端以 queId 为字段身份
2092
+ # (40013 实测 1624 组同名),get 提出的合法形态必须能写回;name 解析的
2093
+ # 歧义由各 selector resolver 以 AMBIGUOUS_FIELD_SELECTOR 显式拒绝,
2094
+ # 不在目标校验层做 name 唯一性强校验。
2077
2095
  for field in fields:
2078
- name = str(field.get("name") or "")
2079
- if name in names:
2080
- raise AppFormFailure("DUPLICATE_FIELD_NAME", f"duplicate top-level field name: {name}", "CONTRACT_PRECHECK")
2081
- names.add(name)
2082
2096
  if _field_type(field) == "subtable":
2083
- sub_names: set[str] = set()
2084
2097
  for subfield in field.get("subfields") or []:
2085
2098
  if _field_type(subfield) not in SUBFIELD_TYPES:
2086
2099
  raise AppFormFailure("INVALID_SPEC", "unsupported subtable field type", "CONTRACT_PRECHECK", details={"allowedTypes": sorted(SUBFIELD_TYPES)})
2087
- if subfield.get("name") in sub_names:
2088
- raise AppFormFailure("DUPLICATE_FIELD_NAME", f"duplicate subtable field name: {subfield.get('name')}", "CONTRACT_PRECHECK")
2089
- sub_names.add(str(subfield.get("name") or ""))
2090
2100
  title_selector = spec.get("dataTitleField")
2091
2101
  data_title: JSONObject | None = None
2092
2102
  if fields:
@@ -2145,6 +2155,8 @@ class AppFormCompiler:
2145
2155
  relation_context["questionRelations"] = relations
2146
2156
  internal_fields, relations = _compile_q_linker_binding_fields(fields=internal_fields, current_schema=relation_context)
2147
2157
  self._validate_unique_relation_targets(relations, internal_fields)
2158
+ except AmbiguousFieldSelector as error:
2159
+ raise AppFormFailure("AMBIGUOUS_FIELD_SELECTOR", str(error), "COMPILE", details={"candidateQueIds": error.candidates}) from error
2148
2160
  except (KeyError, ValueError) as error:
2149
2161
  raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", str(error), "COMPILE") from error
2150
2162
  self._assign_subfield_temp_ids(internal_fields)
@@ -2626,8 +2638,7 @@ class AppFormCompiler:
2626
2638
 
2627
2639
  def _reference_config(self, profile: str, config: JSONObject, template: JSONObject | None = None) -> JSONObject:
2628
2640
  target_app_key = str(config.get("targetAppKey") or "")
2629
- target_form = self._read_form(profile, target_app_key, being_draft=False)
2630
- candidates = _backend_question_candidates(target_form)
2641
+ candidates, _target_form = self._target_form_candidates(profile, target_app_key)
2631
2642
  explicit_display = isinstance(config.get("displayFields"), list)
2632
2643
  deprecated_keys = [
2633
2644
  key
@@ -2714,8 +2725,7 @@ class AppFormCompiler:
2714
2725
  target_app_key = str(relation.get("targetAppKey") or "")
2715
2726
  if not target_app_key:
2716
2727
  raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "select scope relation requires targetAppKey", "COMPILE")
2717
- target_form = self._read_form(profile, target_app_key, being_draft=False)
2718
- candidates = _backend_question_candidates(target_form)
2728
+ candidates, target_form = self._target_form_candidates(profile, target_app_key)
2719
2729
  displayed = _resolve_backend_selector(relation.get("displayedField") or {}, candidates, "selectScopeRelation.displayedField")
2720
2730
  payload: JSONObject = {
2721
2731
  "queId": _positive_int(internal.get("que_id")) or int(internal.get("que_temp_id") or 0),
@@ -2737,14 +2747,27 @@ class AppFormCompiler:
2737
2747
  def _resolve_subfield_runtime_id(selector: JSONObject, internal_fields: list[JSONObject], location: str) -> int:
2738
2748
  if not isinstance(selector, dict):
2739
2749
  raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must be an object", "COMPILE")
2750
+ # 同 _resolve_backend_selector:name 两侧 strip(存量字段名带前后空格,queId 兜底唯一性)。
2740
2751
  que_id = _positive_int(selector.get("queId"))
2741
- name = str(selector.get("name") or "")
2752
+ name = str(selector.get("name") or "").strip()
2742
2753
  matches = [
2743
2754
  item for item in internal_fields
2744
2755
  if (que_id is None or _positive_int(item.get("que_id")) == que_id)
2745
- and (not name or str(item.get("name") or "") == name)
2756
+ and (not name or str(item.get("name") or "").strip() == name)
2746
2757
  ]
2747
2758
  if len(matches) != 1:
2759
+ if que_id is None and matches:
2760
+ raise AppFormFailure(
2761
+ "AMBIGUOUS_FIELD_SELECTOR",
2762
+ f"{location} matches multiple subfields with the same name; use queId to disambiguate",
2763
+ "COMPILE",
2764
+ details={
2765
+ "selector": selector,
2766
+ "candidateQueIds": sorted(
2767
+ {_positive_int(item.get("que_id")) or 0 for item in matches}
2768
+ ),
2769
+ },
2770
+ )
2748
2771
  raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must resolve to exactly one subfield", "COMPILE", details={"selector": selector, "matches": len(matches)})
2749
2772
  return _positive_int(matches[0].get("que_id")) or int(matches[0].get("que_temp_id") or 0)
2750
2773
 
@@ -2796,8 +2819,7 @@ class AppFormCompiler:
2796
2819
  source_fields = available_internal_fields
2797
2820
  source_fields_are_backend = False
2798
2821
  if source.get("kind") == "app":
2799
- target_form = self._read_form(profile, str(source.get("appKey") or ""), being_draft=False)
2800
- source_fields = _backend_question_candidates(target_form)
2822
+ source_fields, _target_form = self._target_form_candidates(profile, str(source.get("appKey") or ""))
2801
2823
  source_fields_are_backend = True
2802
2824
  target = _resolve_backend_selector(source.get("field") or {}, source_fields, "source.field")
2803
2825
  relation.update(
@@ -3237,6 +3259,34 @@ class AppFormCompiler:
3237
3259
  raise AppFormFailure("FORM_READ_FAILED", "backend returned an invalid form", "READ")
3238
3260
  return value
3239
3261
 
3262
+ def _target_form_candidates(self, profile: str, app_key: str) -> tuple[list[JSONObject], JSONObject]:
3263
+ """跨表引用解析候选集:目标表 草稿∪发布 并集(queId 为 key,草稿覆盖)。
3264
+
3265
+ 后端写链对跨表 queId(referQuestions/displayedQueId/matchRules 源字段)无
3266
+ 存在性校验、原样落库(FormSvcImpl.updateFormInfo);链上唯一读目标字段的
3267
+ Formula.matchRulesToFormula 取 question∪draft_question 且草稿覆盖,编辑器
3268
+ 候选接口(/question/{appKey}/relation)同样支持 beingDraft 草稿态。get 按
3269
+ 草稿姿态提取声明,apply 只读发布会漏掉目标表「只加在草稿、从未发布」的字段
3270
+ (round-trip 断裂);只读草稿又会漏掉发布后草稿里删掉的旧字段——并集与
3271
+ 后端写链语义一致。草稿读失败(目标表无草稿等)退回仅发布态。
3272
+ """
3273
+ draft_form: JSONObject = {}
3274
+ try:
3275
+ draft_form = self._read_form(profile, app_key, being_draft=True)
3276
+ except AppFormFailure:
3277
+ draft_form = {}
3278
+ published_form = self._read_form(profile, app_key, being_draft=False)
3279
+ merged: list[JSONObject] = []
3280
+ seen: set[int] = set()
3281
+ for item in [*_backend_question_candidates(draft_form), *_backend_question_candidates(published_form)]:
3282
+ que_id = _positive_int(item.get("queId"))
3283
+ if que_id is not None:
3284
+ if que_id in seen:
3285
+ continue
3286
+ seen.add(que_id)
3287
+ merged.append(item)
3288
+ return merged, (draft_form or published_form)
3289
+
3240
3290
 
3241
3291
  class AppFormReconciler:
3242
3292
  RUNTIME_VERSION = "v1alpha1"
@@ -4395,33 +4445,46 @@ def _snake_code_block(value: JSONObject) -> JSONObject:
4395
4445
 
4396
4446
 
4397
4447
  def _backend_question_candidates(form: JSONObject) -> list[JSONObject]:
4448
+ """目标表字段的解析候选集(含表格子字段及其「父表·子字段」前缀变体)。
4449
+
4450
+ 遍历与后端对齐:FormSvcImpl.fillMatchRulesList 按 supId 全局解析父表标题、
4451
+ 不看容器嵌套层级,FormQuestionSvcImpl 的候选集同样把段落内字段展开——因此
4452
+ 表格(queType=18)无论位于 formQues 顶层、baseQues 还是段落(queType=24)
4453
+ 内部,其 subQuestions 都是合法候选;段落本身不作为候选。
4454
+ """
4398
4455
  result: list[JSONObject] = []
4456
+
4457
+ def _visit(item: JSONObject) -> None:
4458
+ que_type = _positive_int(item.get("queType"))
4459
+ if que_type == 24:
4460
+ for inner in item.get("innerQuestions") or []:
4461
+ for nested in inner if isinstance(inner, list) else []:
4462
+ if isinstance(nested, dict):
4463
+ _visit(nested)
4464
+ return
4465
+ result.append(item)
4466
+ # 表格(queType=18)子字段同样是合法的目标字段:后端 data_relation
4467
+ # 运行时按 queId 解析目标字段并把表格子字段纳入 targetQuestionMap
4468
+ # (DataRelationCalService 先按 queId 查 question、再把表格的
4469
+ # subQuestions 合入)。displayedQueId 指向表格子字段是存量合法
4470
+ # 配置,提取能读出、编译必须能写回(两闸门同口径)。
4471
+ # matchRules 的筛选源字段(sourceField)在存量里带父表名前缀
4472
+ # (「父表·子字段」,_data_relation_field_name 按同形态写回)。
4473
+ if que_type == 18:
4474
+ table_title = str(item.get("queTitle") or "")
4475
+ for sub in item.get("subQuestions") or []:
4476
+ if isinstance(sub, dict):
4477
+ prefixed = dict(sub)
4478
+ prefixed["_sup_que_title"] = table_title
4479
+ result.append(prefixed)
4480
+
4399
4481
  for item in form.get("baseQues") or []:
4400
4482
  if isinstance(item, dict):
4401
- result.append(item)
4483
+ _visit(item)
4402
4484
  for row in form.get("formQues") or []:
4403
4485
  for item in row if isinstance(row, list) else []:
4404
- if not isinstance(item, dict):
4405
- continue
4406
- if _positive_int(item.get("queType")) == 24:
4407
- for inner in item.get("innerQuestions") or []:
4408
- result.extend(nested for nested in inner if isinstance(inner, list) and isinstance(nested, dict))
4409
- else:
4410
- result.append(item)
4411
- # 表格(queType=18)子字段同样是合法的目标字段:后端 data_relation
4412
- # 运行时按 queId 解析目标字段并把表格子字段纳入 targetQuestionMap
4413
- # (DataRelationCalService 先按 queId 查 question、再把表格的
4414
- # subQuestions 合入)。displayedQueId 指向表格子字段是存量合法
4415
- # 配置,提取能读出、编译必须能写回(两闸门同口径)。
4416
- # matchRules 的筛选源字段(sourceField)在存量里带父表名前缀
4417
- # (「父表·子字段」,_data_relation_field_name 按同形态写回)。
4418
- if _positive_int(item.get("queType")) == 18:
4419
- table_title = str(item.get("queTitle") or "")
4420
- for sub in item.get("subQuestions") or []:
4421
- if isinstance(sub, dict):
4422
- prefixed = dict(sub)
4423
- prefixed["_sup_que_title"] = table_title
4424
- result.append(prefixed)
4486
+ if isinstance(item, dict):
4487
+ _visit(item)
4425
4488
  return result
4426
4489
 
4427
4490
 
@@ -4451,29 +4514,58 @@ def _backend_selector_titles(item: JSONObject) -> list[str]:
4451
4514
 
4452
4515
 
4453
4516
  def _resolve_backend_selector(selector: JSONObject, candidates: list[JSONObject], location: str) -> JSONObject:
4517
+ # name 比较两侧 strip:存量字段名带前导/尾随空格(后端无空白校验),声明里的
4518
+ # name 与候选 queTitle 一个空格即失配;queId 已同时给出,空格差异不构成歧义。
4454
4519
  que_id = _positive_int(selector.get("queId"))
4455
- name = str(selector.get("name") or "")
4520
+ name = str(selector.get("name") or "").strip()
4456
4521
  matches = [
4457
4522
  item
4458
4523
  for item in candidates
4459
4524
  if (que_id is None or _positive_int(item.get("queId")) == que_id)
4460
- and (not name or name in _backend_selector_titles(item))
4525
+ and (not name or name in {title.strip() for title in _backend_selector_titles(item)})
4461
4526
  ]
4462
4527
  if len(matches) != 1:
4528
+ # 方案 A:后端允许同父级同名(queId 为身份)。name-only 命中多个时显式报
4529
+ # 歧义并给出候选 queId,禁止静默取第一个。
4530
+ if que_id is None and matches:
4531
+ raise AppFormFailure(
4532
+ "AMBIGUOUS_FIELD_SELECTOR",
4533
+ f"{location} matches multiple fields with the same name; use queId to disambiguate",
4534
+ "CONTRACT_PRECHECK",
4535
+ details={
4536
+ "selector": selector,
4537
+ "candidateQueIds": sorted(
4538
+ {_positive_int(item.get("queId")) or 0 for item in matches}
4539
+ ),
4540
+ },
4541
+ )
4463
4542
  raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must resolve to exactly one field", "CONTRACT_PRECHECK", details={"selector": selector, "matches": len(matches)})
4464
4543
  return matches[0]
4465
4544
 
4466
4545
 
4467
4546
  def _resolve_canonical_selector(selector: JSONObject, candidates: list[JSONObject], location: str) -> JSONObject:
4547
+ # 同 _resolve_backend_selector:name 两侧 strip(存量字段名带前后空格,queId 兜底唯一性)。
4468
4548
  que_id = _positive_int(selector.get("queId"))
4469
- name = str(selector.get("name") or "")
4549
+ name = str(selector.get("name") or "").strip()
4470
4550
  matches = [
4471
4551
  item
4472
4552
  for item in candidates
4473
4553
  if (que_id is None or _positive_int(item.get("queId")) == que_id)
4474
- and (not name or str(item.get("name") or "") == name)
4554
+ and (not name or str(item.get("name") or "").strip() == name)
4475
4555
  ]
4476
4556
  if len(matches) != 1:
4557
+ if que_id is None and matches:
4558
+ raise AppFormFailure(
4559
+ "AMBIGUOUS_FIELD_SELECTOR",
4560
+ f"{location} matches multiple fields with the same name; use queId to disambiguate",
4561
+ "CONTRACT_PRECHECK",
4562
+ details={
4563
+ "selector": selector,
4564
+ "candidateQueIds": sorted(
4565
+ {_positive_int(item.get("queId")) or 0 for item in matches}
4566
+ ),
4567
+ },
4568
+ )
4477
4569
  raise AppFormFailure(
4478
4570
  "INVALID_FIELD_SELECTOR",
4479
4571
  f"{location} must resolve to exactly one top-level field",
@@ -4484,14 +4576,27 @@ def _resolve_canonical_selector(selector: JSONObject, candidates: list[JSONObjec
4484
4576
 
4485
4577
 
4486
4578
  def _resolve_internal_selector(selector: JSONObject, fields: list[JSONObject], location: str) -> JSONObject:
4579
+ # 同 _resolve_backend_selector:name 两侧 strip(存量字段名带前后空格,queId 兜底唯一性)。
4487
4580
  que_id = _positive_int(selector.get("queId") or selector.get("que_id"))
4488
- name = str(selector.get("name") or "")
4581
+ name = str(selector.get("name") or "").strip()
4489
4582
  matches = [
4490
4583
  item
4491
4584
  for item in fields
4492
4585
  if (que_id is None or _positive_int(item.get("que_id")) == que_id)
4493
- and (not name or str(item.get("name") or "") == name)
4586
+ and (not name or str(item.get("name") or "").strip() == name)
4494
4587
  ]
4495
4588
  if len(matches) != 1:
4589
+ if que_id is None and matches:
4590
+ raise AppFormFailure(
4591
+ "AMBIGUOUS_FIELD_SELECTOR",
4592
+ f"{location} matches multiple fields with the same name; use queId to disambiguate",
4593
+ "COMPILE",
4594
+ details={
4595
+ "selector": selector,
4596
+ "candidateQueIds": sorted(
4597
+ {_positive_int(item.get("que_id")) or 0 for item in matches}
4598
+ ),
4599
+ },
4600
+ )
4496
4601
  raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must resolve to exactly one field", "COMPILE", details={"selector": selector, "matches": len(matches)})
4497
4602
  return matches[0]