@stalfh233/omc-cli 0.6.15 → 0.6.17

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,7 +19,57 @@
19
19
  * per-tool preflight apply exactly as they do for a single call.
20
20
  */
21
21
  import { refusalText } from "./cli-contract.js";
22
+ import { createArtifact } from "./artifacts.js";
23
+ import { artifactFetchHint, artifactRoot } from "./command-transport.js";
22
24
  import { runScript } from "./script.js";
25
+ function inputBusinessKey(args) {
26
+ const keys = ["code", "name", "bizObjectId", "id"];
27
+ const out = {};
28
+ for (const key of keys)
29
+ if (args[key] !== undefined)
30
+ out[key] = args[key];
31
+ return out;
32
+ }
33
+ function failureDetails(entry, args) {
34
+ const message = refusalText(entry.result);
35
+ const formData = args.formData && typeof args.formData === "object" && !Array.isArray(args.formData)
36
+ ? args.formData
37
+ : args;
38
+ const numericName = Object.keys(formData).find((key) => /amount|qty|quantity|price|number|count/i.test(key));
39
+ const numericValue = numericName === undefined ? undefined : formData[numericName];
40
+ if (numericName !== undefined && typeof numericValue === "string" && /50000|服务异常|wire-business/i.test(message)) {
41
+ return {
42
+ fieldCode: numericName,
43
+ value: numericValue,
44
+ reason: `NUMERICAL 字段收到字符串 "${numericValue}"`,
45
+ };
46
+ }
47
+ return { hint: "平台回了无明细 50000,常见成因见 help omc-refusals;请用该行输入与已发布字段逐项核对" };
48
+ }
49
+ export function batchWarnings(argsList) {
50
+ const warnings = [];
51
+ const seenCodes = new Map();
52
+ argsList.forEach((args, index) => {
53
+ const row = index + 1;
54
+ const data = args.formData && typeof args.formData === "object" && !Array.isArray(args.formData) ? args.formData : args;
55
+ if (data.code === undefined || data.code === null || data.code === "")
56
+ warnings.push({ row, fieldCode: "code", code: "required", message: "必填字段 code 缺失" });
57
+ else if (typeof data.code === "string") {
58
+ const previous = seenCodes.get(data.code);
59
+ if (previous !== undefined)
60
+ warnings.push({ row, fieldCode: "code", code: "duplicate", message: `code 与第 ${previous} 行重复` });
61
+ else
62
+ seenCodes.set(data.code, row);
63
+ }
64
+ for (const [fieldCode, value] of Object.entries(data)) {
65
+ if (typeof value === "string" && value.length > 255)
66
+ warnings.push({ row, fieldCode, code: "maxLength", message: `${fieldCode} 长度 ${value.length} 超过常见上限 255` });
67
+ if (typeof value === "number" && !Number.isInteger(value) && /qty|quantity|count|level/i.test(fieldCode))
68
+ warnings.push({ row, fieldCode, code: "scale-exceeded", message: `${fieldCode} 含小数,但字段可能是 0 位数值字段` });
69
+ }
70
+ });
71
+ return warnings;
72
+ }
23
73
  /** Cap mirrors `omc script`: a runaway list should be a refusal, not a 20-min hang. */
24
74
  export const BATCH_MAX_ENTRIES = 200;
25
75
  /**
@@ -69,9 +119,12 @@ export async function runBatchCall(options) {
69
119
  .filter((entry) => entry.status !== "ok")
70
120
  .map((entry) => ({
71
121
  index: entry.index,
122
+ row: entry.index + 1,
123
+ indexBase: 0,
72
124
  status: String(entry.result.status ?? "failed"),
73
125
  blocker: entry.result.blocker ?? null,
74
126
  message: refusalText(entry.result),
127
+ ...failureDetails(entry, options.argsList[entry.index] ?? {}),
75
128
  ...(Array.isArray(entry.result.issues) ? { issues: entry.result.issues } : {}),
76
129
  }));
77
130
  const executed = entries.length;
@@ -92,13 +145,22 @@ export async function runBatchCall(options) {
92
145
  const skippedMarker = performed?.skipped;
93
146
  return {
94
147
  index: entry.index,
148
+ row: entry.index + 1,
149
+ indexBase: 0,
95
150
  ok: entry.status === "ok",
151
+ ...inputBusinessKey(options.argsList[entry.index] ?? {}),
96
152
  ...(bizObjectId !== undefined ? { bizObjectId } : {}),
97
153
  ...(entry.status === "ok" ? {} : { status: String(entry.result.status ?? "failed"), blocker: entry.result.blocker ?? null }),
98
154
  ...(skippedMarker !== undefined ? { skipped: String(skippedMarker) } : {}),
99
155
  };
100
156
  });
101
157
  const entriesInline = entriesSummary.slice(0, ENTRIES_INLINE_LIMIT);
158
+ const warnings = batchWarnings(options.argsList);
159
+ const warningsInline = warnings.slice(0, ENTRIES_INLINE_LIMIT);
160
+ const detailOmitted = entriesSummary.length > entriesInline.length || warnings.length > warningsInline.length;
161
+ const detailArtifact = detailOmitted
162
+ ? createArtifact(artifactRoot(), JSON.stringify({ entries: entriesSummary, warnings }, null, 2) + "\n")
163
+ : undefined;
102
164
  return {
103
165
  exitCode: failed > 0 ? 1 : 0,
104
166
  result: {
@@ -112,9 +174,17 @@ export async function runBatchCall(options) {
112
174
  // 每条一行摘要(index/ok/bizObjectId)——不是完整信封,但足以回答
113
175
  // 「第 3 条建成了吗、它的 id 是什么」。失败项另有 failures 带完整原因。
114
176
  entries: entriesInline,
115
- ...(entries.length > entriesInline.length
116
- ? { entriesOmitted: entries.length - entriesInline.length, entriesHint: `条目超过 ${ENTRIES_INLINE_LIMIT} 条,只内联前 ${ENTRIES_INLINE_LIMIT} 条摘要;完整结果走 detailArtifactId / omc artifact get` }
117
- : {}),
177
+ ...(warnings.length > 0 ? {
178
+ warnings: warningsInline,
179
+ ...(warnings.length > warningsInline.length ? { warningsOmitted: warnings.length - warningsInline.length } : {}),
180
+ } : {}),
181
+ ...(entriesSummary.length > entriesInline.length ? { entriesOmitted: entriesSummary.length - entriesInline.length } : {}),
182
+ ...(detailArtifact !== undefined ? {
183
+ detailArtifactId: detailArtifact.artifactId,
184
+ sha256: detailArtifact.sha256,
185
+ fetch: artifactFetchHint(detailArtifact.artifactId),
186
+ entriesHint: `条目或预检警告超过 ${ENTRIES_INLINE_LIMIT} 条,只内联前 ${ENTRIES_INLINE_LIMIT} 条;完整结果见 detailArtifactId`,
187
+ } : {}),
118
188
  // Only failures carry full per-entry detail: echoing 8 full success
119
189
  // envelopes would rebuild exactly the wall of text this command exists
120
190
  // to remove.
@@ -123,7 +123,7 @@ const SURFACES = [
123
123
  },
124
124
  {
125
125
  id: "workflow",
126
- title: "流程模板",
126
+ title: "流程模板 + 组织与权限",
127
127
  lane: LANES.workflow,
128
128
  description: "流程模板生命周期:get_draft/get_published、create、update、publish、bindRule、delete、workitem.list。**含组织/部门/角色/权限范围(org.* 工具)**:部门树、角色组/角色、用户搜索、成员与管理范围(userScope)——流程参与人/可见性的候选来源。",
129
129
  operations: ["create", "update", "publish", "bindRule", "delete"],
@@ -193,7 +193,7 @@ const CAPABILITY_ALIASES = Object.freeze({
193
193
  businessrule: ["business-rules", "B1-businessrule"],
194
194
  "rule-lifecycle": ["rule-lifecycles", "S25-rule-lifecycle"],
195
195
  // r28h F1/F7: org.* lives under workflow; aliases let "我要管部门/组织" hit it.
196
- workflow: ["workflows", "W7-S29-greenfield", "org", "organization", "organisation", "组织", "组织架构"],
196
+ workflow: ["workflows", "W7-S29-greenfield", "org", "organization", "permission", "role", "权限", "角色", "组织"],
197
197
  onlinejs: ["online-js"],
198
198
  programming: ["backend-online-coding", "programming-online-dev", "在线开发", "在线编码"],
199
199
  bizservice: ["bizservices", "B2-registration"],
@@ -252,12 +252,12 @@ export const CAPABILITY_CUES = Object.freeze({
252
252
  programming: ["在线开发", "在线编码", "后端源码", "java源码", "编码模式", "handler", "programming", "源码编译"],
253
253
  bizservice: ["集成", "接口", "服务", "回调", "对接", "外部系统", "datasql", "restful"],
254
254
  report: ["报表", "图表", "看板", "bi", "dashboard"],
255
+ data: ["运行数据", "提交数据", "保存记录", "查记录", "造数据", "测试数据", "测试单据", "造单据", "造单", "录数据", "填数据", "模拟数据", "业务数据", "造几条", "造两张", "造几张", "验数据", "批量导入", "批量写入", "批量删除", "导入数据"],
255
256
  // r18 (2026-10-26): "造数据 / 造测试单据 / 录数据" is the RUN-DATA capability, but
256
257
  // the goal usually also says 子表/明细 (field cues) — without these compound
257
258
  // cues the router sent the probe to `field` in read mode (write:false), so it
258
259
  // never reached data.submit. Compound (not bare 数据) to avoid stealing
259
260
  // ordinary model/field asks that merely mention data.
260
- data: ["运行数据", "提交数据", "保存记录", "查记录", "造数据", "测试数据", "测试单据", "造单据", "造单", "录数据", "填数据", "模拟数据", "业务数据", "造几条", "造两张", "造几张", "验数据"],
261
261
  });
262
262
  /**
263
263
  * Boundary cues — authored alongside CAPABILITY_CUES (same runtime authority).
package/dist/cli.js CHANGED
@@ -463,9 +463,10 @@ async function cmdBatchCall(store, toolName, rawList, options) {
463
463
  failures.push({ index, blocker: payload.blocker ?? null, message: refusalText(payload), ...(Array.isArray(payload.validation) ? { validation: payload.validation } : {}), ...(payload.citation !== undefined ? { citation: payload.citation } : {}) });
464
464
  }
465
465
  });
466
- const result = {
466
+ let result = {
467
467
  status: failures.length > 0 ? "partial" : "ok",
468
468
  mode: "preflight",
469
+ shapeOnly: true,
469
470
  tool: toolName,
470
471
  requested: entries.length,
471
472
  shapeChecked: entries.length,
@@ -473,8 +474,16 @@ async function cmdBatchCall(store, toolName, rawList, options) {
473
474
  writes: 0,
474
475
  // On failure the caller has to change the JSON first, so there is no
475
476
  // runnable command yet; on success, hand over the exact execution command.
476
- ...(failures.length > 0 ? { failures } : { nextCommand: `omc call ${toolName} --args-list ${JSON.stringify(JSON.stringify(entries))}` }),
477
+ ...(failures.length > 0 ? { failures } : { nextCommand: `omc call ${toolName} --args-list '<file|stdin>'` }),
477
478
  };
479
+ const preflightPaths = [...argvValues(options.args, "--extract"), ...argvValues(options.args, "--jq")]
480
+ .flatMap((path) => path.split(",")).map((path) => path.trim()).filter(Boolean);
481
+ const preflightKeys = options.args.includes("--extract-keys") || options.args.includes("--keys") || options.args.some((token, index) => token === "--jq" && (options.args[index + 1] === undefined || options.args[index + 1].startsWith("--")));
482
+ if (preflightPaths.length > 0 || preflightKeys) {
483
+ const projected = projectResultExtract(result, preflightPaths, preflightKeys);
484
+ if (projected !== undefined)
485
+ result = projected;
486
+ }
478
487
  const classified = classifyResult(result);
479
488
  writeEnvelope(envelope(classified.status, result, request, classified.error));
480
489
  return failures.length > 0 ? 1 : 0;
@@ -525,7 +534,7 @@ async function cmdCall(store, args) {
525
534
  return emitTransportResult({ status: "ok", ...described, helpNote: "来自 omc describe;完整面用 omc describe <tool> [--full|--param|--example]" }, { request, maxOutputBytes });
526
535
  }
527
536
  }
528
- console.error("用法:omc call <tool> [--env <环境>] [--args '<json>' | --args - (stdin) | --args-file <配置根内路径>] [--extract <path>]... [--jq [path]] [--extract-keys] [--args-list <json数组> [--preflight]]");
537
+ console.error("用法:omc call <tool> [--env <环境>] [--args '<json>' | --args - (stdin) | --args-file <配置根内路径(必须位于配置根内;跨目录临时文件用 --args -(stdin))>] [--extract <path>]... [--jq [path]] [--extract-keys] [--args-list <json数组> [--preflight]]");
529
538
  console.error("输出预算:--max-output-bytes 默认 65536(只约束最终 stdout 信封;读面 diet ~2048 独立外置 detailArtifactId);artifact get 内联上限 8192");
530
539
  console.error("写入:由环境 writable 决定(只读环境拒写);生产环境的写必须在 args 里带 confirmProduction:true。");
531
540
  return emit(toolName && toolName !== "help" ? "error" : "ok", {}, toolName && toolName !== "help" ? CLI_EXIT_CODES.invalidInput : 0);
@@ -1632,17 +1641,22 @@ async function cmdGraphPull(environment, concurrency, transport) {
1632
1641
  return emitThrown(error, transport);
1633
1642
  }
1634
1643
  }
1635
- function cmdGraphReferences(kind, code, transport) {
1644
+ function cmdGraphReferences(kind, code, transport, requestedEnvironment) {
1636
1645
  if (!["service", "model", "field"].includes(kind))
1637
1646
  return emitInvalid(transport, { message: "kind 必须是 service / model / field(同 rule.references 第一版边界)" });
1638
1647
  try {
1639
1648
  const result = queryGraphReferences(kind, code);
1649
+ if (requestedEnvironment !== undefined && requestedEnvironment !== result.index.environmentName)
1650
+ return emitResult({ status: "refused", blocker: "graph-environment-mismatch", message: `索引已绑定环境 ${result.index.environmentName},不是 ${requestedEnvironment};请先 omc graph pull --env ${requestedEnvironment}`, index: result.index }, transport);
1640
1651
  return emitResult({
1641
1652
  status: "ok",
1642
1653
  kind: result.kind,
1643
1654
  code: result.code,
1644
1655
  matches: result.matches,
1645
1656
  index: result.index,
1657
+ // r35a-F4:覆盖声明必须透传——CLI 这里是白名单式重建,漏加就等于没修
1658
+ // (matches:[] 曾被当成可安全删除的依据)。
1659
+ ...(result.coverage !== undefined ? { coverage: result.coverage } : {}),
1646
1660
  ...(result.note !== undefined ? { note: result.note } : {}),
1647
1661
  ...(result.gapWarning !== undefined ? { gapWarning: result.gapWarning } : {}),
1648
1662
  }, transport);
@@ -1656,7 +1670,10 @@ function cmdGraphReferences(kind, code, transport) {
1656
1670
  function cmdGraphStatus(transport) {
1657
1671
  try {
1658
1672
  const status = readGraphStatus();
1659
- return emitResult({ status: "ok", index: status.index, dbBytes: status.dbBytes, gaps: status.gaps }, transport);
1673
+ // r31a-F16:这里是**第四个**白名单式重建(前三次:compact() 投影、references
1674
+ // 覆盖声明、mcp-graph 的 explore)。白名单每漏一个键就等于没修,且不会被任何
1675
+ // 单测发现——所以改成展开,新增字段自动透传。
1676
+ return emitResult({ status: "ok", ...status }, transport);
1660
1677
  }
1661
1678
  catch (error) {
1662
1679
  if (error instanceof GraphIndexAbsentError)
@@ -2145,7 +2162,7 @@ async function main() {
2145
2162
  commandExitCode = CLI_EXIT_CODES.ok;
2146
2163
  });
2147
2164
  command.command("doctor").option("--environment <environment>").option("--env <environment>").option("--service <serviceCode>", "可选:体检单个业务集成服务的鉴权参数形状(掩码/占位/全 0 检出,WARN 非致命)").action(async (options) => { commandExitCode = await cmdDoctor(new ConfigStore(defaultPaths()), options.environment ?? options.env, options.service); });
2148
- command.command("call <tool>").option("--env <environment>").option("--environment <environment>").option("--args <json>").option("--args-file <path>").option("--args-list <json-array>", '同一工具跑 N 次(JSON 数组,每条一个 args 对象);逐条执行、默认不中断,最后回 succeeded/failed 汇总。适合批量建字段等').option("--preflight", "与 --args-list 一起用:只跑参数校验 + 写前语义闸(零 wire、零写入),一次报全所有形状问题").option("--keys", "与 artifact get 的 --keys 同义(= --extract-keys):列出本次结果 artifact 的顶层键与体积").addOption(new Option("--extract <path>", "在本次结果的 artifact 内取一个路径的值(可重复;省掉一次 artifact get)").argParser((value, previous = []) => [...previous, value]).default([])).addOption(new Option("--jq [path]", "同 --extract,并支持 [] 映射(如 items[].fields.planNo);裸 --jq 列出结果的顶层键").argParser((value, previous = []) => [...previous, value]).default([])).option("--extract-keys", "列出本次结果 artifact 的顶层键与体积(省掉一次 artifact get --keys)").option("--max-output-bytes <bytes>", "最终 stdout 信封预算;默认 65536(投影/--jq 之后再判);读面 diet(~2048) 独立外置 detailArtifactId,抬高本参数不会把已 diet 的大表重新内联;artifact get 内联上限 8192").option("--request-id <requestId>").option("--timeout-ms <milliseconds>").option("--wait <milliseconds>").option("--dry-run").addHelpText("after", `\n${onboardingHelp()}\n`).action(async (tool, options) => {
2165
+ command.command("call <tool>").option("--env <environment>").option("--environment <environment>").option("--args <json>").option("--args-file <path>", "从文件读参数 JSON。**必须位于配置根内**(跨目录/系统临时文件会被拒);免落盘用 `--args -`(stdin)").option("--args-list <json-array>", '同一工具跑 N 次(JSON 数组,每条一个 args 对象);逐条执行、默认不中断,最后回 succeeded/failed 汇总。适合批量建字段等').option("--preflight", "与 --args-list 一起用:只跑参数校验 + 写前语义闸(零 wire、零写入),一次报全所有形状问题").option("--keys", "与 artifact get 的 --keys 同义(= --extract-keys):列出本次结果 artifact 的顶层键与体积").addOption(new Option("--extract <path>", "在本次结果的 artifact 内取一个路径的值(可重复;省掉一次 artifact get)").argParser((value, previous = []) => [...previous, value]).default([])).addOption(new Option("--jq [path]", "同 --extract,并支持 [] 映射(如 items[].fields.planNo);裸 --jq 列出结果的顶层键").argParser((value, previous = []) => [...previous, value]).default([])).option("--extract-keys", "列出本次结果 artifact 的顶层键与体积(省掉一次 artifact get --keys)").option("--max-output-bytes <bytes>", "最终 stdout 信封预算;默认 65536(投影/--jq 之后再判);读面 diet(~2048) 独立外置 detailArtifactId,抬高本参数不会把已 diet 的大表重新内联;artifact get 内联上限 8192").option("--request-id <requestId>").option("--timeout-ms <milliseconds>").option("--wait <milliseconds>").option("--dry-run").addHelpText("after", `\n${onboardingHelp()}\n`).action(async (tool, options) => {
2149
2166
  const rest = [tool, ...(options.env ? ["--env", options.env] : []), ...(options.environment ? ["--environment", options.environment] : []), ...(options.args !== undefined ? ["--args", options.args] : []), ...(options.argsFile !== undefined ? ["--args-file", options.argsFile] : []), ...(options.argsList !== undefined ? ["--args-list", options.argsList] : []), ...(options.preflight ? ["--preflight"] : []), ...(Array.isArray(options.extract) ? options.extract.flatMap((path) => ["--extract", path]) : []), ...(Array.isArray(options.jq) ? options.jq.flatMap((path) => path === true ? ["--jq"] : ["--jq", path]) : []), ...(options.extractKeys ? ["--extract-keys"] : []),
2150
2167
  ...(options.keys ? ["--keys"] : []), ...(options.maxOutputBytes !== undefined ? ["--max-output-bytes", options.maxOutputBytes] : []), ...(options.requestId ? ["--request-id", options.requestId] : []), ...(options.timeoutMs !== undefined ? ["--timeout-ms", options.timeoutMs] : []), ...(options.wait !== undefined ? ["--wait", options.wait] : []), ...(options.dryRun ? ["--dry-run"] : [])];
2151
2168
  commandExitCode = await cmdCall(new ConfigStore(defaultPaths()), rest);
@@ -2513,8 +2530,8 @@ async function main() {
2513
2530
  const parsed = options.concurrency === undefined ? undefined : Number.parseInt(String(options.concurrency), 10);
2514
2531
  commandExitCode = await withTransport(options, request, (transport) => cmdGraphPull(options.environment ?? options.env, parsed, transport));
2515
2532
  });
2516
- graphCommand.command("references <kind> <code>").option("--max-output-bytes <bytes>").addHelpText("after", "\n离线查询(零 wire):kind = service|model|field,语义同 rule.references 第一版边界;结果带索引新鲜度与扫描缺口(有缺口时零命中不构成「无引用」证明)。\n").action(async (kind, code, options) => {
2517
- commandExitCode = await withTransport(options, request, (transport) => cmdGraphReferences(kind, code, transport));
2533
+ graphCommand.command("references <kind> <code>").option("--env <environment>", "本子命令不接触环境;该参数仅用于提示索引绑定环境").option("--max-output-bytes <bytes>").addHelpText("after", "\n离线查询(零 wire):kind = service|model|field,语义同 rule.references 第一版边界;结果带索引新鲜度与扫描缺口(有缺口时零命中不构成「无引用」证明)。覆盖:规则图/数据规则/视图;不含表单、workflow.permissions、流程绑定、在线 JS。在线版 rule.references 不含数据规则;两者都不含上述未扫描面。\n").action(async (kind, code, options) => {
2534
+ commandExitCode = await withTransport(options, request, (transport) => cmdGraphReferences(kind, code, transport, options.environment ?? options.env));
2518
2535
  });
2519
2536
  graphCommand.command("impact <kind> <code>").option("--max-output-bytes <bytes>").addHelpText("after", "\n影响面(离线):跳1=引用该编码的规则(同 references),跳2=这些规则还读写哪些 service/model/field——改前看爆炸半径。截断上限 50 规则×每类 20 码。\n").action(async (kind, code, options) => {
2520
2537
  commandExitCode = await withTransport(options, request, (transport) => cmdGraphImpact(kind, code, transport));
package/dist/compute.js CHANGED
@@ -189,7 +189,7 @@ export async function cmdCompute(args) {
189
189
  let result;
190
190
  let exitCode;
191
191
  try {
192
- ({ result, exitCode } = await runToolCall("datarule.create", { args: createArgs, requestId: request, environment }));
192
+ ({ result, exitCode } = await runToolCall("datarule.create", { args: createArgs, requestId: request, environment, print: () => undefined }));
193
193
  }
194
194
  catch (error) {
195
195
  const message = error instanceof Error ? error.message : String(error);
@@ -64,7 +64,7 @@ export function relationDisplayFieldCode(type, options) {
64
64
  const displayField = options.displayField;
65
65
  return typeof displayField === "string" && displayField.trim() ? displayField.trim() : undefined;
66
66
  }
67
- export const choicesArg = z.array(z.string()).optional().describe('allowed values for text-choice families (RADIO / CHECKBOX / DROPDOWN_BOX / DROPDOWN_MULTI_BOX), e.g. ["正常","异常","待复检"]。注意:SELECTION(5) 是人员/部门混合选择器,不接受 choices,文本单选请用 DROPDOWN_BOX/RADIO');
67
+ export const choicesArg = z.array(z.string()).optional().describe('allowed values for text-choice families, e.g. ["正常","异常","待复检"]。注意:SELECTION(5) 是人员/部门混合选择器,不接受 choices;文本单选族随版本不同,拒绝信息会列当前 live 集');
68
68
  function jsonString() {
69
69
  return z.string().refine((value) => {
70
70
  if (value === "")
@@ -830,6 +830,43 @@ export function assertFieldFamilyAllowed(args) {
830
830
  // works if the preflight gate is the same gate the write uses.
831
831
  assertNumericalScale(normalized, options);
832
832
  }
833
+ /**
834
+ * Reject the platform's accept-then-drop org-selector+choices shapes before write.
835
+ *
836
+ * 三个组织选择器族同语义(SELECTION 混合 / STAFF_SELECTOR 人员 / DEPARTMENT_SELECTOR
837
+ * 部门):choices 都会被平台静默丢弃,字段建成后写任何 choices 值都 50000 无明细。
838
+ * r37(2026-11-07)之前 SELECTION 走的是 execute 路径的 capability-out-of-coverage
839
+ * (citation.reason 才说不接受 choices),STAFF/DEPARTMENT 走 live 集拒绝——同一语义
840
+ * 三个族两种 blocker。统一收敛到这里:同语义 = 同 blocker(含 options.choices 形态,
841
+ * 那是 F14 真机撞出来的:字段落库、field.list 还回显 choices、运行面才炸)。
842
+ */
843
+ const ORG_SELECTOR_TYPES = new Set([
844
+ FIELD_PROPERTY_TYPES.SELECTION,
845
+ FIELD_PROPERTY_TYPES.STAFF_SELECTOR,
846
+ FIELD_PROPERTY_TYPES.DEPARTMENT_SELECTOR,
847
+ ]);
848
+ export function assertSelectionChoicesAllowed(args) {
849
+ const options = args.options;
850
+ const hasTopLevelChoices = args.choices !== undefined;
851
+ const hasOptionsChoices = options !== null && typeof options === "object" && !Array.isArray(options)
852
+ && Object.prototype.hasOwnProperty.call(options, "choices");
853
+ if (!hasTopLevelChoices && !hasOptionsChoices)
854
+ return;
855
+ const raw = args.propertyType;
856
+ const type = typeof raw === "number" && Number.isInteger(raw)
857
+ ? raw
858
+ : typeof raw === "string" && raw in FIELD_PROPERTY_TYPES
859
+ ? FIELD_PROPERTY_TYPES[raw]
860
+ : undefined;
861
+ if (type === undefined || !ORG_SELECTOR_TYPES.has(type))
862
+ return;
863
+ const family = typeof raw === "string" ? raw : String(type);
864
+ throw new ToolRefusalError({
865
+ status: "refused",
866
+ blocker: "field-choices-family-mismatch",
867
+ message: `${family} 是组织选择器(人员/部门),choices 会被平台静默丢弃;字段虽会建成,但写入任何 choices 值都会返回 50000 且无明细。选人/部门请使用不带 choices 的本族字段(值走引用);文本单选族随版本不同,请先看 omc describe field.create --param propertyType,拒绝信息会列当前 live 集。`,
868
+ });
869
+ }
833
870
  function refuseFieldOptions(reason, message) {
834
871
  const citation = {
835
872
  code: "capability-out-of-coverage",
@@ -672,6 +672,29 @@ export function goalRoute(registry, goal) {
672
672
  firstAnswer: { state: "blocked", why: boundary.note },
673
673
  };
674
674
  }
675
+ if (/批量导入|批量写入|批量删除|导入数据/.test(goal)) {
676
+ const action = /批量删除/.test(goal) ? "data.delete" : "data.save";
677
+ return {
678
+ status: "ok", goal, confident: true, mode: "modify", write: true,
679
+ match: { kind: "capability", ref: "data", title: "运行数据批量写入(--args-list)" },
680
+ route: { capability: "data", lane: "runtime-data", note: "运行数据批量操作逐条执行,失败继续;先用 --preflight 做零写入形状校验", readiness: DISCOVERY_AUTHORITY.readiness, firstCommands: [`omc call ${action} --args-list '<file|stdin>' --preflight`, `omc call ${action} --args-list '<file|stdin>'`], write: true },
681
+ firstAnswer: { state: "needs-input", question: "目标模型 schemaCode 和输入文件/JSON 数组是什么?示例:omc call data.save --args-list '<file|stdin>' --preflight", action: LIST_MODELS_ACTION, pendingCommand: `omc call ${action} --args-list '<file|stdin>'`, why: "批量导入/写入/删除使用 --args-list;先预检形状,再逐条执行" },
682
+ };
683
+ }
684
+ // Cross-application model moves/copies are not an intent migration. There
685
+ // is no model.move/model.copy path across app boundaries: model.move only
686
+ // reparents a model within its current application. Keep the useful
687
+ // declarative route for rebuilding the target model, but put this fact in
688
+ // the route itself so callers cannot mistake intent for an in-place move.
689
+ if (/模型/.test(goal) && /(跨应用|一个应用.*另一个应用|从一个应用.*到另一个应用)/.test(goal) && /(移动|复制|迁移|搬)/.test(goal)) {
690
+ const routed = wholePackageRoute(registry, goal, index, ["app", "model"]);
691
+ const clarification = "事实澄清:没有跨应用迁移/复制模型的专用工具;model.move 仅支持同一应用内换目录。跨应用移植需要在新应用重建模型,并手工搬运数据。";
692
+ return {
693
+ ...routed,
694
+ route: { ...routed.route, note: clarification },
695
+ firstAnswer: { ...routed.firstAnswer, why: `${clarification} ${routed.firstAnswer.why}` },
696
+ };
697
+ }
675
698
  // Composer before the capability solver: a recurring write SHAPE has a single
676
699
  // command that does the job with preflight + idempotency, which beats a ladder
677
700
  // of atomic tools even when the ladder also matches (r13: 26 field.create).
package/dist/graph.js CHANGED
@@ -46,10 +46,18 @@ function nodeSqlite() {
46
46
  * staleness is handled by reporting pulledAt + identity, refreshed by an
47
47
  * explicit re-pull (no incremental watermarks, no write-back hooks).
48
48
  */
49
- const GRAPH_SCHEMA_VERSION = "4";
49
+ const GRAPH_SCHEMA_VERSION = "5";
50
50
  const STALE_LOCK_MS = 15 * 60 * 1000;
51
51
  export const DEFAULT_PULL_CONCURRENCY = 16;
52
52
  export const MAX_PULL_CONCURRENCY = 32;
53
+ /** 索引超过这个天数就提示刷新(配置类查询,1 天足够新)。 */
54
+ export const STALE_INDEX_DAYS = 1;
55
+ export function indexAgeDays(pulledAtIso, now = Date.now()) {
56
+ const pulled = Date.parse(pulledAtIso);
57
+ if (Number.isNaN(pulled))
58
+ return 0;
59
+ return Math.max(0, Math.floor((now - pulled) / 86_400_000));
60
+ }
53
61
  export class GraphIndexAbsentError extends Error {
54
62
  constructor(message) {
55
63
  super(message);
@@ -179,13 +187,39 @@ export async function pullRuleGraph(session, concurrency = DEFAULT_PULL_CONCURRE
179
187
  // zero-guard) wedge the scheduler.
180
188
  const scope = await resolveApplicationScope(session, undefined).catch(() => undefined);
181
189
  const applicationCode = scope?.code ?? "";
182
- const schemaCodes = flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: applicationCode } })).data))
183
- .filter((row) => row.type === "BizModel" && typeof row.code === "string" && row.code !== "")
184
- .map((row) => String(row.code))
185
- .sort();
190
+ const gaps = [];
191
+ const treeRows = flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: applicationCode } })).data));
192
+ const modelEntities = treeRows.filter((row) => row.type === "BizModel" && typeof row.code === "string" && row.code !== "").map((row) => ({ schemaCode: String(row.code), name: String(row.name ?? row.code) })).sort((a, b) => a.schemaCode.localeCompare(b.schemaCode));
193
+ const schemaCodes = modelEntities.map((row) => row.schemaCode);
194
+ const fields = [];
195
+ const services = [];
196
+ await Promise.all(schemaCodes.map(async (schemaCode) => {
197
+ try {
198
+ const rows = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode, isPublish: "true" } })).data);
199
+ for (const row of rows) {
200
+ const code = String(row.code ?? row.propertyCode ?? "");
201
+ if (code)
202
+ fields.push({ schemaCode, code, name: String(row.name ?? row.propertyName ?? code) });
203
+ }
204
+ }
205
+ catch (error) {
206
+ gaps.push({ kind: "field-list", ref: schemaCode, note: error instanceof Error ? error.message : String(error) });
207
+ }
208
+ }));
209
+ try {
210
+ await wireCall(session, { path: ENDPOINTS.bizServiceCategoryList });
211
+ const rows = wireRows((await wireCall(session, { path: ENDPOINTS.bizServiceListByCategoryId, query: { categoryId: "" } })).data);
212
+ for (const row of rows) {
213
+ const code = String(row.code ?? "");
214
+ if (code)
215
+ services.push({ code, name: String(row.name ?? code) });
216
+ }
217
+ }
218
+ catch (error) {
219
+ gaps.push({ kind: "service-list", ref: "all", note: error instanceof Error ? error.message : String(error) });
220
+ }
186
221
  const rules = [];
187
222
  const edges = [];
188
- const gaps = [];
189
223
  let parseFailures = 0;
190
224
  let dataruleCount = 0;
191
225
  let viewCount = 0;
@@ -375,6 +409,9 @@ export async function pullRuleGraph(session, concurrency = DEFAULT_PULL_CONCURRE
375
409
  },
376
410
  rules,
377
411
  edges,
412
+ models: modelEntities,
413
+ fields: fields.sort((a, b) => `${a.schemaCode}/${a.code}`.localeCompare(`${b.schemaCode}/${b.code}`)),
414
+ services: services.sort((a, b) => a.code.localeCompare(b.code)),
378
415
  gaps,
379
416
  scanned: { models: schemaCodes.length, rules: rules.length, datarules: dataruleCount, views: viewCount, parseFailures },
380
417
  timings: { listMs, readMs, totalMs: Date.now() - totalStart },
@@ -489,7 +526,10 @@ export async function publishGraphPull(session, concurrency = DEFAULT_PULL_CONCU
489
526
  via TEXT NOT NULL
490
527
  );
491
528
  CREATE INDEX edges_lookup ON edges(kind, code);
492
- CREATE TABLE gaps (kind TEXT NOT NULL, ref TEXT NOT NULL, note TEXT);
529
+ CREATE TABLE gaps (kind TEXT NOT NULL, ref TEXT NOT NULL, note TEXT);
530
+ CREATE TABLE models (schema_code TEXT PRIMARY KEY, name TEXT NOT NULL);
531
+ CREATE TABLE fields (schema_code TEXT NOT NULL, code TEXT NOT NULL, name TEXT NOT NULL);
532
+ CREATE TABLE services (code TEXT PRIMARY KEY, name TEXT NOT NULL);
493
533
  `);
494
534
  const insertMeta = db.prepare("INSERT INTO meta (key, value) VALUES (?, ?)");
495
535
  // `applicationResolution` is an object; String(value) would persist
@@ -506,12 +546,21 @@ export async function publishGraphPull(session, concurrency = DEFAULT_PULL_CONCU
506
546
  const insertGap = db.prepare("INSERT INTO gaps (kind, ref, note) VALUES (?, ?, ?)");
507
547
  for (const gap of data.gaps)
508
548
  insertGap.run(gap.kind, gap.ref, gap.note ?? null);
549
+ const insertModel = db.prepare("INSERT INTO models (schema_code, name) VALUES (?, ?)");
550
+ for (const row of data.models)
551
+ insertModel.run(row.schemaCode, row.name);
552
+ const insertField = db.prepare("INSERT INTO fields (schema_code, code, name) VALUES (?, ?, ?)");
553
+ for (const row of data.fields)
554
+ insertField.run(row.schemaCode, row.code, row.name);
555
+ const insertService = db.prepare("INSERT INTO services (code, name) VALUES (?, ?)");
556
+ for (const row of data.services)
557
+ insertService.run(row.code, row.name);
509
558
  // FTS5 mirrors rules (code+name) for `graph search`; one rebuild after
510
559
  // the bulk insert is cheaper than per-row triggers on a wholesale rebuild.
511
560
  db.exec(`
512
- CREATE VIRTUAL TABLE rules_fts USING fts5(rule_code, name, content='rules', content_rowid='rowid');
513
- INSERT INTO rules_fts(rules_fts) VALUES('rebuild');
514
- `);
561
+ CREATE VIRTUAL TABLE rules_fts USING fts5(rule_code, name, content='rules', content_rowid='rowid');
562
+ INSERT INTO rules_fts(rules_fts) VALUES('rebuild');
563
+ `);
515
564
  db.exec("COMMIT;");
516
565
  }
517
566
  finally {
@@ -544,6 +593,11 @@ function openGraphIndex() {
544
593
  throw new GraphIndexAbsentError(`索引 schema 版本不匹配(${meta.schemaVersion} ≠ ${GRAPH_SCHEMA_VERSION});重跑 omc graph pull`);
545
594
  const ruleCount = db.prepare("SELECT COUNT(*) AS n FROM rules").get().n;
546
595
  const modelCount = db.prepare("SELECT COUNT(DISTINCT schema_code) AS n FROM rules").get().n;
596
+ const entityCounts = {
597
+ models: db.prepare("SELECT COUNT(*) AS n FROM models").get().n,
598
+ fields: db.prepare("SELECT COUNT(*) AS n FROM fields").get().n,
599
+ services: db.prepare("SELECT COUNT(*) AS n FROM services").get().n,
600
+ };
547
601
  const edgeRows = db.prepare("SELECT kind, COUNT(*) AS n FROM edges GROUP BY kind").all();
548
602
  const edges = {};
549
603
  for (const row of edgeRows)
@@ -555,6 +609,8 @@ function openGraphIndex() {
555
609
  byKind[row.kind] = row.n;
556
610
  gapCount += row.n;
557
611
  }
612
+ const pulledAtIso = meta.pulledAtIso ?? "";
613
+ const ageDays = indexAgeDays(pulledAtIso);
558
614
  return {
559
615
  db,
560
616
  summary: {
@@ -564,10 +620,17 @@ function openGraphIndex() {
564
620
  // such key, so this stays optional rather than failing the read.
565
621
  ...(meta.applicationResolution ? { applicationResolution: parseApplicationResolution(meta.applicationResolution) } : {}),
566
622
  identityFingerprint: meta.identityFingerprint ?? "",
567
- pulledAtIso: meta.pulledAtIso ?? "",
623
+ pulledAtIso,
568
624
  freshness: gapCount === 0 ? "complete" : "partial",
625
+ ageDays,
626
+ // r31a-F16:旧索引 + complete 标签 = 静默错答。给可直接照抄的刷新命令,
627
+ // 并明确「complete 只指扫描无缺口」。
628
+ ...(ageDays >= STALE_INDEX_DAYS
629
+ ? { staleWarning: `索引是 ${ageDays} 天前的快照(pulledAt ${pulledAtIso});freshness:"complete" 只表示扫描无缺口、不表示最新。先 \`omc graph pull --env ${meta.environmentName ?? "<env>"}\` 再查,或给结论加时效限定` }
630
+ : {}),
569
631
  models: modelCount,
570
632
  rules: ruleCount,
633
+ entities: entityCounts,
571
634
  edges,
572
635
  gaps: { count: gapCount, byKind },
573
636
  },
@@ -578,6 +641,17 @@ function openGraphIndex() {
578
641
  throw error instanceof GraphIndexAbsentError ? error : new GraphIndexAbsentError(`索引不可读(schema 不匹配或文件损坏):${error instanceof Error ? error.message : String(error)};重跑 omc graph pull`);
579
642
  }
580
643
  }
644
+ /**
645
+ * r35a-F4 / r35a-F4b(真机 2026-11-06):探针拿 `matches:[]` 差点把一个**被表单引用**
646
+ * 的字段判成「可安全删除」,紧接着发现 `graph impact` 的 `totals` 全 0 也有同样误读风险。
647
+ * 三个面(CLI references / CLI impact / MCP explore)必须共用同一份声明——
648
+ * 分散定义就会被漏掉一处(MCP 面第一版就漏了)。
649
+ */
650
+ export const GRAPH_COVERAGE = Object.freeze({
651
+ scanned: Object.freeze(["businessrule", "datarule", "listview"]),
652
+ notScanned: Object.freeze(["form", "workflow.permissions", "workflow.bindings", "onlinejs", "rule.conditions"]),
653
+ note: "零命中/全零 totals 只代表 scanned 面没有命中;notScanned 面(尤其 form 与 workflow.permissions)未扫描,不可据此判定可安全删除或无影响",
654
+ });
581
655
  /**
582
656
  * Zero-hit disambiguation (2026-10-31 capability-test friction, hit twice in
583
657
  * one session): querying `impact field mcc_supplier` returns an empty result
@@ -607,16 +681,22 @@ export function queryGraphReferences(kind, code) {
607
681
  via: row.via,
608
682
  }));
609
683
  const gapped = summary.gaps.count > 0;
684
+ // r35a-F4(真机 2026-11-06):探针拿 matches:[] 差点把一个**被表单引用**的
685
+ // 字段判成「可安全删除」——note 只说了「不含规则条件叶子/流程绑定」,从没
686
+ // 明说表单与流程权限表不在扫描范围。散文字段不承担安全契约:这里给结构化
687
+ // coverage,让「没人引用」与「这一面没扫」在机器可读层面可分。
688
+ const coverage = GRAPH_COVERAGE;
610
689
  return {
611
690
  kind,
612
691
  code,
613
692
  matches,
693
+ coverage,
614
694
  index: summary,
615
695
  ...(matches.length === 0
616
696
  ? {
617
697
  note: (gapped
618
698
  ? "零命中,但索引存在扫描缺口——不可作为「无引用」证明;先 omc graph pull 重建再查"
619
- : "零命中=索引覆盖范围内没有引用它(规则面:service→methodMapping.serviceCode;model→targetObjectCode/methodMapping.schemaCode;field→dataActions.targetDataItem/映射 bizCode;数据规则面:propertyCodes 目标与 options {字段} 引用;视图面:列/排序/条件的 propertyCode。不含规则条件叶子/流程绑定)") +
699
+ : "零命中=仅 scanned 面(规则/数据规则/视图)没有引用它;form(表单控件)与 workflow.permissions 未扫描,见 coverage.notScanned——不可据此判定可安全删除") +
620
700
  (zeroHitKindHint(db, kind, code, "references") ?? ""),
621
701
  }
622
702
  : {}),
@@ -633,7 +713,14 @@ export function readGraphStatus() {
633
713
  const { db, summary } = openGraphIndex();
634
714
  try {
635
715
  const gaps = db.prepare("SELECT kind, ref, note FROM gaps ORDER BY kind, ref LIMIT 50").all();
636
- return { index: summary, dbBytes: statSync(graphDbPath()).size, gaps };
716
+ const dbPath = graphDbPath();
717
+ return {
718
+ index: summary,
719
+ dbBytes: statSync(dbPath).size,
720
+ dbPath,
721
+ dbId: sha256(dbPath).slice(0, 12),
722
+ gaps,
723
+ };
637
724
  }
638
725
  finally {
639
726
  db.close();
@@ -651,6 +738,11 @@ export function searchGraphRules(term, limit = 20) {
651
738
  try {
652
739
  const tokens = term.trim().split(/\s+/).filter((token) => token !== "");
653
740
  let matches = [];
741
+ const entityMatches = {
742
+ model: { count: 0, total: 0, items: [] },
743
+ field: { count: 0, total: 0, items: [] },
744
+ service: { count: 0, total: 0, items: [] },
745
+ };
654
746
  if (tokens.length > 0) {
655
747
  const quoted = tokens.map((token, index) => `"${token.replaceAll('"', '""')}"${index === tokens.length - 1 ? "*" : ""}`).join(" ");
656
748
  const rows = db
@@ -673,13 +765,24 @@ export function searchGraphRules(term, limit = 20) {
673
765
  refs,
674
766
  };
675
767
  });
768
+ const searchEntity = (countSql, rowsSql, params, map) => {
769
+ const total = Number(db.prepare(countSql).get(...params).n);
770
+ const rows = db.prepare(rowsSql).all(...params, limit);
771
+ return { count: rows.length, total, items: rows.map(map), ...(rows.length < total ? { truncated: true } : {}) };
772
+ };
773
+ entityMatches.model = searchEntity("SELECT COUNT(*) AS n FROM models WHERE instr(name, ?) > 0 OR instr(schema_code, ?) > 0", "SELECT schema_code, name FROM models WHERE instr(name, ?) > 0 OR instr(schema_code, ?) > 0 ORDER BY schema_code LIMIT ?", [term.trim(), term.trim()], (row) => ({ schemaCode: row.schema_code, name: row.name }));
774
+ entityMatches.field = searchEntity("SELECT COUNT(*) AS n FROM fields WHERE instr(name, ?) > 0 OR instr(code, ?) > 0", "SELECT schema_code, code, name FROM fields WHERE instr(name, ?) > 0 OR instr(code, ?) > 0 ORDER BY schema_code, code LIMIT ?", [term.trim(), term.trim()], (row) => ({ schemaCode: row.schema_code, code: row.code, name: row.name }));
775
+ entityMatches.service = searchEntity("SELECT COUNT(*) AS n FROM services WHERE instr(name, ?) > 0 OR instr(code, ?) > 0", "SELECT code, name FROM services WHERE instr(name, ?) > 0 OR instr(code, ?) > 0 ORDER BY code LIMIT ?", [term.trim(), term.trim()], (row) => ({ code: row.code, name: row.name }));
676
776
  }
677
777
  const gapped = summary.gaps.count > 0;
678
778
  return {
679
779
  term,
680
780
  rules: matches,
781
+ matches: entityMatches,
681
782
  index: summary,
682
- ...(matches.length === 0
783
+ coverage: { scanned: ["rule.code", "rule.name", "model.code", "model.name", "field.code", "field.name", "service.code", "service.name"], notScanned: ["rule.body", "form", "workflow.permissions"], note: "检索覆盖规则与实体的 code/name;规则体、表单与流程权限未扫描" },
784
+ ...(term.trim() !== "" && !/^[A-Za-z][A-Za-z0-9_.-]*$/.test(term.trim()) ? { note: "疑似业务词;本工具同时检索实体 name/code 与规则 code/name,引用明细请使用匹配到的 code 再查 references/impact" } : {}),
785
+ ...(matches.length === 0 && entityMatches.model.total === 0 && entityMatches.field.total === 0 && entityMatches.service.total === 0
683
786
  ? {
684
787
  note: tokens.length === 0
685
788
  ? "检索词为空"
@@ -757,11 +860,12 @@ export function impactGraph(kind, code) {
757
860
  listviews: listViewHits.slice(0, 20).map((hit) => ({ schemaCode: hit.schema_code, viewCode: hit.view_code ?? "", via: hit.via })),
758
861
  totals: { rules: hop1.length, datarules: dataruleHits.length, listviews: listViewHits.length, touchedServices: services.size, touchedModels: models.size, touchedFields: fields.size },
759
862
  index: summary,
863
+ coverage: GRAPH_COVERAGE,
760
864
  ...(hop1.length === 0 && dataruleHits.length === 0 && listViewHits.length === 0
761
865
  ? {
762
866
  note: (gapped
763
867
  ? "零命中,但索引存在扫描缺口——不可作为「无影响」证明;先 omc graph pull 重建再查"
764
- : "零命中=索引覆盖范围内没有引用它(规则面+数据规则面+视图列/排序/条件面;流程绑定暂不在索引内)") +
868
+ : "零命中=仅 scanned 面(规则/数据规则/视图)没有影响;form(表单控件)与 workflow.permissions 未扫描,见 coverage.notScanned——不可据此判定「删除无影响」") +
765
869
  (zeroHitKindHint(db, kind, code, "impact") ?? ""),
766
870
  }
767
871
  : {}),