@qingflow-tech/qingflow-cli 1.1.45-beta.4 → 1.1.45-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.
- package/README.md +3 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-cli/SKILL.md +1 -1
- package/src/qingflow_mcp/app_form_validator.py +223 -0
- package/src/qingflow_mcp/builder_facade/service.py +5 -0
- package/src/qingflow_mcp/cli/commands/builder.py +131 -8
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +0 -1
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.45-beta.
|
|
10
|
+
npm install @qingflow-tech/qingflow-cli@1.1.45-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.45-beta.
|
|
24
|
+
npx -y -p @qingflow-tech/qingflow-cli@1.1.45-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.45-beta.
|
|
65
|
+
npx -y -p @qingflow-tech/qingflow-cli@1.1.45-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.45-beta.
|
|
3
|
+
"version": "1.1.45-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
|
@@ -444,7 +444,7 @@ qingflow auth logout --forget-persisted
|
|
|
444
444
|
| **app / portal / view / chart** | `app list [--query 关键词]` → **`app get --app-key`**(`accessible_views` / `view_id`、`import_capability`)· `builder package list [--query 关键词]` → `builder package get --package-id` · `portal list` · `portal get --dash-key` · `view get --view-id`(只读视图元信息;`baseInfo` 或 `viewConfig` 只有 40002 / 40027 / 404 这类权限/缺失边界可用另一侧降级,非权限类后端错误不隐藏;解析 app 仅依赖同源 view/form 与可见应用树,不回退旧应用搜索)· `chart get --chart-id`(图表 `baseInfo` / 数据 / 配置分阶段读取;单个辅助阶段 40002 / 40027 / 404 只作为 warning,非权限类后端错误不隐藏) |
|
|
445
445
|
| **record** | `record schema insert`(新建记录 insert-ready schema,仅 `--app-key`)· **`browse`/`list`/`access`:`--app-key` + `--view-id`(缺一常 CONFIG)** · `record list --query ... [--query-field FIELD_ID] [--page-size N]` 用于候选定位/样本浏览 · `record get --record-id` 读详情页首屏上下文 · `record logs --record-id` 读完整数据/流程日志 JSONL · `record insert --items-file`(单条也是 items 一行)· `schema update --record-id [--view-id]` / **`update`**(单条 `--fields-file` **或** 批量 `--items-file`/`--dry-run`,二者勿混)· `record delete --app-key --record-id --view-id system:*` / `--record-ids-file` · `export start/direct --view-id` 仅用于明确导出文件 · **大 schema**:先 **[FIELD_DATA_TYPES](./reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md)** / **[RECORD_CREATE](./reference/record/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md)** / **[RECORD_UPDATE](./reference/record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md)** / **[RECORD_DELETE](./reference/record/QINGFLOW_CLI_RECORD_DELETE_WORKFLOW.md)**,再 `-h` |
|
|
446
446
|
| **import** | `record schema import?` · `import template` → `verify` → `repair?` → `start`(**`being-enter-auditing` 必填**)→ `status`(**三者 selector 只选一**);导入权限预检的 `baseInfo` 只有 40002 / 40027 / 404 可降级为 unknown,非权限错误不能当成无导入权或继续执行;**链路与本机目录**只靠本节不够 → **[RECORD_IMPORT](./reference/record/QINGFLOW_CLI_RECORD_IMPORT_WORKFLOW.md)** |
|
|
447
|
-
| **builder** | **搭建/系统化配置**:先按 **[INDEX](./reference/00-INDEX.md)** / **[BUILDER](./reference/builder/README.md)** 选主路径,再读 `builder contract`。**完整系统/应用包**:`builder package apply --config-file` → 每个应用依次执行 `builder app-form schema`、更新时 `get --app-key APP_KEY --being-draft`、`validate --schema-version VERSION --file DECLARATION.json`、`apply --file DECLARATION.json`(固定版本、完整声明、记录返回的 `appKey`)→ 视图(按钮随视图 `action_buttons` 写)→ 门户(缺报表时写 `chart`)→ 发布/回读。旧 `schema apply` / `layout apply` 不在当前 CLI parser 中。超时、`partial_success`、`write_executed=true`、`safe_to_retry=false` 或回读不完整时先读取 AppForm draft/published,再恢复,不得重复创建或创建 V2/测试/随机后缀绕过重名。**单应用**:新建和更新遵循同一 AppForm CLI 链路,再按需求配置流程/视图。更新动作归入对应资源文档:AppForm [30](./reference/builder/30-schema-fields.md)、视图 [50](./reference/builder/50-views.md)、报表 [60](./reference/builder/60-charts.md)、门户 [70](./reference/builder/70-portal.md)、关联资源/非主链路按钮维护 [80](./reference/builder/80-buttons-associated-resources.md)、流程 [90](./reference/builder/90-workflow.md)、发布 [99](./reference/builder/99-publish-verify.md)
|
|
447
|
+
| **builder** | **搭建/系统化配置**:先按 **[INDEX](./reference/00-INDEX.md)** / **[BUILDER](./reference/builder/README.md)** 选主路径,再读 `builder contract`。**完整系统/应用包**:`builder package apply --config-file` → 每个应用依次执行 `builder app-form schema`、更新时 `get --app-key APP_KEY --being-draft`、`validate --schema-version VERSION --file DECLARATION.json`、`apply --file DECLARATION.json`(固定版本、完整声明、记录返回的 `appKey`)→ 视图(按钮随视图 `action_buttons` 写)→ 门户(缺报表时写 `chart`)→ 发布/回读。旧 `schema apply` / `layout apply` 不在当前 CLI parser 中。超时、`partial_success`、`write_executed=true`、`safe_to_retry=false` 或回读不完整时先读取 AppForm draft/published,再恢复,不得重复创建或创建 V2/测试/随机后缀绕过重名。**单应用**:新建和更新遵循同一 AppForm CLI 链路,再按需求配置流程/视图。更新动作归入对应资源文档:AppForm [30](./reference/builder/30-schema-fields.md)、视图 [50](./reference/builder/50-views.md)、报表 [60](./reference/builder/60-charts.md)、门户 [70](./reference/builder/70-portal.md)、关联资源/非主链路按钮维护 [80](./reference/builder/80-buttons-associated-resources.md)、流程 [90](./reference/builder/90-workflow.md)、发布 [99](./reference/builder/99-publish-verify.md)。**AI 公式/代码块校验**:`builder app-form validate-formula --app-key KEY --formula '<公式>' --test-case <文件路径>`(`--formula` 内联;`--test-case` **只收文件路径或 `-` stdin,不收内联 JSON**)与 `builder app-form validate-code-block --app-key KEY --code <内联文本|@文件路径> --test-case <文件路径>`(本地静态 + 后端真跑,输出 `staticErrors` / `runResult` / `runError`);两者是公式/代码块生成场景的真实校验命令,校验结论必须来自其真实执行,不得以本地模拟替代。代码内占位符 `qf_field.{字段名$$queId$$}`(**字段名不带引号**,带引号会被 CLI 自动剥离但勿依赖)与 `keyQueId` 均使用**十进制** queId(CLI 真跑前自动编码为运行时形态;错误回显自动还原为十进制,勿逆向编码 id)。test-case values 按类型:文本/电话=字符串、数字=数字字面量、日期=yyyy-MM-dd、附件=URL 字符串数组、单选/下拉/多选=选项文案(CLI 自动译为选项 id)、成员/部门=不可提供(代码判空,覆盖校验豁免)。 |
|
|
448
448
|
| **task**(须落盘见上文) | `task list [--task-box][--flow-status][--app-key][--query] --page --page-size`(**枚举、`--query`、与 `record` 视图口径勿混**:见上文 SOP · **[TASK_CONTEXT](./reference/task/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md)**)· **`get`/`log`/`report`/`action`**:默认只传 `task list` 返回的 **`data.items[].task_id`**;`report` 另须必选 **`--report-id`**;`action` **无 `--dry-run`** |
|
|
449
449
|
|
|
450
450
|
**高发必败**:旧应用搜索入口 · **`record list`/`browse`/`access` 缺 `--view-id`** · **`task report` 缺 `--report-id`** · **`record update` 批量与单条参数混用**。
|
|
@@ -541,3 +541,226 @@ def validate_code_block_static(code: str) -> list[dict[str, str]]:
|
|
|
541
541
|
}
|
|
542
542
|
)
|
|
543
543
|
return errors
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# ---------------------------------------------------------------------------
|
|
547
|
+
# 代码块字段占位符编码
|
|
548
|
+
#
|
|
549
|
+
# 后端 codeBlockTest 运行时通过 CustomPushContentConverter.getQueId 解码代码里的
|
|
550
|
+
# ``qf_field.{label$$<encoded>$$}`` 段(私有混淆编码,非十进制),再按解码出的十进制
|
|
551
|
+
# queId 匹配 --test-case 的 keyQueId 填值;匹配不上会静默跳过(占位符原样留跑)。
|
|
552
|
+
# AI 生成的代码按十进制 queId 书写(与 skill/编辑器上下文一致),本模块在真跑前把
|
|
553
|
+
# 纯数字的 ``$$<digits>$$`` 段编码为运行时形态——与后端采纳链路(encryptQueId)同一
|
|
554
|
+
# 编码、同一"纯数字才编码"规则,已编码(含字母)的段原样保留,避免双重编码。
|
|
555
|
+
# ---------------------------------------------------------------------------
|
|
556
|
+
|
|
557
|
+
_ENC_KEY = 1000013
|
|
558
|
+
_INT_TO_HEX = "0123456789ABCDEF"
|
|
559
|
+
_DECIMAL_PLACEHOLDER = re.compile(r"\$\$(-?\d+)\$\$")
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def encode_que_id(que_id: int) -> str:
|
|
563
|
+
"""Java ``CustomPushContentConverter.encryptQueId`` 的逐行为等价移植。"""
|
|
564
|
+
have_prefix = que_id < 0
|
|
565
|
+
abs_id = abs(que_id)
|
|
566
|
+
ran = abs_id * 17
|
|
567
|
+
inp = str(abs_id)
|
|
568
|
+
seeds: list[int] = []
|
|
569
|
+
salt_sum = 0
|
|
570
|
+
for _ in range(max(0, 4 - len(inp))):
|
|
571
|
+
tmp = (ran % 20) + 71
|
|
572
|
+
ran = ((ran + 13) * 17) % _ENC_KEY
|
|
573
|
+
salt_sum += tmp
|
|
574
|
+
seeds.append(tmp)
|
|
575
|
+
result = [(salt_sum + i + int(ch, 16)) % 15 for i, ch in enumerate(inp)]
|
|
576
|
+
for seed in seeds:
|
|
577
|
+
pos = (ran + seed) % len(result)
|
|
578
|
+
ran = ((ran + 13) * 17) % _ENC_KEY
|
|
579
|
+
result.insert(pos, seed)
|
|
580
|
+
encoded = "".join(chr(value) if value > 15 else _INT_TO_HEX[value] for value in result)
|
|
581
|
+
return ("-" if have_prefix else "") + encoded
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def encode_code_block_field_ids(code: str) -> str:
|
|
585
|
+
"""把代码里的十进制 queId 占位符段编码为后端运行时形态。
|
|
586
|
+
|
|
587
|
+
仅处理纯数字(可带负号)的 ``$$...$$`` 段;混有字母的段视为已编码,原样保留。
|
|
588
|
+
"""
|
|
589
|
+
if not isinstance(code, str):
|
|
590
|
+
return code
|
|
591
|
+
return _DECIMAL_PLACEHOLDER.sub(
|
|
592
|
+
lambda match: f"$${encode_que_id(int(match.group(1)))}$$", code
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def referenced_code_block_field_ids(code: str) -> set[int]:
|
|
597
|
+
"""提取代码里以十进制 queId 引用的字段 id 集合(供 test-case 覆盖校验)。
|
|
598
|
+
|
|
599
|
+
只统计纯数字(可带负号)的 ``$$...$$`` 段;已编码(含字母)段解码依赖后端实现,
|
|
600
|
+
不在此覆盖校验范围内。
|
|
601
|
+
"""
|
|
602
|
+
if not isinstance(code, str):
|
|
603
|
+
return set()
|
|
604
|
+
return {int(match.group(1)) for match in _DECIMAL_PLACEHOLDER.finditer(code)}
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
# ---------------------------------------------------------------------------
|
|
608
|
+
# 代码块校验的占位符容错与回显还原
|
|
609
|
+
#
|
|
610
|
+
# 现场问题(agent 实测):
|
|
611
|
+
# 1. 模型常把占位符写成 qf_field.{"字段名$$id$$"} —— 字段名带引号。后端替换正则要求
|
|
612
|
+
# 结尾恰为 `$$}`,带引号变成 `$$"}` 匹配失败,占位符原样留在代码里直接语法错误。
|
|
613
|
+
# 这里在编码归一化前剥离字段名两侧引号,带引号写法自动修正。
|
|
614
|
+
# 2. CLI 把 `$$十进制$$` 编码为混淆形态后,校验失败的错误回显里是编码 id,模型认不出
|
|
615
|
+
# 是自己写的 queId,会试图逆向"哈希"浪费大量轮次。这里记录 编码→十进制 映射,
|
|
616
|
+
# 输出前把错误文本里的编码段还原为十进制。
|
|
617
|
+
# ---------------------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
_QUOTED_PLACEHOLDER = re.compile(r'(qf_field\.\{)["\']([^$\'"{]+?)\$\$(-?\d+)\$\$["\'](\})')
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def strip_placeholder_quotes(code: str) -> tuple[str, int]:
|
|
623
|
+
"""剥离占位符字段名两侧引号:qf_field.{"名$$id$$"} → qf_field.{名$$id$$}。
|
|
624
|
+
|
|
625
|
+
Returns:
|
|
626
|
+
(修正后的代码, 修正处数)
|
|
627
|
+
"""
|
|
628
|
+
if not isinstance(code, str):
|
|
629
|
+
return code, 0
|
|
630
|
+
count = 0
|
|
631
|
+
|
|
632
|
+
def _strip(match: re.Match[str]) -> str:
|
|
633
|
+
nonlocal count
|
|
634
|
+
count += 1
|
|
635
|
+
return f"{match.group(1)}{match.group(2)}$${match.group(3)}$$}}"
|
|
636
|
+
|
|
637
|
+
return _QUOTED_PLACEHOLDER.sub(_strip, code), count
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def build_encoded_id_map(code: str) -> dict[str, str]:
|
|
641
|
+
"""对代码里的纯数字占位段构建 编码形态→十进制 映射(用于错误回显还原)。"""
|
|
642
|
+
if not isinstance(code, str):
|
|
643
|
+
return {}
|
|
644
|
+
return {
|
|
645
|
+
encode_que_id(int(match.group(1))): match.group(1)
|
|
646
|
+
for match in _DECIMAL_PLACEHOLDER.finditer(code)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def restore_decimal_ids_in_text(text: str, id_map: dict[str, str]) -> str:
|
|
651
|
+
"""把文本里出现的 `$$<编码>$$` 还原为 `$$<十进制>$$`(错误回显可读化)。"""
|
|
652
|
+
if not isinstance(text, str) or not id_map:
|
|
653
|
+
return text
|
|
654
|
+
for encoded, decimal in id_map.items():
|
|
655
|
+
text = text.replace(f"$${encoded}$$", f"$${decimal}$$")
|
|
656
|
+
return text
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
# 选择类字段 test-case 值翻译:后端只认选项 optId(数字),传文案静默变 null;
|
|
660
|
+
# 模型不可能知道 optId,这里按字段定义把"选项文案"翻译成 optId。
|
|
661
|
+
_SELECT_FIELD_TYPES = {"single_choice", "single_select", "multi_select"}
|
|
662
|
+
# 值不可推导的字段类型(成员/部门要 uid/部门id,模型无从得知):覆盖校验跳过。
|
|
663
|
+
_UNDERIVABLE_FIELD_TYPES = {"member", "department"}
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def translate_select_test_case_values(
|
|
667
|
+
fields: list[dict] | None, key_que_values: list[dict]
|
|
668
|
+
) -> tuple[list[dict], list[dict[str, str]], list[str]]:
|
|
669
|
+
"""把选择类字段的"选项文案"值翻译为 optId。
|
|
670
|
+
|
|
671
|
+
Args:
|
|
672
|
+
fields: 字段定义清单(含 que_id/type/option_details;可为 None 表示未取到)。
|
|
673
|
+
key_que_values: 已规范化的 test-case。
|
|
674
|
+
Returns:
|
|
675
|
+
(翻译后的 test-case, 翻译记录[{queId, from, to}], 无法翻译的选项错误清单)
|
|
676
|
+
"""
|
|
677
|
+
if not fields:
|
|
678
|
+
return key_que_values, [], []
|
|
679
|
+
option_lookup: dict[int, dict[str, object]] = {}
|
|
680
|
+
type_by_que: dict[int, str] = {}
|
|
681
|
+
for field in fields:
|
|
682
|
+
if not isinstance(field, dict):
|
|
683
|
+
continue
|
|
684
|
+
que_id = field.get("que_id") or field.get("queId")
|
|
685
|
+
if not isinstance(que_id, int):
|
|
686
|
+
continue
|
|
687
|
+
field_type = str(field.get("type") or "")
|
|
688
|
+
type_by_que[que_id] = field_type
|
|
689
|
+
details = field.get("option_details")
|
|
690
|
+
if isinstance(details, list):
|
|
691
|
+
by_value = {
|
|
692
|
+
str(item.get("value") or "").strip(): item.get("id")
|
|
693
|
+
for item in details
|
|
694
|
+
if isinstance(item, dict) and item.get("id") is not None
|
|
695
|
+
}
|
|
696
|
+
if by_value:
|
|
697
|
+
option_lookup[que_id] = by_value
|
|
698
|
+
|
|
699
|
+
translated = [dict(item) for item in key_que_values]
|
|
700
|
+
changes: list[dict[str, str]] = []
|
|
701
|
+
errors: list[str] = []
|
|
702
|
+
for item in translated:
|
|
703
|
+
key = item.get("keyQueId")
|
|
704
|
+
if not isinstance(key, int) or key not in option_lookup:
|
|
705
|
+
continue
|
|
706
|
+
by_value = option_lookup[key]
|
|
707
|
+
new_values: list[object] = []
|
|
708
|
+
for value in item.get("values") or []:
|
|
709
|
+
text = str(value).strip()
|
|
710
|
+
if text in by_value:
|
|
711
|
+
new_values.append(by_value[text])
|
|
712
|
+
changes.append({"queId": str(key), "from": text, "to": str(by_value[text])})
|
|
713
|
+
elif str(value).isdigit() and int(value) in set(by_value.values()):
|
|
714
|
+
new_values.append(int(value))
|
|
715
|
+
else:
|
|
716
|
+
errors.append(
|
|
717
|
+
f"field queId {key}: 选项「{text}」不存在,可选:{'/'.join(by_value.keys())}"
|
|
718
|
+
)
|
|
719
|
+
new_values.append(value)
|
|
720
|
+
item["values"] = new_values
|
|
721
|
+
return translated, changes, errors
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
# ---------------------------------------------------------------------------
|
|
725
|
+
# 公式校验的引用提取与选择类值翻译
|
|
726
|
+
#
|
|
727
|
+
# 实测(真实后端 /ai/coding/formula/validate):
|
|
728
|
+
# - 缺值字段静默变空(CONCAT 直接跳过)→ 空洞通过,需要覆盖门;
|
|
729
|
+
# - 单选/多选必须传 **[optId 数组]**:文案/optId 标量均报错;返回值为 optId 串(非文案);
|
|
730
|
+
# - 日期字段裸引用报错,套日期函数(YEAR 等)+ "yyyy-MM-dd" 字符串可用。
|
|
731
|
+
# ---------------------------------------------------------------------------
|
|
732
|
+
|
|
733
|
+
_FORMULA_REFERENCE = re.compile(r"#queId_(\d+)\$")
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def referenced_formula_field_ids(formula: str) -> set[int]:
|
|
737
|
+
"""提取公式 `#queId_<id>$` 引用的字段 id 集合(供覆盖校验)。"""
|
|
738
|
+
if not isinstance(formula, str):
|
|
739
|
+
return set()
|
|
740
|
+
return {int(m.group(1)) for m in _FORMULA_REFERENCE.finditer(formula)}
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def translate_formula_test_case_values(
|
|
744
|
+
fields: list[dict] | None, test_case: dict
|
|
745
|
+
) -> tuple[dict, list[dict[str, str]], list[str]]:
|
|
746
|
+
"""公式 testCase 值归一化(全类型实测契约)。
|
|
747
|
+
|
|
748
|
+
实测核心规律:**裸引用/函数求值统一要求 [数组] 形态**,标量一律报错(含文本/数字/日期);
|
|
749
|
+
单选/多选传 ["选项文案"] 数组直接可过且求值结果是文案本身(优于内部 id)。
|
|
750
|
+
因此本函数只做一件事:把所有标量值包装成 [标量];已是数组原样保留。
|
|
751
|
+
|
|
752
|
+
Args:
|
|
753
|
+
fields: 字段定义(当前未用,保留签名兼容;数组包装与类型无关)。
|
|
754
|
+
test_case: {queId: value} 对象。
|
|
755
|
+
Returns:
|
|
756
|
+
(归一化 test-case, 包装记录, 错误清单——恒空)
|
|
757
|
+
"""
|
|
758
|
+
if not isinstance(test_case, dict):
|
|
759
|
+
return test_case, [], []
|
|
760
|
+
translated = dict(test_case)
|
|
761
|
+
changes: list[dict[str, str]] = []
|
|
762
|
+
for key, value in list(translated.items()):
|
|
763
|
+
if not isinstance(value, list):
|
|
764
|
+
translated[key] = [value]
|
|
765
|
+
changes.append({"queId": str(key), "from": f"scalar:{value}", "to": f"[{value}]"})
|
|
766
|
+
return translated, changes, []
|
|
@@ -21167,6 +21167,11 @@ def _compact_public_field_read(*, field: dict[str, Any], layout: dict[str, Any],
|
|
|
21167
21167
|
"required": bool(field.get("required")),
|
|
21168
21168
|
"section_id": None if nested else _find_field_section_id(layout, str(field.get("name") or "")),
|
|
21169
21169
|
}
|
|
21170
|
+
if field.get("type") in {FieldType.single_select.value, FieldType.multi_select.value} and field.get(
|
|
21171
|
+
"option_details"
|
|
21172
|
+
):
|
|
21173
|
+
# 选项 id 透出:validate-code-block 需要把"选项文案"翻译成 optId(后端只认 optId)
|
|
21174
|
+
payload["option_details"] = deepcopy(field.get("option_details"))
|
|
21170
21175
|
if field.get("type") == FieldType.relation.value:
|
|
21171
21176
|
payload["target_app_key"] = field.get("target_app_key")
|
|
21172
21177
|
payload["relation_mode"] = _normalize_relation_mode(field.get("relation_mode"))
|
|
@@ -7,6 +7,14 @@ from pathlib import Path
|
|
|
7
7
|
from ..context import CliContext
|
|
8
8
|
from ...app_form_validator import (
|
|
9
9
|
AppFormValidationError,
|
|
10
|
+
build_encoded_id_map,
|
|
11
|
+
referenced_formula_field_ids,
|
|
12
|
+
translate_formula_test_case_values,
|
|
13
|
+
encode_code_block_field_ids,
|
|
14
|
+
referenced_code_block_field_ids,
|
|
15
|
+
restore_decimal_ids_in_text,
|
|
16
|
+
strip_placeholder_quotes,
|
|
17
|
+
translate_select_test_case_values,
|
|
10
18
|
validate_code_block_static,
|
|
11
19
|
validate_declaration,
|
|
12
20
|
validate_formula_expression,
|
|
@@ -441,14 +449,40 @@ def _handle_app_form_validate_formula(args: argparse.Namespace, context: CliCont
|
|
|
441
449
|
"--test-case must be a JSON object {queId: value}", error_code="INVALID_TEST_CASE"
|
|
442
450
|
)
|
|
443
451
|
|
|
452
|
+
# 字段定义:优先 --fields;缺省拉全量(保留 option_details 供选择类值翻译)
|
|
453
|
+
fields_path = getattr(args, "fields", None)
|
|
454
|
+
if fields_path:
|
|
455
|
+
fields = _load_fields_for_validation(args, context)
|
|
456
|
+
else:
|
|
457
|
+
fields = _load_code_block_validation_fields(args, context) or _load_fields_for_validation(args, context)
|
|
458
|
+
|
|
444
459
|
# ① 本地语法校验(函数名 + 字段引用存在性)
|
|
445
|
-
|
|
446
|
-
|
|
460
|
+
syntax_fields = [
|
|
461
|
+
{"queId": f.get("que_id") or f.get("queId"), "name": f.get("name")} for f in (fields or [])
|
|
462
|
+
] if fields else None
|
|
463
|
+
syntax_errors = validate_formula_expression(formula, syntax_fields)
|
|
464
|
+
|
|
465
|
+
# ①' 选择类值翻译:实测后端只认 [optId 数组],文案/标量 optId 均报错——把文案自动翻成 [optId]
|
|
466
|
+
test_case, select_changes, option_errors = translate_formula_test_case_values(fields, test_case)
|
|
467
|
+
|
|
468
|
+
# ①'' 覆盖校验:公式引用缺值字段会静默变空(CONCAT 直接跳过)→ 空洞通过,这里拦下
|
|
469
|
+
referenced_ids = referenced_formula_field_ids(formula)
|
|
470
|
+
underivable = _underivable_field_ids(fields)
|
|
471
|
+
covered_ids = {int(k) for k in test_case if str(k).isdigit()}
|
|
472
|
+
missing_ids = sorted((referenced_ids - covered_ids) - underivable)
|
|
473
|
+
coverage_errors = [
|
|
474
|
+
{
|
|
475
|
+
"pointer": "test-case",
|
|
476
|
+
"message": f"field queId {que_id} is referenced by formula but has no test-case value "
|
|
477
|
+
f"(reference would evaluate as empty)",
|
|
478
|
+
}
|
|
479
|
+
for que_id in missing_ids
|
|
480
|
+
]
|
|
447
481
|
|
|
448
|
-
# ②
|
|
482
|
+
# ② 后端求值校验(本地有错时不浪费后端调用)
|
|
449
483
|
evaluation = None
|
|
450
484
|
evaluation_error = None
|
|
451
|
-
if not syntax_errors:
|
|
485
|
+
if not syntax_errors and not coverage_errors and not option_errors:
|
|
452
486
|
result = context.builder.app_form_evaluate_formula(
|
|
453
487
|
profile=args.profile, app_key=args.app_key, formula=formula, test_case=test_case
|
|
454
488
|
)
|
|
@@ -457,6 +491,8 @@ def _handle_app_form_validate_formula(args: argparse.Namespace, context: CliCont
|
|
|
457
491
|
|
|
458
492
|
ok = bool(
|
|
459
493
|
not syntax_errors
|
|
494
|
+
and not coverage_errors
|
|
495
|
+
and not option_errors
|
|
460
496
|
and isinstance(evaluation, dict)
|
|
461
497
|
and evaluation.get("valid") is True
|
|
462
498
|
)
|
|
@@ -465,6 +501,9 @@ def _handle_app_form_validate_formula(args: argparse.Namespace, context: CliCont
|
|
|
465
501
|
"status": "success" if ok else "failed",
|
|
466
502
|
"ok": ok,
|
|
467
503
|
"syntaxErrors": syntax_errors,
|
|
504
|
+
"coverageErrors": coverage_errors,
|
|
505
|
+
"optionErrors": option_errors,
|
|
506
|
+
"autoFixes": {"selectValuesTranslated": select_changes},
|
|
468
507
|
"evaluation": evaluation,
|
|
469
508
|
"evaluationError": evaluation_error,
|
|
470
509
|
"message": "formula is valid" if ok else "formula validation failed",
|
|
@@ -472,7 +511,15 @@ def _handle_app_form_validate_formula(args: argparse.Namespace, context: CliCont
|
|
|
472
511
|
|
|
473
512
|
|
|
474
513
|
def _handle_app_form_validate_code_block(args: argparse.Namespace, context: CliContext) -> dict:
|
|
475
|
-
|
|
514
|
+
raw_code = _read_code_arg(args.code)
|
|
515
|
+
# 占位符容错:模型常给字段名加引号(qf_field.{"名$$id$$"}),后端替换正则要求结尾恰为
|
|
516
|
+
# `$$}`,带引号直接匹配失败→占位符原样进 JS→语法错误。先剥离引号再进编码。
|
|
517
|
+
code_after_quotes, quote_fixes = strip_placeholder_quotes(raw_code)
|
|
518
|
+
# 后端运行时按 encryptQueId 的编码形态解码代码里的 $$id$$ 段再匹配 test-case 填值;
|
|
519
|
+
# AI 生成的代码是十进制 queId,先编码归一化(含字母的已编码段原样保留,防双重编码)。
|
|
520
|
+
code = encode_code_block_field_ids(code_after_quotes)
|
|
521
|
+
# 错误回显还原映射:后端错误里出现的编码 id 还原为模型写的十进制,避免模型逆向混淆。
|
|
522
|
+
encoded_id_map = build_encoded_id_map(code_after_quotes)
|
|
476
523
|
test_case = load_json_value(
|
|
477
524
|
args.test_case, option_name="--test-case", max_bytes=1 * 1024 * 1024, max_depth=32
|
|
478
525
|
)
|
|
@@ -482,32 +529,108 @@ def _handle_app_form_validate_code_block(args: argparse.Namespace, context: CliC
|
|
|
482
529
|
error_code="INVALID_TEST_CASE",
|
|
483
530
|
)
|
|
484
531
|
|
|
532
|
+
# 字段定义(选项翻译/覆盖豁免用;拉取失败不阻断,只是少一层自动修正)
|
|
533
|
+
fields = _load_code_block_validation_fields(args, context)
|
|
534
|
+
|
|
485
535
|
# ① 本地静态校验(qf_output 赋值 + 基本语法)
|
|
486
536
|
static_errors = validate_code_block_static(code)
|
|
487
537
|
|
|
488
|
-
#
|
|
538
|
+
# ①' 选择类值翻译:后端只认选项 optId,传文案静默变 null——把文案自动翻成 optId。
|
|
489
539
|
key_que_values = _normalize_code_block_test_case(test_case)
|
|
540
|
+
key_que_values, select_changes, option_errors = translate_select_test_case_values(fields, key_que_values)
|
|
541
|
+
|
|
542
|
+
# ①'' 测试覆盖校验:代码引用的字段必须在 test-case 中给值(成员/部门类豁免——
|
|
543
|
+
# 其值是 uid/部门 id,模型无从得知,代码须自行 null 安全)。缺值时后端会把占位符
|
|
544
|
+
# 替换成字面量 null 静默跑通(空洞通过)——在这里拦下,逼 test-case 补全而不是放行。
|
|
545
|
+
covered_ids = _code_block_test_case_field_ids(key_que_values)
|
|
546
|
+
underivable = _underivable_field_ids(fields)
|
|
547
|
+
referenced_ids = referenced_code_block_field_ids(raw_code)
|
|
548
|
+
missing_ids = sorted((referenced_ids - covered_ids) - underivable)
|
|
549
|
+
coverage_errors = [
|
|
550
|
+
{
|
|
551
|
+
"pointer": "test-case",
|
|
552
|
+
"message": f"field queId {que_id} is referenced by code but has no test-case value "
|
|
553
|
+
f"(placeholder would run as literal null)",
|
|
554
|
+
}
|
|
555
|
+
for que_id in missing_ids
|
|
556
|
+
]
|
|
557
|
+
|
|
558
|
+
# ② 后端真跑校验(本地有错时不浪费后端调用)
|
|
490
559
|
run_result = None
|
|
491
560
|
run_error = None
|
|
492
|
-
if not static_errors:
|
|
561
|
+
if not static_errors and not coverage_errors and not option_errors:
|
|
493
562
|
result = context.builder.app_form_run_code_block(
|
|
494
563
|
profile=args.profile, app_key=args.app_key, code=code, key_que_values=key_que_values
|
|
495
564
|
)
|
|
496
565
|
run_result = result.get("runResult")
|
|
497
566
|
run_error = result.get("runError")
|
|
498
567
|
|
|
499
|
-
|
|
568
|
+
if isinstance(run_error, str):
|
|
569
|
+
run_error = restore_decimal_ids_in_text(run_error, encoded_id_map)
|
|
570
|
+
|
|
571
|
+
ok = not static_errors and not coverage_errors and not option_errors and run_result is not None
|
|
500
572
|
return {
|
|
501
573
|
"category": "validation",
|
|
502
574
|
"status": "success" if ok else "failed",
|
|
503
575
|
"ok": ok,
|
|
504
576
|
"staticErrors": static_errors,
|
|
577
|
+
"coverageErrors": coverage_errors,
|
|
578
|
+
"optionErrors": option_errors,
|
|
579
|
+
"autoFixes": {
|
|
580
|
+
"placeholderQuotesStripped": quote_fixes,
|
|
581
|
+
"selectValuesTranslated": select_changes,
|
|
582
|
+
},
|
|
505
583
|
"runResult": run_result,
|
|
506
584
|
"runError": run_error,
|
|
507
585
|
"message": "code block is valid" if ok else "code block validation failed",
|
|
508
586
|
}
|
|
509
587
|
|
|
510
588
|
|
|
589
|
+
def _load_code_block_validation_fields(
|
|
590
|
+
args: argparse.Namespace, context: CliContext
|
|
591
|
+
) -> list[dict] | None:
|
|
592
|
+
"""加载字段定义(类型+选项 optId),供选择类值翻译与成员/部门覆盖豁免;失败返回 None。"""
|
|
593
|
+
try:
|
|
594
|
+
fetched = context.builder.app_get_fields(profile=args.profile, app_key=args.app_key)
|
|
595
|
+
except Exception: # noqa: BLE001 - 字段拉取失败不阻断校验主流程
|
|
596
|
+
return None
|
|
597
|
+
if not isinstance(fetched, dict) or fetched.get("status") != "success":
|
|
598
|
+
return None
|
|
599
|
+
raw_fields = fetched.get("fields")
|
|
600
|
+
if not isinstance(raw_fields, list):
|
|
601
|
+
return None
|
|
602
|
+
return [field for field in raw_fields if isinstance(field, dict)]
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _underivable_field_ids(fields: list[dict] | None) -> set[int]:
|
|
606
|
+
"""值不可由模型推导的字段(成员/部门):test-case 覆盖校验豁免。"""
|
|
607
|
+
if not fields:
|
|
608
|
+
return set()
|
|
609
|
+
result: set[int] = set()
|
|
610
|
+
for field in fields:
|
|
611
|
+
field_type = str(field.get("type") or "")
|
|
612
|
+
if field_type in {"member", "department"}:
|
|
613
|
+
que_id = field.get("que_id") or field.get("queId")
|
|
614
|
+
if isinstance(que_id, int):
|
|
615
|
+
result.add(que_id)
|
|
616
|
+
return result
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _code_block_test_case_field_ids(key_que_values: list[dict]) -> set[int]:
|
|
620
|
+
"""test-case 覆盖的字段 id 集合:顶层 keyQueId + 表格 tableValues 行内 keyQueId。"""
|
|
621
|
+
covered: set[int] = set()
|
|
622
|
+
for item in key_que_values:
|
|
623
|
+
key = item.get("keyQueId")
|
|
624
|
+
if isinstance(key, int):
|
|
625
|
+
covered.add(key)
|
|
626
|
+
for row in item.get("tableValues") or []:
|
|
627
|
+
if isinstance(row, list):
|
|
628
|
+
for cell in row:
|
|
629
|
+
if isinstance(cell, dict) and isinstance(cell.get("keyQueId"), int):
|
|
630
|
+
covered.add(cell["keyQueId"])
|
|
631
|
+
return covered
|
|
632
|
+
|
|
633
|
+
|
|
511
634
|
def _load_fields_for_validation(args: argparse.Namespace, context: CliContext) -> list[dict] | None:
|
|
512
635
|
"""加载字段清单:优先 --fields 参数,缺省时从后端拉取应用字段。
|
|
513
636
|
|
|
@@ -242,7 +242,6 @@ def build_question(field: dict[str, Any], temp_id: int) -> tuple[dict[str, Any],
|
|
|
242
242
|
{
|
|
243
243
|
"referenceConfig": build_reference_config(field, temp_id),
|
|
244
244
|
"queOriginType": 25,
|
|
245
|
-
"queDefaultType": 2,
|
|
246
245
|
}
|
|
247
246
|
)
|
|
248
247
|
if field_type == FieldType.code_block:
|