@stalfh233/omc-cli 0.6.3 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/call.js +10 -1
- package/dist/capability-absences.js +15 -2
- package/dist/cli-contract.js +23 -4
- package/dist/cli-output.js +23 -10
- package/dist/cli.js +91 -11
- package/dist/command-transport.js +7 -1
- package/dist/discovery.js +32 -3
- package/dist/governed-execution.js +26 -1
- package/dist/lanes/b2-registration-contract.js +1 -1
- package/dist/lanes/b2-registration.js +176 -1
- package/dist/lanes/form.js +20 -0
- package/dist/lanes/model.js +20 -4
- package/dist/lanes/runtime-data.js +71 -9
- package/dist/path-projection.js +31 -7
- package/dist/read.js +31 -5
- package/dist/support.js +12 -0
- package/dist/tool-types.js +18 -0
- package/dist/wire.js +9 -1
- package/dist/write.js +7 -5
- package/package.json +3 -3
- package/skills/omc-start-here.md +1 -1
|
@@ -19,6 +19,82 @@ const B2_TOOLS = B2_LANE_CONTRACT.tools;
|
|
|
19
19
|
function decodeJsonColumn(value) {
|
|
20
20
|
return decodeSharedJsonColumn(value, JSON_COLUMN_NULL_COALESCE);
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* 只读 SQL 判定用的结构视图:剥掉字符串字面量与注释,只留可执行的 SQL 骨架。
|
|
24
|
+
*
|
|
25
|
+
* 为什么需要:`SELECT note FROM ledger WHERE note = 'for update'` 是正常查询,
|
|
26
|
+
* 但直接对全文跑「FOR UPDATE」正则会误杀;反过来,若不剥字面量就放宽规则,
|
|
27
|
+
* `SELECT 1 INTO OUTFILE ...` 这类真副作用又会漏过。
|
|
28
|
+
*
|
|
29
|
+
* 保守约定:引号/注释**不配对**时返回 null(调用方据此拒绝)——不猜残缺 SQL 的
|
|
30
|
+
* 语义。支持 SQL 的两种转义(`''` 与 `\'`)。
|
|
31
|
+
*/
|
|
32
|
+
function stripSqlLiteralsAndComments(sql) {
|
|
33
|
+
let out = "";
|
|
34
|
+
let index = 0;
|
|
35
|
+
while (index < sql.length) {
|
|
36
|
+
const char = sql[index];
|
|
37
|
+
const next = sql[index + 1];
|
|
38
|
+
if (char === "'" || char === '"' || char === "`") {
|
|
39
|
+
const quote = char;
|
|
40
|
+
index += 1;
|
|
41
|
+
let closed = false;
|
|
42
|
+
while (index < sql.length) {
|
|
43
|
+
const current = sql[index];
|
|
44
|
+
if (current === "\\") {
|
|
45
|
+
index += 2;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (current === quote) {
|
|
49
|
+
if (quote !== "`" && sql[index + 1] === quote) {
|
|
50
|
+
index += 2;
|
|
51
|
+
continue;
|
|
52
|
+
} // '' 转义
|
|
53
|
+
index += 1;
|
|
54
|
+
closed = true;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
index += 1;
|
|
58
|
+
}
|
|
59
|
+
if (!closed)
|
|
60
|
+
return null;
|
|
61
|
+
out += " '' ";
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (char === "-" && next === "-" && (index + 2 >= sql.length || /\s/.test(sql[index + 2]))) {
|
|
65
|
+
// SQL/MySQL 的 `--` 注释要求后面跟空白;不跟空白时是算术(`1--2`),
|
|
66
|
+
// 若当注释剥掉会把其后的 `INTO OUTFILE` 一起吞成注释 → 绕过。
|
|
67
|
+
const end = sql.indexOf("\n", index);
|
|
68
|
+
if (end < 0)
|
|
69
|
+
return out;
|
|
70
|
+
index = end;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (char === "#") {
|
|
74
|
+
// MySQL 的 `#` 注释(无需空白)。
|
|
75
|
+
const end = sql.indexOf("\n", index);
|
|
76
|
+
if (end < 0)
|
|
77
|
+
return out;
|
|
78
|
+
index = end;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (char === "/" && next === "*") {
|
|
82
|
+
// MySQL 可执行注释 `/*!50000 ... */` 里的语句**会执行**——不能当注释剥掉,
|
|
83
|
+
// 直接判定为无法静态证明只读。
|
|
84
|
+
if (sql[index + 2] === "!")
|
|
85
|
+
return null;
|
|
86
|
+
const end = sql.indexOf("*/", index + 2);
|
|
87
|
+
if (end < 0)
|
|
88
|
+
return null; // 未闭合注释:拒绝
|
|
89
|
+
index = end + 2;
|
|
90
|
+
out += " ";
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
out += char;
|
|
94
|
+
index += 1;
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
22
98
|
function adapterCodeOf(serviceRow) {
|
|
23
99
|
return typeof serviceRow?.adapterCode === "string" ? serviceRow.adapterCode : "";
|
|
24
100
|
}
|
|
@@ -1294,10 +1370,109 @@ export function registerB2Lane(tools) {
|
|
|
1294
1370
|
const TEST_EVIDENCED_ADAPTERS = new Set(["RESTFUL", "DATA_SQL", "DATA_TABLE", "SOAP", "INTERNAL"]);
|
|
1295
1371
|
/** Adapters whose method configJson must be non-null to be executable (SOAP methods are WSDL-discovered and carry none). */
|
|
1296
1372
|
const TEST_CONFIG_REQUIRED_ADAPTERS = new Set(["RESTFUL", "DATA_SQL", "DATA_TABLE", "SAP", "KAFKA", "INTERNAL"]);
|
|
1373
|
+
/** 只读环境放行判定:只认「显式传参 + DATA_SQL + 严格 SELECT-only」。 */
|
|
1374
|
+
const methodTestReadOnlyEscape = {
|
|
1375
|
+
requested: (args) => args.readOnlyOnReadOnlyEnv === true,
|
|
1376
|
+
verify: async (session, args) => {
|
|
1377
|
+
const serviceCode = String(args.serviceCode ?? "");
|
|
1378
|
+
const methodCode = String(args.methodCode ?? "");
|
|
1379
|
+
const refusal = (blocker, message, extra = {}) => ({
|
|
1380
|
+
ok: false,
|
|
1381
|
+
refusal: { status: "refused", blocker, serviceCode, methodCode, message, ...extra },
|
|
1382
|
+
});
|
|
1383
|
+
let serviceRow;
|
|
1384
|
+
try {
|
|
1385
|
+
serviceRow = (await wireCall(session, { path: ENDPOINTS.bizServiceGetByCode, query: { serviceCode } })).data;
|
|
1386
|
+
}
|
|
1387
|
+
catch (error) {
|
|
1388
|
+
return refusal("read-only-escape-unverified", `只读放行需要先证明方法确实是只读 SELECT,但服务配置读回失败(${error instanceof Error ? error.message : String(error)})——零试跑调用。`);
|
|
1389
|
+
}
|
|
1390
|
+
if (serviceRow === null)
|
|
1391
|
+
return refusal("wire-business", `业务服务不存在:${serviceCode}`);
|
|
1392
|
+
const adapterCode = adapterCodeOf(serviceRow);
|
|
1393
|
+
if (adapterCode !== "DATA_SQL")
|
|
1394
|
+
return refusal("read-only-escape-adapter-refused", `readOnlyOnReadOnlyEnv 只对 DATA_SQL 适配器生效(本次 ${adapterCode || "(空)"}):RESTFUL/SOAP/INTERNAL 等会真实打外部系统(可能 INSERT/UPDATE),无法静态证明只读,只读环境一律拒绝。`, { adapterCode });
|
|
1395
|
+
let rows;
|
|
1396
|
+
try {
|
|
1397
|
+
rows = wireRows((await wireCall(session, { path: ENDPOINTS.bizServiceMethodList, query: { serviceCode, filterFlag: "false" } })).data);
|
|
1398
|
+
}
|
|
1399
|
+
catch (error) {
|
|
1400
|
+
return refusal("read-only-escape-unverified", `方法面读回失败(${error instanceof Error ? error.message : String(error)})——零试跑调用。`);
|
|
1401
|
+
}
|
|
1402
|
+
const method = rows.find((row) => row.code === methodCode);
|
|
1403
|
+
if (!method)
|
|
1404
|
+
return refusal("wire-business", `业务方法不存在:${serviceCode}/${methodCode}`);
|
|
1405
|
+
// 平台把方法 configJson 以**明文 JSON**落库(controller 解密后存明文;
|
|
1406
|
+
// 回读面同样是明文)。个别部署/中间层可能回密文,所以两种都认:先当明文
|
|
1407
|
+
// JSON 解析,失败再按 AES 解密——两条都失败就拒绝(不能证明只读)。
|
|
1408
|
+
const rawConfig = typeof method.configJson === "string" ? method.configJson : "";
|
|
1409
|
+
if (!rawConfig)
|
|
1410
|
+
return refusal("bizservice-test-shell", `方法 ${methodCode} 是注册壳(无 configJson)——不可执行,只读放行不适用。`);
|
|
1411
|
+
let parsedConfig;
|
|
1412
|
+
try {
|
|
1413
|
+
parsedConfig = JSON.parse(rawConfig);
|
|
1414
|
+
}
|
|
1415
|
+
catch {
|
|
1416
|
+
try {
|
|
1417
|
+
parsedConfig = JSON.parse((await omcCore()).decryptBizServiceMethodConfigJson(rawConfig));
|
|
1418
|
+
}
|
|
1419
|
+
catch (error) {
|
|
1420
|
+
return refusal("read-only-escape-unverified", `configJson 既不是明文 JSON 也无法解密(${error instanceof Error ? error.message : String(error)})——不能证明只读,零试跑调用。`);
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
const sql = typeof parsedConfig.sql === "string" ? parsedConfig.sql : "";
|
|
1424
|
+
const trimmed = sql.trim().replace(/^\(+/, "").trim();
|
|
1425
|
+
// 严格 SELECT-only:`;` 多语句、WITH 后接 DML 一律不放行(宁可误杀)。
|
|
1426
|
+
// 审查补强(2026-11-04):`/^select/` 远不等于只读——下面这些都是真实
|
|
1427
|
+
// 副作用写,却被首词 select 掩护:
|
|
1428
|
+
// SELECT … INTO OUTFILE/DUMPFILE '/tmp/x' → 落盘写文件
|
|
1429
|
+
// SELECT … INTO @v → 会话变量(配合后续可用)
|
|
1430
|
+
// SELECT … FOR UPDATE / FOR SHARE / LOCK IN SHARE MODE → 行锁
|
|
1431
|
+
// SELECT 1\nDELETE FROM t → 换行分隔的第二条语句(无分号)
|
|
1432
|
+
// 只读环境上放行这些等于给写副作用开口子,所以一律拒绝。
|
|
1433
|
+
// 先剥掉字符串字面量与注释再判(否则 `WHERE note = 'for update'` 被误杀);
|
|
1434
|
+
// 引号/注释不配对就直接拒绝——那段 SQL 本身就有问题,不猜。
|
|
1435
|
+
const structural = stripSqlLiteralsAndComments(sql);
|
|
1436
|
+
if (structural === null)
|
|
1437
|
+
return refusal("read-only-escape-not-select", `readOnlyOnReadOnlyEnv 需要能静态判定的 SQL:方法 ${methodCode} 的 SQL 字符串/注释引号不配对,无法证明只读(零试跑调用)。`, { sqlPreview: sql.slice(0, 120) });
|
|
1438
|
+
const dangerousClauses = [
|
|
1439
|
+
{ pattern: /\binto\s+(outfile|dumpfile)\b/i, label: "INTO OUTFILE/DUMPFILE(写文件)" },
|
|
1440
|
+
{ pattern: /\binto\s+@\w+/i, label: "INTO @变量(会话变量)" },
|
|
1441
|
+
{ pattern: /\bfor\s+(update|share)\b/i, label: "FOR UPDATE/SHARE(行锁)" },
|
|
1442
|
+
{ pattern: /\block\s+in\s+share\s+mode\b/i, label: "LOCK IN SHARE MODE(行锁)" },
|
|
1443
|
+
// 不再只盯「换行后的第二条语句」:剥离注释后 `SELECT 1 /*x*/ DELETE …`
|
|
1444
|
+
// 会变成同一行,只查换行前缀就漏。改成全文任何位置出现写关键字即拒绝
|
|
1445
|
+
// (fail-closed;正常 SELECT 不会出现这些词,列名如 update_log 有下划线
|
|
1446
|
+
// 不算词边界,不会被误杀)。
|
|
1447
|
+
{ pattern: /\b(insert|update|delete|drop|alter|truncate|create|replace|call|exec|execute|merge|grant|revoke)\b/i, label: "写/DDL 关键字(非纯 SELECT)" },
|
|
1448
|
+
];
|
|
1449
|
+
const dangerous = dangerousClauses.find((entry) => entry.pattern.test(structural));
|
|
1450
|
+
const looksSelectOnly = /^select\b/i.test(trimmed) && !/[;]/.test(structural) && !/^with\b/i.test(trimmed) && dangerous === undefined;
|
|
1451
|
+
if (!looksSelectOnly)
|
|
1452
|
+
return refusal("read-only-escape-not-select", dangerous !== undefined
|
|
1453
|
+
? `readOnlyOnReadOnlyEnv 只放行**纯读取**的 SELECT(方法 ${methodCode} 的 SQL 含 ${dangerous.label})——这在只读环境上仍然有副作用,请在可写环境执行。`
|
|
1454
|
+
: `readOnlyOnReadOnlyEnv 只放行单条 SELECT(方法 ${methodCode} 的 SQL 不是严格 SELECT-only:禁止 ';' 多语句、WITH…DML、非 SELECT 语句)。要跑写库/DDL 方法请在可写环境执行。`, { sqlPreview: sql.slice(0, 120), ...(dangerous !== undefined ? { rejectedClause: dangerous.label } : {}) });
|
|
1455
|
+
return {
|
|
1456
|
+
ok: true,
|
|
1457
|
+
note: {
|
|
1458
|
+
reason: "read-only-select-on-read-only-environment",
|
|
1459
|
+
adapterCode,
|
|
1460
|
+
methodCode,
|
|
1461
|
+
sqlPreview: sql.slice(0, 120),
|
|
1462
|
+
note: "只读环境 + 显式 readOnlyOnReadOnlyEnv + DATA_SQL 严格 SELECT-only:本次试跑已放行;它仍会真调数据库(SELECT)并记集成日志,不写配置行。",
|
|
1463
|
+
},
|
|
1464
|
+
};
|
|
1465
|
+
},
|
|
1466
|
+
};
|
|
1297
1467
|
writeTool(tools, "method.test", B2_LANE, B2_TOOLS["method.test"].scope, B2_TOOLS["method.test"].description, {
|
|
1298
1468
|
serviceCode: z.string().min(1),
|
|
1299
1469
|
methodCode: z.string().min(1),
|
|
1300
1470
|
testInputParametersMap: z.record(z.string(), z.unknown()).describe("试跑入参:参数 code → JSON 值(BizServiceMethodVO Map<String,Object>;按方法声明参数面校验)"),
|
|
1471
|
+
// P2-5(GitLab #18):method.test 是治理写(会真调外部系统),只读环境下默认
|
|
1472
|
+
// 拒绝——但一条纯 SELECT 的 DATA_SQL 方法没有任何副作用,此前却必须先把
|
|
1473
|
+
// 生产环境改成 writable 才能读数据(安全语义倒置)。显式传本参数 + 工具
|
|
1474
|
+
// 用真实配置证明「DATA_SQL 且严格 SELECT-only」才放行,其余一律原写门。
|
|
1475
|
+
readOnlyOnReadOnlyEnv: z.boolean().optional().describe("只读环境(writable=false)里显式放行本次试跑:仅当服务是 DATA_SQL 且方法 SQL 是单条 SELECT 时生效;不传=按写门拒绝(默认)。仍会真执行 SELECT 并记集成日志,不写配置行"),
|
|
1301
1476
|
}, async (session, args) => {
|
|
1302
1477
|
// Ticket 107 — a REAL one-shot execution of the configured method
|
|
1303
1478
|
// (BizServiceMethodController.java:398-412 → BizServiceFacadeImpl:426-464
|
|
@@ -1398,6 +1573,6 @@ export function registerB2Lane(tools) {
|
|
|
1398
1573
|
...(externalSuccess ? {} : { externalFailure }),
|
|
1399
1574
|
},
|
|
1400
1575
|
};
|
|
1401
|
-
});
|
|
1576
|
+
}, undefined, undefined, undefined, methodTestReadOnlyEscape);
|
|
1402
1577
|
}
|
|
1403
1578
|
//# sourceMappingURL=b2-registration.js.map
|
package/dist/lanes/form.js
CHANGED
|
@@ -144,21 +144,41 @@ export function registerFormLane(tools) {
|
|
|
144
144
|
...(wanted ? { draftControls: pick(sheet.draftAttributesJson), publishedControls: pick(sheet.publishedAttributesJson) } : {}),
|
|
145
145
|
};
|
|
146
146
|
}, {
|
|
147
|
+
// T1(GitLab #17,2026-11-04):`full` 此前没传,于是 form.get --full 永远
|
|
148
|
+
// 整包内联(实测 94.9KB 只为 grep 一个串)——与 data/workflow 的 8KB 读门
|
|
149
|
+
// 不一致,且外置态的根键随来源漂移。传入后大包走 fullOmitted + artifact,
|
|
150
|
+
// 小包行为不变(≤8KB 仍原样内联)。
|
|
151
|
+
full: args.full === true,
|
|
147
152
|
// 2026-11-01 读门 2048:controls=true 是**显式**取控件定义的请求——compact
|
|
148
153
|
// 保留定义映射与按钮/options(--full 的语义面),其余大数组归码表/计数。
|
|
154
|
+
// T1:六份布局 JSON 是大**字符串**(不是数组/对象),旧 compact 的 else 分支
|
|
155
|
+
// 原样保留 → 外置了也没省体积。超过 4KB 的单串改为 `*Omitted` + 字节数,
|
|
156
|
+
// 正文用 `artifact get <detailArtifactId> --jq draftViewJson` 定向取回。
|
|
149
157
|
compact: (result) => {
|
|
150
158
|
const KEEP = new Set(["draftControls", "publishedControls", "customButtonJson", "options", "formButtons", "formButtonCodes"]);
|
|
151
159
|
const out = {};
|
|
160
|
+
const omitted = [];
|
|
152
161
|
for (const [key, value] of Object.entries(result)) {
|
|
153
162
|
if (KEEP.has(key))
|
|
154
163
|
out[key] = value;
|
|
164
|
+
else if (typeof value === "string" && Buffer.byteLength(value, "utf8") > 4096) {
|
|
165
|
+
out[`${key}Omitted`] = true;
|
|
166
|
+
out[`${key}Bytes`] = Buffer.byteLength(value, "utf8");
|
|
167
|
+
omitted.push(key);
|
|
168
|
+
}
|
|
155
169
|
else if (Array.isArray(value))
|
|
156
170
|
out[`${key}Codes`] = value.map((entry) => (entry && typeof entry === "object" ? String(entry.code ?? entry.propertyCode ?? "") : String(entry))).filter((entry) => entry.length > 0), out[`${key}Count`] = value.length;
|
|
171
|
+
// 大**对象**也要降载:审查 2026-11-04 指出旧 else 分支把
|
|
172
|
+
// htmlRenderCompat 这类非 KEEP 对象整包内联,抵消了 T1 的省体积目标。
|
|
157
173
|
else if (value !== null && typeof value === "object")
|
|
158
174
|
out[`${key}Count`] = Object.keys(value).length;
|
|
159
175
|
else
|
|
160
176
|
out[key] = value;
|
|
161
177
|
}
|
|
178
|
+
// 有任一布局被外置时给一行可执行取回路径,避免把 Omitted 面误读成
|
|
179
|
+
// 「这个表单没有布局」。
|
|
180
|
+
if (omitted.length > 0)
|
|
181
|
+
out.layoutHint = `这些布局正文已外置(${omitted.join("、")}):用 \`omc artifact get <detailArtifactId> --jq <key>\` 取原文,例如 --jq draftViewJson。**不要**把 Omitted 面当空布局回写 form.draft(fromReadback 需要 artifact 里的原文)。`;
|
|
162
182
|
return out;
|
|
163
183
|
},
|
|
164
184
|
}),
|
package/dist/lanes/model.js
CHANGED
|
@@ -9,7 +9,7 @@ import { writeSummary } from "../human-summary.js";
|
|
|
9
9
|
import { capabilityArg, environmentArg } from "../args.js";
|
|
10
10
|
import { governedRead, withoutReadbackCache } from "../read.js";
|
|
11
11
|
import { parseToolInput } from "../tool-types.js";
|
|
12
|
-
import { applicationResolutionOf, applicationScopeHint, flattenTreeRows, resolveApplicationCode, resolveApplicationScope } from "../support.js";
|
|
12
|
+
import { applicationResolutionOf, applicationScopeHint, applicationScopeNotice, flattenTreeRows, resolveApplicationCode, resolveApplicationScope } from "../support.js";
|
|
13
13
|
import { writeTool } from "../write.js";
|
|
14
14
|
import { registerPackageTool } from "../package-tool.js";
|
|
15
15
|
import { defineTool, ToolInputError, ToolRefusalError } from "../tool-types.js";
|
|
@@ -221,6 +221,7 @@ export function registerModelLane(tools) {
|
|
|
221
221
|
totalBeforeFilter: all.length,
|
|
222
222
|
filterApplied: needle !== undefined && needle !== "",
|
|
223
223
|
...(needle ? { filter: needle } : {}),
|
|
224
|
+
scopeHint: applicationScopeNotice(scope),
|
|
224
225
|
models,
|
|
225
226
|
...(models.length === 0 ? { hint: applicationScopeHint(scope, "模型") } : {}),
|
|
226
227
|
};
|
|
@@ -275,14 +276,28 @@ export function registerModelLane(tools) {
|
|
|
275
276
|
// parallel (bounded) instead of paying six sequential round-trips. Only
|
|
276
277
|
// workflow.get_published stays chained (it needs workflowCode from the
|
|
277
278
|
// draft face). Output ordering is unchanged (results land by index).
|
|
278
|
-
|
|
279
|
+
// T4(GitLab #17,2026-11-04):业务方永远按中文菜单名提问,而本面此前不
|
|
280
|
+
// 回显示名——只能靠 listview.list 反推(未配视图的模型无路可查)。应用树
|
|
281
|
+
// 节点本来就带 name,与六面同一批并发读,零额外顺序往返。
|
|
282
|
+
const treeFace = async () => {
|
|
283
|
+
try {
|
|
284
|
+
return { node: (await resolveModelTreeNode(session, schemaCode)).node };
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
const [fieldFace, formFace, listviewFace, ruleFace, dataruleFace, draftFace, treeRead] = await mapBounded([
|
|
279
291
|
() => face("field.list", { schemaCode, published: true, ...(all ? { all: true } : {}) }, faceErrcodes),
|
|
280
292
|
() => face("form.get", { schemaCode }),
|
|
281
293
|
() => face("listview.get", { schemaCode }),
|
|
282
294
|
() => face("rule.list", { schemaCode }),
|
|
283
295
|
() => face("datarule.list", { schemaCode }),
|
|
284
296
|
() => face("workflow.get_draft", { schemaCode }),
|
|
285
|
-
|
|
297
|
+
() => treeFace(),
|
|
298
|
+
], 7, (task) => task());
|
|
299
|
+
const treeNode = treeRead?.node;
|
|
300
|
+
const modelName = typeof treeNode?.name === "string" && treeNode.name ? treeNode.name : typeof treeNode?.text === "string" && treeNode.text ? treeNode.text : undefined;
|
|
286
301
|
const fieldRows = Array.isArray(fieldFace?.fields) ? fieldFace.fields : [];
|
|
287
302
|
const fields = fieldRows
|
|
288
303
|
.filter((field) => all || !SYSTEM_FIELD_CODES.has(String(field.code)))
|
|
@@ -353,6 +368,7 @@ export function registerModelLane(tools) {
|
|
|
353
368
|
const modelPresent = absent ? false : fieldFace !== undefined && !fieldRefused ? true : undefined;
|
|
354
369
|
return {
|
|
355
370
|
schemaCode,
|
|
371
|
+
...(modelName !== undefined ? { name: modelName } : {}),
|
|
356
372
|
...(modelPresent !== undefined ? { modelPresent } : {}),
|
|
357
373
|
...(modelPresent === false
|
|
358
374
|
? {
|
|
@@ -367,7 +383,7 @@ export function registerModelLane(tools) {
|
|
|
367
383
|
rulesCustom: rules.filter((rule) => rule.defaultRule !== true).map((rule) => rule.code),
|
|
368
384
|
datarules,
|
|
369
385
|
workflow,
|
|
370
|
-
hint: "model.inspect 聚合设计时六面(fields/form/listview/rules/datarules/workflow
|
|
386
|
+
hint: "model.inspect 聚合设计时六面(fields/form/listview/rules/datarules/workflow,摘要);name 是模型显示名(业务方的中文菜单名),与 schemaCode 一一对应,不必再用 listview.list 反推。modelPresent 是存在性判据(field 面 300004=false);字段 options 细节用 field.list --code;规则图用 rule.get schemaCode+ruleCode;运行数据用 data.list(不在此面)。",
|
|
371
387
|
};
|
|
372
388
|
};
|
|
373
389
|
const one = await inspectOne(codes[0]);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { FilterDslError, injectPropertyTypes, toQueryCondition } from "../data-list-filter.js";
|
|
3
|
+
import { FilterDslError, injectPropertyTypes, SIMPLE_OP_MAP, toQueryCondition } from "../data-list-filter.js";
|
|
4
4
|
import { ENDPOINTS } from "../endpoints.js";
|
|
5
5
|
import { capabilityArg, environmentArg } from "../args.js";
|
|
6
6
|
import { governedRead } from "../read.js";
|
|
@@ -759,6 +759,9 @@ export function registerRuntimeDataLane(tools) {
|
|
|
759
759
|
lane: RUNTIME_LANE,
|
|
760
760
|
scope: RUNTIME_TOOLS["data.list"].scope,
|
|
761
761
|
description: RUNTIME_TOOLS["data.list"].description,
|
|
762
|
+
// T3(GitLab #17):op 的封闭取值来自 DSL 的 SIMPLE_OP_MAP(不是 zod enum——
|
|
763
|
+
// 那样会抢先拒绝、丢掉带 acceptedOps 的说明性拒绝)。默认 describe 内联一次。
|
|
764
|
+
paramEnums: { filter: Object.keys(SIMPLE_OP_MAP).join("|") },
|
|
762
765
|
inputSchema: {
|
|
763
766
|
environment: environmentArg,
|
|
764
767
|
schemaCode: z.string().min(1),
|
|
@@ -768,7 +771,7 @@ export function registerRuntimeDataLane(tools) {
|
|
|
768
771
|
// `Expected object, received array`). An array is the caller's AND-list,
|
|
769
772
|
// so accept it and treat it as `filters` rather than costing a retry
|
|
770
773
|
// (dogfood R1 round17).
|
|
771
|
-
filter: z.union([z.record(z.string(), z.unknown()), z.array(z.record(z.string(), z.unknown()))]).optional().describe('one clause { op, field, value? } (or an array of clauses = AND); op
|
|
774
|
+
filter: z.union([z.record(z.string(), z.unknown()), z.array(z.record(z.string(), z.unknown()))]).optional().describe('one clause { op, field, value? } (or an array of clauses = AND); op 取值见过滤语义:eq/neq/gt/lt/gte/lte/like/notlike/in/notin/between/notbetween/isnull/isnotnull(camelCase 别名 equally valid: equals/notEquals/contains/isNull/isNotNull)。**like/contains 是子串包含(String.includes),不是前缀匹配,`%` / `_` 是字面量而非通配符**;按前缀分片请用 gte/lt 区间或更长的前缀+本地去重,别假设 like:"0%" 只命中 0 开头(GitLab #18 P1-3 实测 4 个"前缀分片"互相重叠 2258 条)'),
|
|
772
775
|
filters: z.array(z.record(z.string(), z.unknown())).optional().describe("clauses combined with AND; a malformed clause is refused with blocker invalid-filter-DSL"),
|
|
773
776
|
filterGroup: z.record(z.string(), z.unknown()).optional().describe('nested group { op:"and"|"or", clauses:[clause|group] }'),
|
|
774
777
|
rawCondition: z.unknown().optional(),
|
|
@@ -779,7 +782,7 @@ export function registerRuntimeDataLane(tools) {
|
|
|
779
782
|
showTotal: z.boolean().default(true),
|
|
780
783
|
page: z.number().int().min(0).default(0),
|
|
781
784
|
size: z.number().int().min(1).max(200).default(10),
|
|
782
|
-
all: z.boolean().optional().describe("true 时自动翻页聚合全部结果(内部按 size=200 逐页拉取,安全上限 1000
|
|
785
|
+
all: z.boolean().optional().describe("true 时自动翻页聚合全部结果(内部按 size=200 逐页拉取,安全上限 1000 行;超过时回 truncated:true + missingCount,别当全集)——存量盘点/数据核对用,替代手动循环翻页。与 page 互斥(all 固定从第 0 页起)"),
|
|
783
786
|
full: z.boolean().optional().describe("内联完整列表结果;默认大结果只返回计数与 detailFile"),
|
|
784
787
|
// r31 (2026-10-31 real machine): agents pass `query: {field: value}` as a
|
|
785
788
|
// filter — zod would STRIP the unknown key silently and the server
|
|
@@ -816,7 +819,7 @@ export function registerRuntimeDataLane(tools) {
|
|
|
816
819
|
status: "refused",
|
|
817
820
|
blocker: "invalid-filter-DSL",
|
|
818
821
|
reason: error.code,
|
|
819
|
-
message: `${error.message};filter 的最小合法形状:{"filter":{"op":"eq","field":"<字段code>","value":"<值>"}}(op 取 eq/neq/gt/lt/gte/lte/like/notlike/in/notin/between/notbetween/isnull/isnotnull
|
|
822
|
+
message: `${error.message};filter 的最小合法形状:{"filter":{"op":"eq","field":"<字段code>","value":"<值>"}}(op 取 eq/neq/gt/lt/gte/lte/like/notlike/in/notin/between/notbetween/isnull/isnotnull;like/contains 是**子串包含**、% 非通配)。多条件用 filters:[clause,…](AND)或 filterGroup:{op:"and"|"or",clauses:[…]}。可运行示例:describe data.list --example list-filter-equality`,
|
|
820
823
|
minimalValidFilter: { filter: { op: "eq", field: "<fieldCode>", value: "<value>" } },
|
|
821
824
|
acceptedOps: ["eq", "neq", "gt", "lt", "gte", "lte", "like", "notlike", "in", "notin", "between", "notbetween", "isnull", "isnotnull"],
|
|
822
825
|
};
|
|
@@ -856,6 +859,20 @@ export function registerRuntimeDataLane(tools) {
|
|
|
856
859
|
// 字典 key 匹配)。带出这些字段,total:0 时给写法提示,防"查不到数据"
|
|
857
860
|
// 误报(实测 313 条被 total:0 误读成"今天没有已审核单")。
|
|
858
861
|
choiceFilterFields = collectConditionLeafFields(condition).filter((code) => CHOICE_FILTER_PROPERTY_TYPES.has(typeByCode.get(code) ?? -1));
|
|
862
|
+
// P1-4(GitLab #18,2026-11-04):子表字段(CHILD_TABLE=8)的值是行数组,
|
|
863
|
+
// 运行面 query/list 只做标量过滤——拿它当 filter 字段会回一个没有明细的
|
|
864
|
+
// errcode 50000「服务异常」,被读成"平台挂了"。元数据已证它是子表,就在
|
|
865
|
+
// 发查询前拒绝并给替代路径(data.load 读主表后看子表数组)。
|
|
866
|
+
const childTableFields = collectConditionLeafFields(condition).filter((code) => typeByCode.get(code) === CHILD_TABLE_PROPERTY_TYPE);
|
|
867
|
+
if (childTableFields.length > 0)
|
|
868
|
+
return {
|
|
869
|
+
status: "refused",
|
|
870
|
+
blocker: "unsupported-filter-on-child-table",
|
|
871
|
+
schemaCode: args.schemaCode,
|
|
872
|
+
fields: childTableFields,
|
|
873
|
+
message: `这些字段是子表(propertyType=${CHILD_TABLE_PROPERTY_TYPE},值是行数组):运行面 query/list 只支持标量过滤,按子表字段过滤会回 errcode 50000 且无明细。改法:用 data.load 读主表记录(子表行随主记录返回),或在本地按子表字段筛选。`,
|
|
874
|
+
nextCommand: `omc call data.load --env <env> --args '{"schemaCode":"${args.schemaCode}","bizObjectId":"<主表 bizObjectId>"}'`,
|
|
875
|
+
};
|
|
859
876
|
}
|
|
860
877
|
const requestedFields = args.displayFields ?? args.columns;
|
|
861
878
|
const fields = requestedFields && requestedFields.length > 0 ? requestedFields : undefined;
|
|
@@ -875,6 +892,17 @@ export function registerRuntimeDataLane(tools) {
|
|
|
875
892
|
if (args.all === true) {
|
|
876
893
|
const CAP = 1000;
|
|
877
894
|
const pageSize = 200;
|
|
895
|
+
// GitLab #18 P1-2: `all:true` is "aggregate from page 0 up to the cap".
|
|
896
|
+
// A caller who ALSO passed page expected server-side paging, got page 0
|
|
897
|
+
// silently (page:6 → 0 rows, read as "no data"). Refuse the combo
|
|
898
|
+
// instead of guessing which one they meant.
|
|
899
|
+
if ((args.page ?? 0) !== 0)
|
|
900
|
+
throw new ToolInputError({
|
|
901
|
+
status: "error",
|
|
902
|
+
blocker: "invalid-input",
|
|
903
|
+
tool: "data.list",
|
|
904
|
+
message: `all:true 与 page 互斥:all 固定从第 0 页聚合到上限 ${CAP} 行;要指定页请去掉 all(page/size 手动翻页),要全集请用 filters 分片。`,
|
|
905
|
+
});
|
|
878
906
|
const firstBody = { ...body, page: 0, size: pageSize };
|
|
879
907
|
const first = await wireCall(session, { path: ENDPOINTS.runtimeQueryList, method: "POST", body: firstBody });
|
|
880
908
|
const firstPage = extractBizObjectPage(first.data);
|
|
@@ -892,8 +920,15 @@ export function registerRuntimeDataLane(tools) {
|
|
|
892
920
|
rows.push(...nextPage.content);
|
|
893
921
|
pageIndex += 1;
|
|
894
922
|
}
|
|
895
|
-
|
|
896
|
-
|
|
923
|
+
// 翻页按 size=200 走,最后一页可能越过 total(total=999 会拉到 1000 行);
|
|
924
|
+
// 先按 total 夹一次,itemsCount/items.length 才与 total 可比。
|
|
925
|
+
const collected = rows.length > total ? rows.slice(0, total) : rows;
|
|
926
|
+
// GitLab #18 P1-2:`capped = rows.length > CAP` 是死代码——target 已被
|
|
927
|
+
// Math.min(total, CAP) 夹住,rows 永远到不了 CAP+1,于是 total>1000 时
|
|
928
|
+
// 返回「total:1043 / items:1000 / 无任何标记」,43 条静默消失(实测被
|
|
929
|
+
// 读成"数据就这么多")。真正的截断判据是「拿到的比总量少」。
|
|
930
|
+
const truncatedByCap = collected.length < total;
|
|
931
|
+
const items = collected.map((row0) => {
|
|
897
932
|
const row = row0;
|
|
898
933
|
const rawData = row.data && typeof row.data === "object" ? row.data : {};
|
|
899
934
|
const data = args.full === true ? rawData : compactRuntimeRecord(rawData);
|
|
@@ -915,7 +950,27 @@ export function registerRuntimeDataLane(tools) {
|
|
|
915
950
|
// 元数据读失败不阻断数据返回
|
|
916
951
|
}
|
|
917
952
|
}
|
|
918
|
-
return {
|
|
953
|
+
return {
|
|
954
|
+
schemaCode: args.schemaCode,
|
|
955
|
+
total,
|
|
956
|
+
page: "all",
|
|
957
|
+
size: collected.length,
|
|
958
|
+
pagesFetched: pageIndex,
|
|
959
|
+
// 形状常量:脚本按 result.items 取数(与内联态一致),itemsCount 与
|
|
960
|
+
// total 的差就是被截断的条数——不再只靠 size 反推。
|
|
961
|
+
itemsCount: collected.length,
|
|
962
|
+
...(truncatedByCap
|
|
963
|
+
? {
|
|
964
|
+
truncated: true,
|
|
965
|
+
capped: true,
|
|
966
|
+
cap: CAP,
|
|
967
|
+
missingCount: total - collected.length,
|
|
968
|
+
capNotice: `已取前 ${collected.length} 条 / 共 ${total} 条(安全上限 ${CAP})——还差 ${total - collected.length} 条没返回。不要把它当全集:加 filters 分片(如按日期/业务键)分别拉取,或去掉 all 用 page/size 手动翻页。`,
|
|
969
|
+
}
|
|
970
|
+
: {}),
|
|
971
|
+
...(allHint ? { projectionHint: allHint } : {}),
|
|
972
|
+
items,
|
|
973
|
+
};
|
|
919
974
|
}
|
|
920
975
|
let result;
|
|
921
976
|
try {
|
|
@@ -945,6 +1000,13 @@ export function registerRuntimeDataLane(tools) {
|
|
|
945
1000
|
}
|
|
946
1001
|
}
|
|
947
1002
|
}
|
|
1003
|
+
// P1-4:其余 50000 保持原样抛出(真服务异常必须照实暴露),但给一条
|
|
1004
|
+
// 不改判定的排查提示——子表/子表 schema 的过滤与系统字段过滤是这个面
|
|
1005
|
+
// 最常见的"服务异常"来源(live: 子表 schema 加 parentId 过滤 → 50000,
|
|
1006
|
+
// 去掉 filter 就正常)。只加 hint,不猜 blocker。
|
|
1007
|
+
if (error instanceof WireError && error.kind === "business" && error.errcode === 50000) {
|
|
1008
|
+
throw new WireError(error.kind, error.message, error.errcode, error.diagnostic, `若 filter/filters 里含子表字段或子表自身 schema 的 parentId 等系统字段:运行面 query/list 的标量过滤不适用于子表,去掉 filter 后按主表 id 用 data.load 读子表行。其余情况按真服务异常处理(同一 schemaCode 去掉 filter 可读即说明是过滤形状问题)。`);
|
|
1009
|
+
}
|
|
948
1010
|
throw error;
|
|
949
1011
|
}
|
|
950
1012
|
const page = extractBizObjectPage(result.data);
|
|
@@ -1050,8 +1112,8 @@ export function registerRuntimeDataLane(tools) {
|
|
|
1050
1112
|
environment: environmentArg,
|
|
1051
1113
|
schemaCode: z.string(),
|
|
1052
1114
|
bizObjectId: z.string({ required_error: "bizObjectId 必填(参数名就是 bizObjectId——不是 id 也不是 objectId,r44 两个 agent 都猜了 objectId;取值来自 data.submit 读回的 objectId/sequenceNo 或 data.list 的 row.bizObjectId)" }),
|
|
1053
|
-
full: z.boolean().optional().describe("返回未裁剪的完整业务对象(含 owner/creater
|
|
1054
|
-
fields: z.array(z.string().min(1)).optional().describe("
|
|
1115
|
+
full: z.boolean().optional().describe("返回未裁剪的完整业务对象(含 owner/creater 等系统字段;默认返回业务字段+身份的精简记录)。整条大对象(百 KB 级)会被外置成 artifact——只需要几列时改用 fields"),
|
|
1116
|
+
fields: z.array(z.string().min(1)).optional().describe("列投影:只保留列出的字段——核验状态时传 [\"sequenceStatus\",\"<业务状态字段>\"],别整条 data.load(r4-training 2026-10-20:同一对象被读 74 次);缺失字段回在 missingFields。这是本地裁剪、在 artifact 外置**之前**生效,所以只取 9 列不会先拉整包(GitLab #17 T5)。平台 form/load 无列参数,投影只能在这一层做"),
|
|
1055
1117
|
capability: capabilityArg,
|
|
1056
1118
|
},
|
|
1057
1119
|
handler: async (ctx, args, self) => {
|
package/dist/path-projection.js
CHANGED
|
@@ -105,21 +105,23 @@ export function projectJsonPathFanout(value, path) {
|
|
|
105
105
|
if (marker < 0) {
|
|
106
106
|
const projected = projectJsonPath(value, path);
|
|
107
107
|
return projected.missed
|
|
108
|
-
? { values: [], missed: true, ...(projected.at !== undefined ? { at: projected.at } : {}), ...(projected.available !== undefined ? { available: projected.available } : {}), missedEntries: 0 }
|
|
109
|
-
: { values: [projected.value], missed: false, missedEntries: 0 };
|
|
108
|
+
? { values: [], missed: true, ...(projected.at !== undefined ? { at: projected.at } : {}), ...(projected.available !== undefined ? { available: projected.available } : {}), missedEntries: 0, nullEntries: 0 }
|
|
109
|
+
: { values: [projected.value], missed: false, missedEntries: 0, nullEntries: projected.value === null ? 1 : 0 };
|
|
110
110
|
}
|
|
111
111
|
const before = path.slice(0, marker);
|
|
112
112
|
const after = path.slice(marker + 2).replace(/^[./]+/, "");
|
|
113
113
|
const base = projectJsonPath(value, before);
|
|
114
114
|
if (base.missed)
|
|
115
|
-
return { values: [], missed: true, ...(base.at !== undefined ? { at: base.at } : {}), ...(base.available !== undefined ? { available: base.available } : {}), missedEntries: 0 };
|
|
115
|
+
return { values: [], missed: true, ...(base.at !== undefined ? { at: base.at } : {}), ...(base.available !== undefined ? { available: base.available } : {}), missedEntries: 0, nullEntries: 0 };
|
|
116
116
|
if (!Array.isArray(base.value))
|
|
117
|
-
return { values: [], missed: true, at: before || "$", available: `该层不是数组(是 ${base.value === null ? "null" : typeof base.value});[] 只能映射数组,如 items[].fields.planNo`, missedEntries: 0 };
|
|
117
|
+
return { values: [], missed: true, at: before || "$", available: `该层不是数组(是 ${base.value === null ? "null" : typeof base.value});[] 只能映射数组,如 items[].fields.planNo`, missedEntries: 0, nullEntries: 0 };
|
|
118
118
|
const values = [];
|
|
119
119
|
let missedEntries = 0;
|
|
120
|
+
let nullEntries = 0;
|
|
120
121
|
for (const entry of base.value) {
|
|
121
122
|
const projected = projectJsonPathFanout(entry, after);
|
|
122
123
|
values.push(...projected.values);
|
|
124
|
+
nullEntries += projected.nullEntries;
|
|
123
125
|
if (projected.missed)
|
|
124
126
|
missedEntries += 1;
|
|
125
127
|
}
|
|
@@ -127,9 +129,9 @@ export function projectJsonPathFanout(value, path) {
|
|
|
127
129
|
// array) — report the miss so the caller does not read an empty list as data.
|
|
128
130
|
if (values.length === 0 && missedEntries > 0) {
|
|
129
131
|
const first = projectJsonPathFanout(base.value[0], after);
|
|
130
|
-
return { values: [], missed: true, ...(first.at !== undefined ? { at: `${before}[].${first.at}` } : {}), ...(first.available !== undefined ? { available: first.available } : {}), missedEntries };
|
|
132
|
+
return { values: [], missed: true, ...(first.at !== undefined ? { at: `${before}[].${first.at}` } : {}), ...(first.available !== undefined ? { available: first.available } : {}), missedEntries, nullEntries: 0 };
|
|
131
133
|
}
|
|
132
|
-
return { values, missed: false, ...(missedEntries > 0 ? { missedEntries } : { missedEntries: 0 }) };
|
|
134
|
+
return { values, missed: false, ...(missedEntries > 0 ? { missedEntries } : { missedEntries: 0 }), nullEntries };
|
|
133
135
|
}
|
|
134
136
|
/** Collect every value of a repeatable flag (`--extract a --extract b`). */
|
|
135
137
|
function argvValues(args, name) {
|
|
@@ -145,6 +147,13 @@ function argvValues(args, name) {
|
|
|
145
147
|
}
|
|
146
148
|
/** Shared wording for every projection miss, so all three faces teach the same syntax. */
|
|
147
149
|
const PATH_MISS_HINT = "路径未命中(这一层没有该段)。可用写法:点式 a.b、下标 a[0]、切片 a[0:6]、数组映射 items[].fields.x、按键选择 rules[code=Create] 或 data.activities[code=A1](数组与 index→条目对象都支持;键名可省 activityCode/nodeCode 等别名;返回第一个匹配项)。available 是该层真实存在的键。注意:本参数是路径选择器、不是 jq 语言(不支持 | 管道与对象构造);一次取多列用逗号分隔(--jq \"a.b,c.d\")或重复传 --jq。";
|
|
150
|
+
/**
|
|
151
|
+
* T6(GitLab #17):`mapped` 只是"路径命中的条目数",值为 null 也算命中。
|
|
152
|
+
* 全 null + ok:true 曾被读成"取到了 N 个值/台账只有 1 行"(真实 42 行)。
|
|
153
|
+
* 提示里把两种成因分开说:未投影(看 data.list 的 projectionHint / data.load)
|
|
154
|
+
* 与字段本身为空。
|
|
155
|
+
*/
|
|
156
|
+
const ALL_NULL_HINT = `路径全部命中但值都是 null——注意 mapped 只是命中条目数,不代表有值。常见成因是列表投影没配置该列(data.list 只投影视图列,null 或整键省略;看同一次 data.list 的 projectionHint,或用 data.load 读单条权威值)。若 projectionHint 也指向该列,就不是"字段为空"。`;
|
|
148
157
|
/**
|
|
149
158
|
* `call --extract/--extract-keys`: resolve paths inside the result's own
|
|
150
159
|
* artifact (or inside the result object when the tool returned no artifact).
|
|
@@ -186,6 +195,14 @@ export function projectResultExtract(result, paths, keysOnly) {
|
|
|
186
195
|
...(artifactId !== undefined ? { artifactId, sha256: artifact?.sha256 } : {}),
|
|
187
196
|
from: readFromArtifact ? "artifact" : "result",
|
|
188
197
|
};
|
|
198
|
+
// T6:列表投影里「未配置的列」也是 null 键——工具自己已经写了 projectionHint
|
|
199
|
+
// (#15 A6),但投影层此前把它丢了,于是 `--jq items[].fields.x` 全 null 时
|
|
200
|
+
// 调用方看不到任何线索。把源结果的提示原样透传(有才带,零额外体积)。
|
|
201
|
+
const sourceProjectionHint = source && typeof source === "object" && !Array.isArray(source) && typeof source.projectionHint === "string"
|
|
202
|
+
? source.projectionHint
|
|
203
|
+
: undefined;
|
|
204
|
+
if (sourceProjectionHint !== undefined)
|
|
205
|
+
out.projectionHint = sourceProjectionHint;
|
|
189
206
|
if (keysOnly) {
|
|
190
207
|
const record = source && typeof source === "object" && !Array.isArray(source) ? source : undefined;
|
|
191
208
|
out.keys = record
|
|
@@ -203,7 +220,14 @@ export function projectResultExtract(result, paths, keysOnly) {
|
|
|
203
220
|
const fanout = projectJsonPathFanout(source, path);
|
|
204
221
|
return fanout.missed
|
|
205
222
|
? { path, content: null, pathMiss: true, missedAt: fanout.at, available: fanout.available, missedEntries: fanout.missedEntries, hint: PATH_MISS_HINT }
|
|
206
|
-
: {
|
|
223
|
+
: {
|
|
224
|
+
path,
|
|
225
|
+
content: fanout.values,
|
|
226
|
+
mapped: fanout.values.length,
|
|
227
|
+
...(fanout.nullEntries > 0 ? { nullEntries: fanout.nullEntries } : {}),
|
|
228
|
+
...(fanout.nullEntries > 0 && fanout.nullEntries === fanout.values.length ? { hint: ALL_NULL_HINT } : {}),
|
|
229
|
+
...(fanout.missedEntries > 0 ? { missedEntries: fanout.missedEntries, hint: `有 ${fanout.missedEntries} 条记录在该路径下缺失,values 只含命中项` } : {}),
|
|
230
|
+
};
|
|
207
231
|
}
|
|
208
232
|
const projected = projectJsonPath(source, path);
|
|
209
233
|
return projected.missed
|
package/dist/read.js
CHANGED
|
@@ -79,7 +79,7 @@ export async function governedRead(ctx, tool, args, execute, options = {}) {
|
|
|
79
79
|
ctx.readbackCache.set(target, { ...cacheEntry, shapePlaceholders: placeholders });
|
|
80
80
|
return {
|
|
81
81
|
status: "ok",
|
|
82
|
-
...passthroughScalars(result),
|
|
82
|
+
...passthroughScalars(result, omittedScalarKeys(compact)),
|
|
83
83
|
...compact,
|
|
84
84
|
...placeholders,
|
|
85
85
|
full: true,
|
|
@@ -98,13 +98,13 @@ export async function governedRead(ctx, tool, args, execute, options = {}) {
|
|
|
98
98
|
const compact = options.compact ? options.compact(result, session) : compactReadback(result);
|
|
99
99
|
const placeholders = externalizedShapePlaceholders(result, droppedShapeKeys(result, compact));
|
|
100
100
|
ctx.readbackCache.set(target, { ...cacheEntry, shapePlaceholders: placeholders });
|
|
101
|
-
return { status: "ok", ...passthroughScalars(result), ...compact, ...placeholders, environmentFingerprint, detailFile: artifactRef.detailFile, detailSha256: artifactRef.detailSha256 };
|
|
101
|
+
return { status: "ok", ...passthroughScalars(result, omittedScalarKeys(compact)), ...compact, ...placeholders, environmentFingerprint, detailFile: artifactRef.detailFile, detailSha256: artifactRef.detailSha256 };
|
|
102
102
|
}
|
|
103
103
|
catch (error) {
|
|
104
104
|
if (isUncertainWireError(error))
|
|
105
105
|
return { status: "uncertain", frozen: true, reason: `wire-transport:${wireErrorKind(error)}`, detail: error.message };
|
|
106
106
|
if (error instanceof WireError)
|
|
107
|
-
return { status: "refused", blocker: `wire-${error.kind}`, errcode: error.errcode, message: error.message, ...(error.diagnostic ? { diagnostic: error.diagnostic } : {}) };
|
|
107
|
+
return { status: "refused", blocker: `wire-${error.kind}`, errcode: error.errcode, message: error.message, ...(error.diagnostic ? { diagnostic: error.diagnostic } : {}), ...(error.hint ? { hint: error.hint } : {}) };
|
|
108
108
|
throw error;
|
|
109
109
|
}
|
|
110
110
|
}
|
|
@@ -208,10 +208,10 @@ const BULKY_READ_KEYS = new Set(["data", "nodes", "routes", "items", "rules", "a
|
|
|
208
208
|
* `result.workflowCode` got `undefined` (round 10: live demo). Pass the scalar
|
|
209
209
|
* (non-bulky) top-level keys through; the compact's own keys still win.
|
|
210
210
|
*/
|
|
211
|
-
function passthroughScalars(result) {
|
|
211
|
+
function passthroughScalars(result, omit = []) {
|
|
212
212
|
const carried = {};
|
|
213
213
|
for (const [key, value] of Object.entries(result)) {
|
|
214
|
-
if (key === "status" || BULKY_READ_KEYS.has(key))
|
|
214
|
+
if (key === "status" || BULKY_READ_KEYS.has(key) || omit.includes(key))
|
|
215
215
|
continue;
|
|
216
216
|
const type = typeof value;
|
|
217
217
|
if (value === null || type === "string" || type === "number" || type === "boolean")
|
|
@@ -219,6 +219,32 @@ function passthroughScalars(result) {
|
|
|
219
219
|
}
|
|
220
220
|
return carried;
|
|
221
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* T1(GitLab #17):某个工具的自定义 compact 用 `<key>Omitted` 显式声明"这段正文
|
|
224
|
+
* 已外置"(form.get 的六份布局 JSON 是大**字符串**,不是 BULKY_READ_KEYS 里的
|
|
225
|
+
* 数组/对象)。passthroughScalars 会把所有字符串原样带回来,正好把外置又抵消掉
|
|
226
|
+
* (42KB 读回里 41KB 是刚声明省略的布局)。这里把省略声明翻译成跳过名单。
|
|
227
|
+
*
|
|
228
|
+
* 白名单化(审查 2026-11-04):只认 form 的六个布局面。否则任何 compact 恰好
|
|
229
|
+
* 产出的 `<key>Omitted:true`(如 `fullOmitted` → 派生 omit=`full`)都会误删同名
|
|
230
|
+
* 标量,靠后续展开掩盖太脆。
|
|
231
|
+
*/
|
|
232
|
+
const EXTERNALIZED_SCALAR_KEYS = new Set([
|
|
233
|
+
"draftViewJson",
|
|
234
|
+
"draftAttributesJson",
|
|
235
|
+
"draftHtmlJson",
|
|
236
|
+
"draftActionsJson",
|
|
237
|
+
"publishedViewJson",
|
|
238
|
+
"publishedAttributesJson",
|
|
239
|
+
"publishedHtmlJson",
|
|
240
|
+
"publishedActionsJson",
|
|
241
|
+
]);
|
|
242
|
+
function omittedScalarKeys(compact) {
|
|
243
|
+
return Object.keys(compact)
|
|
244
|
+
.filter((key) => key.endsWith("Omitted") && compact[key] === true)
|
|
245
|
+
.map((key) => key.slice(0, -"Omitted".length))
|
|
246
|
+
.filter((key) => EXTERNALIZED_SCALAR_KEYS.has(key));
|
|
247
|
+
}
|
|
222
248
|
function safeFingerprint(session) {
|
|
223
249
|
try {
|
|
224
250
|
return session.environmentFingerprint();
|