@stalfh233/omc-cli 0.6.3 → 0.6.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.
@@ -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
@@ -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
  }),
@@ -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
- const [fieldFace, formFace, listviewFace, ruleFace, dataruleFace, draftFace] = await mapBounded([
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
- ], 6, (task) => task());
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,摘要)。modelPresent 是存在性判据(field 面 300004=false);字段 options 细节用 field.list --code;规则图用 rule.get schemaCode+ruleCode;运行数据用 data.list(不在此面)。",
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 is one of eq, neq, gt, lt, gte, lte, like, notlike, in, notin, between, notbetween, isnull, isnotnull(camelCase 别名 equally valid: equals/notEquals/contains/isNull/isNotNull)'),
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 行,超出截断并标记 capped)——存量盘点/数据核对用,替代手动循环翻页"),
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);多条件用 filters:[clause,…](AND)或 filterGroup:{op:"and"|"or",clauses:[…]}。可运行示例:describe data.list --example list-filter-equality`,
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
- const capped = rows.length > CAP;
896
- const items = (capped ? rows.slice(0, CAP) : rows).map((row0) => {
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 { schemaCode: args.schemaCode, total, page: "all", size: rows.length, pagesFetched: pageIndex, ...(capped ? { capped: true, capNotice: `结果超过安全上限 ${CAP},已截断;请加 filters 分批` } : {}), ...(allHint ? { projectionHint: allHint } : {}), items };
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("只保留列出的字段——核验状态时传 [\"sequenceStatus\",\"<业务状态字段>\"],别整条 data.load(r4-training 2026-10-20:同一对象被读 74 次);缺失字段回在 missingFields"),
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) => {
@@ -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
- : { path, content: fanout.values, mapped: fanout.values.length, ...(fanout.missedEntries > 0 ? { missedEntries: fanout.missedEntries, hint: `有 ${fanout.missedEntries} 条记录在该路径下缺失,values 只含命中项` } : {}) };
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();