@qingflow-tech/qingflow-cli 1.1.51 → 1.1.53
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 +4 -4
- package/npm/lib/runtime.mjs +29 -13
- package/package.json +1 -1
- package/pyproject.toml +8 -2
- package/skills/qingflow-cli/SKILL.md +93 -322
- package/skills/qingflow-cli/manifest.yaml +1 -1
- package/skills/qingflow-cli/reference/00-INDEX.md +4 -4
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_VIEWS_WORKFLOW.md +3 -9
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +3 -137
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md +14 -14
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md +7 -12
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
- package/skills/qingflow-cli/reference/builder/20-build-complete-system.md +3 -2
- package/skills/qingflow-cli/reference/builder/30-schema-fields.md +9 -5
- package/skills/qingflow-cli/reference/builder/50-views.md +192 -191
- package/skills/qingflow-cli/reference/builder/80-buttons-associated-resources.md +2 -0
- package/skills/qingflow-cli/reference/builder/README.md +1 -1
- package/skills/qingflow-cli/reference/builder/reference/app-delivery-sop.md +6 -6
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +76 -95
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md +13 -13
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
- package/skills/qingflow-cli/reference/data/README.md +32 -0
- package/skills/qingflow-cli/reference/examples/README.md +1 -1
- package/skills/qingflow-cli/reference/examples/views/views_apply_v1alpha1.example.json +14 -0
- package/skills/qingflow-cli/reference/examples/views/views_upsert_table_minimal.example.json +30 -36
- package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_SPECIALIZED_WORKFLOW.md +50 -0
- package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
- package/skills/qingflow-cli/reference/record/analysis/README.md +21 -113
- package/skills/qingflow-cli/reference/record/analysis/analysis-gotchas.md +10 -10
- package/skills/qingflow-cli/reference/record/analysis/analysis-patterns.md +9 -9
- package/skills/qingflow-cli/reference/record/analysis/business-context.md +1 -1
- package/skills/qingflow-cli/reference/record/analysis/confidence-reporting.md +10 -10
- package/skills/qingflow-cli/reference/record/analysis/data-access-playbook.md +52 -80
- package/skills/qingflow-cli/reference/record/analysis/pandas-recipes.md +2 -2
- package/skills/qingflow-common/SKILL.md +7 -4
- package/src/qingflow_mcp/app_form_compat.py +3 -0
- package/src/qingflow_mcp/app_form_validator.py +69 -17
- package/src/qingflow_mcp/builder_facade/app_form.py +497 -100
- package/src/qingflow_mcp/builder_facade/models.py +227 -17
- package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +1 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +64 -30
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +6 -10
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +6 -10
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +11 -18
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +6 -10
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +6 -10
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +6 -10
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +46 -15
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/rich_text.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +61 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/formula.json +43 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +7 -12
- package/src/qingflow_mcp/builder_facade/schemas/views/manifest.json +42 -0
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/complete.json +1245 -0
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/button-config.json +53 -0
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/data-permission.json +60 -0
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/display.json +73 -0
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/operation-permission.json +48 -0
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/query-condition.json +47 -0
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/summary.json +319 -0
- package/src/qingflow_mcp/builder_facade/service.py +1666 -509
- package/src/qingflow_mcp/builder_facade/views_extract.py +592 -0
- package/src/qingflow_mcp/builder_facade/views_schema.py +400 -0
- package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
- package/src/qingflow_mcp/cli/commands/builder.py +76 -55
- package/src/qingflow_mcp/cli/commands/data.py +134 -0
- package/src/qingflow_mcp/cli/commands/record.py +1 -1
- package/src/qingflow_mcp/cli/context.py +3 -0
- package/src/qingflow_mcp/cli/formatters.py +97 -1
- package/src/qingflow_mcp/public_surface.py +8 -0
- package/src/qingflow_mcp/response_trim.py +11 -2
- package/src/qingflow_mcp/server.py +2 -0
- package/src/qingflow_mcp/server_app_builder.py +34 -10
- package/src/qingflow_mcp/server_app_user.py +4 -0
- package/src/qingflow_mcp/solution/compiler/view_compiler.py +2 -1
- package/src/qingflow_mcp/solution/executor.py +96 -8
- package/src/qingflow_mcp/solution/spec_models.py +1 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +218 -398
- package/src/qingflow_mcp/tools/csv_export.py +168 -0
- package/src/qingflow_mcp/tools/data_tools.py +284 -0
- package/src/qingflow_mcp/tools/record_tools.py +11 -46
- package/src/qingflow_mcp/tools/solution_tools.py +2 -0
- package/src/qingflow_mcp/tools/view_tools.py +0 -6
- package/src/qingflow_mcp/tools/view_writer.py +44 -0
- package/skills/qingflow-cli/reference/examples/views/views_batch_full.example.json +0 -159
- package/skills/qingflow-cli/reference/views_upsert_table_minimal.example.json +0 -42
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
"""ViewgraphInfo → 公开视图声明的反向编译器(round-trip 提取器,eval 与 `views get --schema-kind complete` 共用)。
|
|
2
|
+
|
|
3
|
+
设计对称性:本模块的每个映射与 `service.py` 正向编译(`_hydrate_view_backend_payload` 族)一一对应;
|
|
4
|
+
「提不出来」的配置面进入 coverage gaps,由调用方汇总进覆盖度报告(evaluation.md §6)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .service import (
|
|
12
|
+
ASSOCIATED_RESOURCE_LIMIT_TYPE_ALL,
|
|
13
|
+
_coerce_any_int,
|
|
14
|
+
_coerce_positive_int,
|
|
15
|
+
_extract_view_button_entries,
|
|
16
|
+
_public_view_filter_groups_from_match_rules,
|
|
17
|
+
_public_visibility_from_member_auth,
|
|
18
|
+
_resolve_system_view_button_logical_id,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# 后端 viewgraphType → 公开 type(ViewUpsertPatch/PublicViewType)
|
|
22
|
+
_VIEW_TYPE_BY_BACKEND = {
|
|
23
|
+
"tableView": "table",
|
|
24
|
+
"cardView": "card",
|
|
25
|
+
"boardView": "board",
|
|
26
|
+
"ganttView": "gantt",
|
|
27
|
+
"hierarchyView": "hierarchy",
|
|
28
|
+
"calendarView": "calendar",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# 后端 dataScope → 公开 data_permission.scope(与 _DATA_PERMISSION_SCOPE_MAP 对称)
|
|
32
|
+
_DATA_SCOPE_TO_PUBLIC = {
|
|
33
|
+
"ALL": "all",
|
|
34
|
+
"ONESELF": "oneself",
|
|
35
|
+
"DEPT": "dept",
|
|
36
|
+
"SUBORDINATE": "subordinate",
|
|
37
|
+
"CUSTOM": "custom",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# 后端视图列中可直接引用的系统字段 ID。它们不需要出现在表单业务字段索引中。
|
|
41
|
+
_SYSTEM_COLUMN_NAMES = {
|
|
42
|
+
0: "apply_num",
|
|
43
|
+
1: "apply_user",
|
|
44
|
+
2: "apply_time",
|
|
45
|
+
3: "update_time",
|
|
46
|
+
4: "current_node",
|
|
47
|
+
5: "current_status",
|
|
48
|
+
6: "current_user",
|
|
49
|
+
-1: "data_source",
|
|
50
|
+
-2: "user_dept",
|
|
51
|
+
-3: "audit_node",
|
|
52
|
+
-4: "audit_node_user",
|
|
53
|
+
-5: "audit_status",
|
|
54
|
+
-6: "audit_time",
|
|
55
|
+
-7: "timeout_time",
|
|
56
|
+
-8: "audit_table",
|
|
57
|
+
-18: "error_info",
|
|
58
|
+
-19: "delete_time",
|
|
59
|
+
-20: "deleter",
|
|
60
|
+
-21: "processing_node",
|
|
61
|
+
-22: "cc_node",
|
|
62
|
+
-23: "processing_time",
|
|
63
|
+
-24: "cc_time",
|
|
64
|
+
-25: "receive_time",
|
|
65
|
+
-30: "app_key",
|
|
66
|
+
-31: "ws_id",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# complete 契约不覆盖、读面存在的配置键(进入 SKIP 账本,evaluation.md §4.3)
|
|
70
|
+
_UNCOVERED_READBACK_KEYS = (
|
|
71
|
+
"attachVisibleOnlyConfig",
|
|
72
|
+
"beingRemarkStatus",
|
|
73
|
+
"querySearchStatus",
|
|
74
|
+
"beingDefaultPrintTplStatus",
|
|
75
|
+
"usages",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _gap(reason_path: str, kind: str, message: str, **details: Any) -> dict[str, Any]:
|
|
80
|
+
gap: dict[str, Any] = {"reason_path": reason_path, "kind": kind, "message": message}
|
|
81
|
+
if details:
|
|
82
|
+
gap["details"] = details
|
|
83
|
+
return gap
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _extract_filters(
|
|
87
|
+
view_info: dict[str, Any],
|
|
88
|
+
*,
|
|
89
|
+
fields_by_que_id: dict[int, dict[str, Any]],
|
|
90
|
+
reason_path: str,
|
|
91
|
+
gaps: list[dict[str, Any]],
|
|
92
|
+
) -> list[Any] | None:
|
|
93
|
+
"""viewgraphLimit → filters。
|
|
94
|
+
|
|
95
|
+
简易模式(limitType=1):规则组是唯一真源(viewgraphLimitFormula 是后端从规则组派生的存储态,
|
|
96
|
+
提取时忽略);单组 → 扁平 [rule,...](组内 AND,向后兼容),多组 → 嵌套 [[rule,...],...]
|
|
97
|
+
(组间 OR、组内 AND,与 visible_when/root_filter 的嵌组形态对齐)。
|
|
98
|
+
公式模式(limitType!=1):用户手写公式串,规则组不含其语义,真不可表达 → gap。
|
|
99
|
+
"""
|
|
100
|
+
limit_type = view_info.get("viewgraphLimitType")
|
|
101
|
+
if limit_type not in (None, 1):
|
|
102
|
+
gaps.append(
|
|
103
|
+
_gap(
|
|
104
|
+
f"{reason_path}",
|
|
105
|
+
"formula_filters",
|
|
106
|
+
"viewgraphLimitType!=1 (formula mode) is not expressible in v1alpha1; view is not extractable",
|
|
107
|
+
viewgraphLimitType=limit_type,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
return None
|
|
111
|
+
groups = view_info.get("viewgraphLimit")
|
|
112
|
+
if not groups:
|
|
113
|
+
return None
|
|
114
|
+
raw_groups = [groups] if isinstance(groups, list) and all(isinstance(rule, dict) for rule in groups) else groups
|
|
115
|
+
if not isinstance(raw_groups, list) or any(
|
|
116
|
+
not isinstance(group, list) or not group or any(not isinstance(rule, dict) for rule in group)
|
|
117
|
+
for group in raw_groups
|
|
118
|
+
):
|
|
119
|
+
gaps.append(_gap(reason_path, "unresolved_filter_rules", "filter groups contain an invalid or empty group"))
|
|
120
|
+
return None
|
|
121
|
+
public_groups = _public_view_filter_groups_from_match_rules(groups, fields_by_que_id=fields_by_que_id)
|
|
122
|
+
if [len(group) for group in public_groups] != [len(group) for group in raw_groups]:
|
|
123
|
+
gaps.append(_gap(reason_path, "unresolved_filter_rules", "filter rules could not be completely extracted"))
|
|
124
|
+
return None
|
|
125
|
+
normalized_groups: list[list[dict[str, Any]]] = []
|
|
126
|
+
for group_index, raw_rules in enumerate(public_groups):
|
|
127
|
+
normalized_group: list[dict[str, Any]] = []
|
|
128
|
+
for rule in raw_rules:
|
|
129
|
+
operator = str(rule.get("operator") or "")
|
|
130
|
+
if operator.startswith("judge_type:"):
|
|
131
|
+
gaps.append(
|
|
132
|
+
_gap(
|
|
133
|
+
f"{reason_path}[{group_index}]",
|
|
134
|
+
"unsupported_operator",
|
|
135
|
+
"filter operator not in the v1alpha1 operator set",
|
|
136
|
+
operator=operator,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
continue
|
|
140
|
+
field_que_id = _coerce_positive_int(rule.get("que_id"))
|
|
141
|
+
if field_que_id is None or field_que_id not in fields_by_que_id:
|
|
142
|
+
gaps.append(
|
|
143
|
+
_gap(
|
|
144
|
+
f"{reason_path}[{group_index}]",
|
|
145
|
+
"unresolved_filter_field",
|
|
146
|
+
"filter rule que_id does not resolve to a form field",
|
|
147
|
+
que_id=rule.get("que_id"),
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
continue
|
|
151
|
+
entry: dict[str, Any] = {"que_id": field_que_id, "operator": operator}
|
|
152
|
+
if operator in {"is_empty", "not_empty"}:
|
|
153
|
+
entry["values"] = []
|
|
154
|
+
elif operator == "in" and not (rule.get("values") or []) and rule.get("value") is None:
|
|
155
|
+
# ANY 判型+空值 = "未选任何值"合法形态,judgeType 保真回写
|
|
156
|
+
entry["values"] = []
|
|
157
|
+
elif isinstance(rule.get("values"), list) and rule["values"]:
|
|
158
|
+
entry["values"] = rule["values"]
|
|
159
|
+
elif rule.get("value") is not None:
|
|
160
|
+
entry["values"] = [rule["value"]]
|
|
161
|
+
else:
|
|
162
|
+
gaps.append(
|
|
163
|
+
_gap(
|
|
164
|
+
f"{reason_path}[{group_index}]",
|
|
165
|
+
"empty_filter_values",
|
|
166
|
+
"filter rule has no resolvable values",
|
|
167
|
+
rule=rule,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
continue
|
|
171
|
+
normalized_group.append(entry)
|
|
172
|
+
if normalized_group:
|
|
173
|
+
normalized_groups.append(normalized_group)
|
|
174
|
+
if not normalized_groups:
|
|
175
|
+
return None
|
|
176
|
+
# 单组 → 扁平(兼容既有声明);多组 → 嵌套(OR of AND)
|
|
177
|
+
return normalized_groups[0] if len(normalized_groups) == 1 else normalized_groups
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _extract_sort(
|
|
181
|
+
view_info: dict[str, Any],
|
|
182
|
+
*,
|
|
183
|
+
fields_by_que_id: dict[int, dict[str, Any]],
|
|
184
|
+
reason_path: str,
|
|
185
|
+
gaps: list[dict[str, Any]],
|
|
186
|
+
) -> list[dict[str, Any]] | None:
|
|
187
|
+
"""viewgraphSorts + sortType → sort;defaultSort(含 queId=0 占位)不产出。"""
|
|
188
|
+
if str(view_info.get("sortType") or "").strip() != "customSort":
|
|
189
|
+
return None
|
|
190
|
+
sorts: list[dict[str, Any]] = []
|
|
191
|
+
for index, raw in enumerate(view_info.get("viewgraphSorts") or []):
|
|
192
|
+
if not isinstance(raw, dict):
|
|
193
|
+
continue
|
|
194
|
+
que_id = raw.get("queId")
|
|
195
|
+
if _coerce_positive_int(que_id) in (None, 0):
|
|
196
|
+
continue
|
|
197
|
+
parsed_sort = _coerce_positive_int(que_id)
|
|
198
|
+
if parsed_sort in (None, 0):
|
|
199
|
+
continue
|
|
200
|
+
if parsed_sort not in fields_by_que_id:
|
|
201
|
+
gaps.append(_gap(f"{reason_path}[{index}]", "unresolved_que_id", "sort queId not in form field schema", que_id=parsed_sort))
|
|
202
|
+
continue
|
|
203
|
+
sorts.append({"que_id": parsed_sort, "ascending": bool(raw.get("beingSortAscend", True))})
|
|
204
|
+
return sorts or None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _extract_buttons(
|
|
208
|
+
view_info: dict[str, Any],
|
|
209
|
+
*,
|
|
210
|
+
fields_by_que_id: dict[int, dict[str, Any]],
|
|
211
|
+
reason_path: str,
|
|
212
|
+
gaps: list[dict[str, Any]],
|
|
213
|
+
) -> list[dict[str, Any]]:
|
|
214
|
+
"""从完整按钮读面提取绑定;DTO 列表仅作为旧读面的回退。"""
|
|
215
|
+
bindings: list[dict[str, Any]] = []
|
|
216
|
+
entries, entry_source = _extract_view_button_entries(view_info)
|
|
217
|
+
if entry_source in {"buttonConfigVO", "buttonConfigDTOList"} and len(entries) != len(view_info[entry_source]):
|
|
218
|
+
gaps.append(_gap(reason_path, "unresolved_button_entry", "button list contains invalid entries"))
|
|
219
|
+
for index, raw in enumerate(entries):
|
|
220
|
+
button_type = str(raw.get("buttonType") or "").upper()
|
|
221
|
+
if not button_type and entry_source == "buttonConfigDTOList":
|
|
222
|
+
button_type = "CUSTOM" if raw.get("buttonId") is not None else "SYSTEM"
|
|
223
|
+
if button_type not in {"SYSTEM", "CUSTOM"}:
|
|
224
|
+
gaps.append(_gap(f"{reason_path}[{index}].button_type", "unresolved_button_type", "button type could not be resolved"))
|
|
225
|
+
continue
|
|
226
|
+
button_id = _coerce_positive_int(raw.get("buttonId"))
|
|
227
|
+
if button_type == "SYSTEM":
|
|
228
|
+
# 系统按钮会由后端重建;自定义按钮必须使用实体 ID,不能使用配置行 ID。
|
|
229
|
+
button_id = _resolve_system_view_button_logical_id(raw) or button_id or _coerce_positive_int(raw.get("id"))
|
|
230
|
+
if button_id is None:
|
|
231
|
+
gaps.append(_gap(f"{reason_path}[{index}].button_id", "button_without_id", "button binding has no resolvable button ID", button_type=button_type))
|
|
232
|
+
continue
|
|
233
|
+
unsupported = [key for key in ("targetFormId", "triggerAddDataConfig", "triggerLinkUrl", "triggerWingsConfig", "httpRequestConfig") if raw.get(key) not in (None, "", [], {})]
|
|
234
|
+
if button_type == "SYSTEM" and unsupported:
|
|
235
|
+
gaps.append(_gap(f"{reason_path}[{index}]", "unsupported_button_config", "button action configuration cannot be represented by a binding", keys=unsupported))
|
|
236
|
+
continue
|
|
237
|
+
binding: dict[str, Any] = {
|
|
238
|
+
"button_id": button_id,
|
|
239
|
+
"config_type": str(raw.get("configType") or "TOP"),
|
|
240
|
+
}
|
|
241
|
+
binding["button_type"] = button_type
|
|
242
|
+
for source_key, target in (
|
|
243
|
+
("buttonText", "button_text"),
|
|
244
|
+
("buttonIcon", "button_icon"),
|
|
245
|
+
("backgroundColor", "background_color"),
|
|
246
|
+
("textColor", "text_color"),
|
|
247
|
+
("triggerAction", "trigger_action"),
|
|
248
|
+
):
|
|
249
|
+
if raw.get(source_key) is not None:
|
|
250
|
+
binding[target] = raw.get(source_key)
|
|
251
|
+
if raw.get("stylePreset"):
|
|
252
|
+
binding["style_preset"] = raw.get("stylePreset")
|
|
253
|
+
binding["being_main"] = bool(raw.get("beingMain"))
|
|
254
|
+
if raw.get("printTpls"):
|
|
255
|
+
binding["print_tpls"] = raw.get("printTpls")
|
|
256
|
+
if raw.get("buttonFormula") is not None:
|
|
257
|
+
binding["button_formula"] = raw.get("buttonFormula")
|
|
258
|
+
binding["button_formula_type"] = raw.get("buttonFormulaType") or 1
|
|
259
|
+
if raw.get("buttonLimit"):
|
|
260
|
+
public_limits = _extract_filters(
|
|
261
|
+
{"viewgraphLimit": raw["buttonLimit"], "viewgraphLimitType": 1},
|
|
262
|
+
fields_by_que_id=fields_by_que_id,
|
|
263
|
+
reason_path=f"{reason_path}[{index}].button_limit",
|
|
264
|
+
gaps=gaps,
|
|
265
|
+
)
|
|
266
|
+
if public_limits and isinstance(public_limits[0], dict):
|
|
267
|
+
public_limits = [public_limits]
|
|
268
|
+
binding["button_limit"] = public_limits or []
|
|
269
|
+
bindings.append(binding)
|
|
270
|
+
return bindings
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _optionally(target: dict[str, Any], key: str, value: Any) -> None:
|
|
274
|
+
if value is None:
|
|
275
|
+
return
|
|
276
|
+
if isinstance(value, str) and not value.strip():
|
|
277
|
+
return
|
|
278
|
+
target[key] = value
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def extract_complete_view_declaration(
|
|
282
|
+
view_info: dict[str, Any],
|
|
283
|
+
*,
|
|
284
|
+
fields_by_que_id: dict[int, dict[str, Any]],
|
|
285
|
+
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
286
|
+
"""把后端 ViewgraphInfo(GET /view/{key}/viewConfig 的 data)反向编译为 complete 档声明。
|
|
287
|
+
|
|
288
|
+
返回 (declaration, coverage_gaps)。声明可再经 `views validate` 离线校验;
|
|
289
|
+
gaps 覆盖:v1alpha1 契约外的配置面(公式/OR 组/系统列/打印模板等)与解析失败项。
|
|
290
|
+
"""
|
|
291
|
+
gaps: list[dict[str, Any]] = []
|
|
292
|
+
name_by_que_id: dict[int, str] = {}
|
|
293
|
+
for que_id, field in fields_by_que_id.items():
|
|
294
|
+
name = str(field.get("name") or "").strip() if isinstance(field, dict) else ""
|
|
295
|
+
if name:
|
|
296
|
+
name_by_que_id[que_id] = name
|
|
297
|
+
|
|
298
|
+
raw_type = str(view_info.get("viewgraphType") or "").strip()
|
|
299
|
+
view_type = _VIEW_TYPE_BY_BACKEND.get(raw_type)
|
|
300
|
+
if view_type is None:
|
|
301
|
+
gaps.append(_gap("type", "unsupported_view_type", "viewgraphType is not a public v1alpha1 type", viewgraphType=raw_type))
|
|
302
|
+
return {}, gaps
|
|
303
|
+
|
|
304
|
+
declaration: dict[str, Any] = {"type": view_type, "name": str(view_info.get("viewgraphName") or "").strip()}
|
|
305
|
+
view_key = str(view_info.get("viewgraphKey") or "").strip()
|
|
306
|
+
if view_key:
|
|
307
|
+
declaration["view_key"] = view_key
|
|
308
|
+
|
|
309
|
+
columns: list[int] = []
|
|
310
|
+
for index, que_id in enumerate(view_info.get("viewgraphQueIds") or []):
|
|
311
|
+
parsed = _coerce_any_int(que_id)
|
|
312
|
+
if parsed in _SYSTEM_COLUMN_NAMES:
|
|
313
|
+
columns.append(parsed)
|
|
314
|
+
continue
|
|
315
|
+
if parsed is None or parsed <= 0:
|
|
316
|
+
gaps.append(
|
|
317
|
+
_gap(f"columns[{index}]", "system_column", "unknown system column is not supported by the public contract", que_id=que_id)
|
|
318
|
+
)
|
|
319
|
+
continue
|
|
320
|
+
if parsed not in fields_by_que_id:
|
|
321
|
+
gaps.append(
|
|
322
|
+
_gap(f"columns[{index}]", "unresolved_que_id", "queId does not exist in the form field schema", que_id=parsed)
|
|
323
|
+
)
|
|
324
|
+
continue
|
|
325
|
+
columns.append(parsed)
|
|
326
|
+
if columns:
|
|
327
|
+
declaration["columns"] = columns
|
|
328
|
+
elif view_type in {"table", "card"}:
|
|
329
|
+
# table/card 至少要有一列;系统列也属于后端可展示列。
|
|
330
|
+
gaps.append(
|
|
331
|
+
_gap("columns", "no_resolvable_columns", "table/card view has no columns resolvable to field names; view is not extractable")
|
|
332
|
+
)
|
|
333
|
+
return {}, gaps
|
|
334
|
+
|
|
335
|
+
group_que_id = _coerce_positive_int(view_info.get("groupQueId"))
|
|
336
|
+
if view_type == "board":
|
|
337
|
+
if group_que_id and group_que_id not in fields_by_que_id:
|
|
338
|
+
gaps.append(_gap("group_que_id", "unresolved_que_id", "board group queId not in form field schema", que_id=group_que_id))
|
|
339
|
+
group_que_id = None
|
|
340
|
+
if group_que_id:
|
|
341
|
+
declaration["group_que_id"] = group_que_id
|
|
342
|
+
else:
|
|
343
|
+
gaps.append(
|
|
344
|
+
_gap("group_by", "unresolved_board_group", "board view groupQueId does not resolve; view is not extractable")
|
|
345
|
+
)
|
|
346
|
+
return {}, gaps
|
|
347
|
+
|
|
348
|
+
gantt_raw = view_info.get("viewgraphGanttConfigVO") if isinstance(view_info.get("viewgraphGanttConfigVO"), dict) else {}
|
|
349
|
+
if view_type == "gantt":
|
|
350
|
+
start_que_id = _coerce_positive_int(gantt_raw.get("startTimeQueId"))
|
|
351
|
+
end_que_id = _coerce_positive_int(gantt_raw.get("endTimeQueId"))
|
|
352
|
+
title_que_id = _coerce_positive_int(gantt_raw.get("titleQueId")) or _coerce_positive_int(view_info.get("titleQue"))
|
|
353
|
+
# 系统列(queId<=5)或已删字段的 queId 不在 fields_by_que_id——不可引用,丢弃+gap
|
|
354
|
+
if start_que_id and start_que_id not in fields_by_que_id:
|
|
355
|
+
gaps.append(_gap("gantt.start_que_id", "unresolved_que_id", "start queId not in form field schema", que_id=start_que_id))
|
|
356
|
+
start_que_id = None
|
|
357
|
+
if end_que_id and end_que_id not in fields_by_que_id:
|
|
358
|
+
gaps.append(_gap("gantt.end_que_id", "unresolved_que_id", "end queId not in form field schema", que_id=end_que_id))
|
|
359
|
+
end_que_id = None
|
|
360
|
+
if title_que_id and title_que_id not in fields_by_que_id:
|
|
361
|
+
gaps.append(_gap("gantt.title_que_id", "unresolved_que_id", "title queId not in form field schema", que_id=title_que_id))
|
|
362
|
+
title_que_id = None
|
|
363
|
+
if not (start_que_id and end_que_id):
|
|
364
|
+
# 契约要求 gantt 必须有 start/end;存量存在配置一半的视图(无起止字段),
|
|
365
|
+
# 判不可提取入账本,而非产出缺字段的非法声明
|
|
366
|
+
gaps.append(_gap("gantt", "unresolved_gantt_config", "gantt start/end fields do not resolve; view is not extractable"))
|
|
367
|
+
return {}, gaps
|
|
368
|
+
declaration["start_que_id"] = start_que_id
|
|
369
|
+
declaration["end_que_id"] = end_que_id
|
|
370
|
+
_optionally(declaration, "title_que_id", title_que_id)
|
|
371
|
+
gantt: dict[str, Any] = {}
|
|
372
|
+
_optionally(gantt, "title_que_id", title_que_id)
|
|
373
|
+
_optionally(gantt, "start_que_id", start_que_id)
|
|
374
|
+
_optionally(gantt, "end_que_id", end_que_id)
|
|
375
|
+
_optionally(gantt, "default_time_dimension", gantt_raw.get("defaultTimeDimension"))
|
|
376
|
+
groups: list[dict[str, Any]] = []
|
|
377
|
+
for raw_group in gantt_raw.get("ganttGroupVOList") or []:
|
|
378
|
+
if not isinstance(raw_group, dict):
|
|
379
|
+
continue
|
|
380
|
+
group_que = _coerce_positive_int(raw_group.get("groupQueId"))
|
|
381
|
+
if group_que:
|
|
382
|
+
entry: dict[str, Any] = {"que_id": group_que}
|
|
383
|
+
_optionally(entry, "ordinal", raw_group.get("ordinal"))
|
|
384
|
+
groups.append(entry)
|
|
385
|
+
if groups:
|
|
386
|
+
gantt["groups"] = groups
|
|
387
|
+
dependency_raw = gantt_raw.get("ganttDependencyVO") if isinstance(gantt_raw.get("ganttDependencyVO"), dict) else {}
|
|
388
|
+
if dependency_raw.get("dependencyQueId") is not None or dependency_raw.get("predecessorTaskQueId") is not None:
|
|
389
|
+
dependency: dict[str, Any] = {}
|
|
390
|
+
dep_que = _coerce_positive_int(dependency_raw.get("dependencyQueId"))
|
|
391
|
+
pred_que = _coerce_positive_int(dependency_raw.get("predecessorTaskQueId"))
|
|
392
|
+
if dep_que and pred_que:
|
|
393
|
+
dependency = {"dependency_que_id": dep_que, "predecessor_que_id": pred_que}
|
|
394
|
+
_optionally(dependency, "start_start_option_id", dependency_raw.get("startStartOptionId"))
|
|
395
|
+
_optionally(dependency, "start_end_option_id", dependency_raw.get("startEndOptionId"))
|
|
396
|
+
_optionally(dependency, "end_start_option_id", dependency_raw.get("endStartOptionId"))
|
|
397
|
+
_optionally(dependency, "end_end_option_id", dependency_raw.get("endEndOptionId"))
|
|
398
|
+
gantt["dependencies"] = [dependency]
|
|
399
|
+
else:
|
|
400
|
+
gaps.append(_gap("gantt.dependencies", "unresolved_gantt_dependency", "gantt dependency queIds do not resolve", raw=dependency_raw))
|
|
401
|
+
if gantt:
|
|
402
|
+
declaration["gantt"] = gantt
|
|
403
|
+
|
|
404
|
+
hierarchy_raw = view_info.get("viewgraphHierarchyConfigVO") if isinstance(view_info.get("viewgraphHierarchyConfigVO"), dict) else {}
|
|
405
|
+
if view_type == "hierarchy":
|
|
406
|
+
title_que = _coerce_positive_int(hierarchy_raw.get("titleQueId"))
|
|
407
|
+
children_que = _coerce_positive_int(hierarchy_raw.get("childrenQueId"))
|
|
408
|
+
if not (title_que and children_que):
|
|
409
|
+
gaps.append(_gap("hierarchy", "unresolved_hierarchy_config", "hierarchy title/children queIds do not resolve; view is not extractable"))
|
|
410
|
+
return {}, gaps
|
|
411
|
+
hierarchy: dict[str, Any] = {"title_que_id": title_que, "children_que_id": children_que}
|
|
412
|
+
root_groups = _public_view_filter_groups_from_match_rules(
|
|
413
|
+
hierarchy_raw.get("rootNodeFilter"), fields_by_que_id=fields_by_que_id
|
|
414
|
+
)
|
|
415
|
+
if root_groups:
|
|
416
|
+
hierarchy["root_filter"] = root_groups
|
|
417
|
+
declaration["hierarchy"] = hierarchy
|
|
418
|
+
_optionally(declaration, "title_que_id", title_que)
|
|
419
|
+
|
|
420
|
+
if view_type == "calendar":
|
|
421
|
+
calendar_raw = view_info.get("viewgraphCalendarConfigVO") if isinstance(view_info.get("viewgraphCalendarConfigVO"), dict) else {}
|
|
422
|
+
title_que = _coerce_positive_int(calendar_raw.get("titleQueId"))
|
|
423
|
+
date_que = _coerce_any_int(calendar_raw.get("dateQueId"))
|
|
424
|
+
color_que = _coerce_positive_int(calendar_raw.get("colorQueId"))
|
|
425
|
+
if title_que is None or title_que not in fields_by_que_id:
|
|
426
|
+
gaps.append(_gap("calendar.title_que_id", "unresolved_calendar_config", "calendar title queId does not resolve; view is not extractable", que_id=title_que))
|
|
427
|
+
return {}, gaps
|
|
428
|
+
if date_que is None or (date_que not in {2, 3} and date_que not in fields_by_que_id):
|
|
429
|
+
gaps.append(_gap("calendar.date_que_id", "unresolved_calendar_config", "calendar date queId does not resolve; view is not extractable", que_id=date_que))
|
|
430
|
+
return {}, gaps
|
|
431
|
+
calendar: dict[str, Any] = {"title_que_id": title_que, "date_que_id": date_que}
|
|
432
|
+
if color_que is not None:
|
|
433
|
+
if color_que not in fields_by_que_id:
|
|
434
|
+
gaps.append(_gap("calendar.color_que_id", "unresolved_que_id", "calendar color queId does not resolve", que_id=color_que))
|
|
435
|
+
return {}, gaps
|
|
436
|
+
calendar["color_que_id"] = color_que
|
|
437
|
+
declaration["calendar"] = calendar
|
|
438
|
+
declaration["title_que_id"] = title_que
|
|
439
|
+
|
|
440
|
+
if view_type in {"card", "board"}:
|
|
441
|
+
_optionally(declaration, "title_que_id", _coerce_positive_int(view_info.get("titleQue")))
|
|
442
|
+
|
|
443
|
+
if view_type == "card":
|
|
444
|
+
card: dict[str, Any] = {}
|
|
445
|
+
for raw_key, gap_key in (("titleQue", "title_que_id"), ("frontCoverQueId", "cover_que_id")):
|
|
446
|
+
raw_val = _coerce_positive_int(view_info.get(raw_key))
|
|
447
|
+
if raw_val and raw_val not in fields_by_que_id:
|
|
448
|
+
gaps.append(_gap(f"card.{gap_key}", "unresolved_que_id", f"card {gap_key} not in form field schema", que_id=raw_val))
|
|
449
|
+
else:
|
|
450
|
+
_optionally(card, gap_key, raw_val)
|
|
451
|
+
_optionally(card, "clipping_mode", view_info.get("clippingMode"))
|
|
452
|
+
_optionally(card, "image_adaption", view_info.get("beingImageAdaption"))
|
|
453
|
+
_optionally(card, "show_field_title", view_info.get("beingShowQueTitle"))
|
|
454
|
+
_optionally(card, "show_title", view_info.get("beingShowTitleQue"))
|
|
455
|
+
_optionally(card, "show_cover", view_info.get("beingShowCover"))
|
|
456
|
+
if group_que_id and group_que_id not in fields_by_que_id:
|
|
457
|
+
gaps.append(_gap("card.group_que_id", "unresolved_que_id", "card group queId not in form field schema", que_id=group_que_id))
|
|
458
|
+
else:
|
|
459
|
+
_optionally(card, "group_que_id", group_que_id)
|
|
460
|
+
_optionally(card, "group_color", view_info.get("beingGroupColor"))
|
|
461
|
+
if card:
|
|
462
|
+
declaration["card"] = card
|
|
463
|
+
|
|
464
|
+
filter_gap_count = len(gaps)
|
|
465
|
+
filters = _extract_filters(view_info, fields_by_que_id=fields_by_que_id, reason_path="filters", gaps=gaps)
|
|
466
|
+
if len(gaps) > filter_gap_count:
|
|
467
|
+
return {}, gaps
|
|
468
|
+
if filters is not None:
|
|
469
|
+
declaration["filters"] = filters
|
|
470
|
+
|
|
471
|
+
data_permission_type = str(view_info.get("dataPermissionType") or "").strip().upper()
|
|
472
|
+
data_scope = _DATA_SCOPE_TO_PUBLIC.get(str(view_info.get("dataScope") or "").strip().upper())
|
|
473
|
+
if data_permission_type or data_scope:
|
|
474
|
+
declaration["data_permission"] = {
|
|
475
|
+
"type": "config" if data_permission_type == "CONFIG" else "custom",
|
|
476
|
+
"scope": data_scope or "all",
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
sort = _extract_sort(view_info, fields_by_que_id=fields_by_que_id, reason_path="sort", gaps=gaps)
|
|
480
|
+
if sort is not None:
|
|
481
|
+
declaration["sort"] = sort
|
|
482
|
+
|
|
483
|
+
auth = view_info.get("auth")
|
|
484
|
+
if auth is not None:
|
|
485
|
+
visibility = _public_visibility_from_member_auth(auth)
|
|
486
|
+
# mode="specific" + 空 selectors 是合法形态(auth=2 但未配置成员);
|
|
487
|
+
# apply 回写 auth=2 + 空成员列表,语义保真,不降级。
|
|
488
|
+
declaration["visibility"] = visibility
|
|
489
|
+
|
|
490
|
+
button_gap_count = len(gaps)
|
|
491
|
+
buttons = _extract_buttons(view_info, fields_by_que_id=fields_by_que_id, reason_path="buttons", gaps=gaps)
|
|
492
|
+
if len(gaps) > button_gap_count:
|
|
493
|
+
return {}, gaps
|
|
494
|
+
if _extract_view_button_entries(view_info)[1] is not None:
|
|
495
|
+
declaration["buttons"] = buttons
|
|
496
|
+
|
|
497
|
+
if view_info.get("queryConditionStatus") is not None:
|
|
498
|
+
rows: list[list[str]] = []
|
|
499
|
+
for raw_row in view_info.get("queryCondition") or []:
|
|
500
|
+
if not isinstance(raw_row, dict):
|
|
501
|
+
continue
|
|
502
|
+
row_names: list[str] = []
|
|
503
|
+
for index, que_id in enumerate(raw_row.get("queIds") or []):
|
|
504
|
+
parsed = _coerce_positive_int(que_id)
|
|
505
|
+
if parsed and parsed in fields_by_que_id:
|
|
506
|
+
row_names.append(parsed)
|
|
507
|
+
if row_names:
|
|
508
|
+
rows.append(row_names)
|
|
509
|
+
if view_info.get("queryConditionStatus") and not rows:
|
|
510
|
+
gaps.append(_gap("query_conditions", "unresolved_query_condition", "queryConditionStatus=true but no queIds resolve to field names"))
|
|
511
|
+
else:
|
|
512
|
+
declaration["query_conditions"] = {
|
|
513
|
+
"enabled": bool(view_info.get("queryConditionStatus")),
|
|
514
|
+
"exact": bool(view_info.get("queryConditionExact")),
|
|
515
|
+
"hide_before_query": bool(view_info.get("hideBeforeQueryCondition")),
|
|
516
|
+
"rows": rows,
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
asos_raw = view_info.get("asosChartConfig") if isinstance(view_info.get("asosChartConfig"), dict) else {}
|
|
520
|
+
asos_visible = view_info.get("asosChartVisible")
|
|
521
|
+
if asos_visible is not None or asos_raw:
|
|
522
|
+
associated: dict[str, Any] = {"visible": bool(asos_visible)}
|
|
523
|
+
if associated["visible"]:
|
|
524
|
+
limit_type = asos_raw.get("limitType")
|
|
525
|
+
if limit_type is not None:
|
|
526
|
+
associated["limit_type"] = "all" if int(limit_type) == ASSOCIATED_RESOURCE_LIMIT_TYPE_ALL else "select"
|
|
527
|
+
item_ids = [item for item in asos_raw.get("asosChartIdList") or [] if item is not None]
|
|
528
|
+
if item_ids:
|
|
529
|
+
associated["associated_item_ids"] = item_ids
|
|
530
|
+
declaration["associated_resources"] = associated
|
|
531
|
+
|
|
532
|
+
display: dict[str, Any] = {}
|
|
533
|
+
_optionally(display, "row_height", view_info.get("defaultRowHigh"))
|
|
534
|
+
_optionally(display, "pin_navigate", view_info.get("beingPinNavigate"))
|
|
535
|
+
_optionally(display, "remark", view_info.get("remark"))
|
|
536
|
+
if display:
|
|
537
|
+
declaration["display"] = display
|
|
538
|
+
|
|
539
|
+
record: dict[str, Any] = {}
|
|
540
|
+
for source, target in (
|
|
541
|
+
("beingAuditRecordVisible", "audit_visible"),
|
|
542
|
+
("beingDataLogVisible", "data_log_visible"),
|
|
543
|
+
("beingQrobotRecordVisible", "qrobot_record_visible"),
|
|
544
|
+
("beingWorkflowNodeFutureListVisible", "workflow_future_visible"),
|
|
545
|
+
("beingCommentStatus", "comment"),
|
|
546
|
+
):
|
|
547
|
+
if view_info.get(source) is not None:
|
|
548
|
+
record[target] = bool(view_info.get(source))
|
|
549
|
+
if record:
|
|
550
|
+
declaration["record"] = record
|
|
551
|
+
|
|
552
|
+
share: dict[str, Any] = {}
|
|
553
|
+
if view_info.get("beingNeedPass") is not None:
|
|
554
|
+
share["need_pass"] = bool(view_info.get("beingNeedPass"))
|
|
555
|
+
# 历史数据保全项(legacy_data_preserved):存量密码不符合当前安全策略(49603)——
|
|
556
|
+
# 视图配置本身合法,后端策略演进导致无法原样回写。提取不回传敏感值,
|
|
557
|
+
# apply 显式清空触发后端 isNotBlank(空)保留语义,原密码/开关零丢失。
|
|
558
|
+
# 这类不是失败,单列统计。
|
|
559
|
+
if view_info.get("beingNeedPass") and str(view_info.get("viewgraphPass") or "").strip():
|
|
560
|
+
gaps.append(_gap("share.password", "legacy_password_preserved", "legacy password not round-tripped (backend policy); original preserved via isNotBlank semantics"))
|
|
561
|
+
if view_info.get("beingForceLogin") is not None:
|
|
562
|
+
share["force_login"] = bool(view_info.get("beingForceLogin"))
|
|
563
|
+
if view_info.get("beingPrintStatus") is not None:
|
|
564
|
+
share["print_enabled"] = bool(view_info.get("beingPrintStatus"))
|
|
565
|
+
if share:
|
|
566
|
+
declaration["share"] = share
|
|
567
|
+
|
|
568
|
+
if view_info.get("printTpls"):
|
|
569
|
+
gaps.append(_gap("share.print", "print_templates", "printTpls details are deferred to v1alpha2", count=len(view_info.get("printTpls") or [])))
|
|
570
|
+
for key in _UNCOVERED_READBACK_KEYS:
|
|
571
|
+
value = view_info.get(key)
|
|
572
|
+
if value in (None, [], {}, "", False):
|
|
573
|
+
continue
|
|
574
|
+
if key == "querySearchStatus" and value is True and view_info.get("queryConditionStatus") is None:
|
|
575
|
+
continue
|
|
576
|
+
gaps.append(_gap(key, "uncovered_readback_key", "read-back key present in backend payload but outside the v1alpha1 contract", value_preview=str(value)[:80]))
|
|
577
|
+
|
|
578
|
+
return declaration, gaps
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def summarize_gaps(gaps: list[dict[str, Any]]) -> dict[str, int]:
|
|
582
|
+
"""按 kind 聚合 coverage gaps,供覆盖度报告使用。"""
|
|
583
|
+
summary: dict[str, int] = {}
|
|
584
|
+
for gap in gaps:
|
|
585
|
+
summary[gap.get("kind") or "unknown"] = summary.get(gap.get("kind") or "unknown", 0) + 1
|
|
586
|
+
return summary
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
__all__ = [
|
|
590
|
+
"extract_complete_view_declaration",
|
|
591
|
+
"summarize_gaps",
|
|
592
|
+
]
|