@stalfh233/omc-cli 0.5.15 → 0.6.0

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.
@@ -0,0 +1,793 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, relative } from "node:path";
4
+ import { ENDPOINTS } from "./endpoints.js";
5
+ import { ConfigStore, defaultPaths } from "./config-store.js";
6
+ import { SessionManager } from "./session-manager.js";
7
+ import { makeToolContext } from "./context.js";
8
+ import { withoutReadbackCache } from "./read.js";
9
+ import { runToolCall } from "./call.js";
10
+ import { ToolRefusalError } from "./tool-types.js";
11
+ import { wireCall, WireError, wireRows } from "./wire.js";
12
+ /**
13
+ * `omc programming` — the file-based working copy for the design-time
14
+ * 在线开发 face. The platform is the remote; `.omc-programming/` is the local
15
+ * tree; the user's git is the history layer (the platform only keeps one draft
16
+ * row plus the published rows, and published content has no wire read
17
+ * endpoint — see .scratch/backend-online-coding/recon.md §8).
18
+ *
19
+ * Commands:
20
+ * pull remote → tree + manifest (environment-stamped)
21
+ * status local edits vs manifest, plus optional remote drift (--deep)
22
+ * apply changed files → programming.save (→ --publish)
23
+ * adopt graphical rule → coding mode: template into the tree
24
+ * test compile → publish → trigger → assertions → cleanup (fixture-driven)
25
+ *
26
+ * The writes go through the REGISTRY tools (runToolCall) so the governed
27
+ * pipeline (writable gate, production confirmation, write lock, audit
28
+ * artifacts, requestId idempotency) applies exactly as in `omc call`.
29
+ */
30
+ const DIR_NAME = ".omc-programming";
31
+ const MANIFEST_VERSION = 1;
32
+ export function workspacePaths(cwd = process.cwd()) {
33
+ const root = join(cwd, DIR_NAME);
34
+ return { root, manifestFile: join(root, "manifest.json"), sourcesDir: join(root, "sources"), testsDir: join(root, "tests") };
35
+ }
36
+ export function sourceFilePath(paths, schemaCode, ruleCode) {
37
+ return join(paths.sourcesDir, schemaCode, `${ruleCode}.java`);
38
+ }
39
+ export function fixtureFilePath(paths, schemaCode, ruleCode) {
40
+ return join(paths.testsDir, schemaCode, `${ruleCode}.yaml`);
41
+ }
42
+ function sha256(text) {
43
+ return createHash("sha256").update(text, "utf8").digest("hex");
44
+ }
45
+ function ruleKey(schemaCode, ruleCode) {
46
+ return `${schemaCode}#${ruleCode}`;
47
+ }
48
+ export function readManifest(paths) {
49
+ if (!existsSync(paths.manifestFile))
50
+ return null;
51
+ return JSON.parse(readFileSync(paths.manifestFile, "utf8"));
52
+ }
53
+ function writeManifest(paths, manifest) {
54
+ mkdirSync(paths.root, { recursive: true });
55
+ const ordered = {
56
+ ...manifest,
57
+ rules: Object.fromEntries(Object.entries(manifest.rules).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))),
58
+ published: Object.fromEntries(Object.entries(manifest.published).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))),
59
+ };
60
+ writeFileSync(paths.manifestFile, JSON.stringify(ordered, null, 2) + "\n", "utf8");
61
+ }
62
+ /** Environment stamp gate: a tree pulled from one environment must not apply to another. */
63
+ export function assertManifestEnvironment(manifest, environment, baseUrl) {
64
+ if (manifest.environment !== environment || manifest.baseUrl !== baseUrl) {
65
+ throw Object.assign(new Error(`本地工作区属于环境 "${manifest.environment}"(${manifest.baseUrl}),不是 "${environment}"(${baseUrl})。每个环境独立 pull;不许跨环境套用(同 omc-graph 的防跨环境语义)。`), { blocker: "programming-workspace-environment-mismatch" });
66
+ }
67
+ }
68
+ /**
69
+ * Read the whole programming face for one application. Schemas come from
70
+ * list_schemas_by_appcode (only models with existing sources); each schema's
71
+ * source list carries the draft content. A schema whose list aborts (the
72
+ * platform throws PROGRAMMING_SOURCE_NONEXISTENT if any draft is missing) is
73
+ * recorded as a gap, never silently dropped.
74
+ */
75
+ async function readRemoteSources(session, applicationCode) {
76
+ const schemas = wireRows((await wireCall(session, { path: ENDPOINTS.programmingListSchemas, query: { appCode: applicationCode } })).data);
77
+ const sources = [];
78
+ const gaps = [];
79
+ for (const schema of schemas) {
80
+ const schemaCode = String(schema.code ?? "");
81
+ if (!schemaCode)
82
+ continue;
83
+ try {
84
+ const rows = wireRows((await wireCall(session, { path: ENDPOINTS.programmingListSources, query: { schemaCode } })).data);
85
+ for (const row of rows) {
86
+ const ruleCode = String(row.businessRuleCode ?? "");
87
+ const content = typeof row.content === "string" ? row.content : "";
88
+ if (!ruleCode || content === "")
89
+ continue;
90
+ sources.push({ schemaCode, ruleCode, content, provisioned: true });
91
+ }
92
+ }
93
+ catch (error) {
94
+ if (error instanceof WireError && error.kind === "business") {
95
+ gaps.push({ schemaCode, reason: error.message });
96
+ continue;
97
+ }
98
+ throw error;
99
+ }
100
+ }
101
+ return { applicationCode, sources, schemas: schemas.length, gaps };
102
+ }
103
+ /** `omc programming pull` — remote → local tree + manifest. */
104
+ export async function cmdProgrammingPull(options) {
105
+ const paths = workspacePaths(options.cwd);
106
+ const context = options.ctx ?? makeToolContext({ sessions: new SessionManager(new ConfigStore(defaultPaths())) });
107
+ const session = await context.sessions.session(options.environment);
108
+ const profileApplicationCode = session.profile.applicationCode;
109
+ const applicationCode = options.appCode ?? profileApplicationCode;
110
+ if (!applicationCode) {
111
+ return { status: "refused", blocker: "programming-application-code-required", message: "缺少应用编码:传 --app <appCode>,或先在环境配置里设置 applicationCode(omc setup)。" };
112
+ }
113
+ const remote = await readRemoteSources(session, applicationCode);
114
+ const previous = readManifest(paths);
115
+ const rules = {};
116
+ for (const source of remote.sources) {
117
+ const key = ruleKey(source.schemaCode, source.ruleCode);
118
+ rules[key] = {
119
+ schemaCode: source.schemaCode,
120
+ ruleCode: source.ruleCode,
121
+ appCode: applicationCode,
122
+ code: `${source.schemaCode}${source.ruleCode}Handler`,
123
+ draftSha256: sha256(source.content),
124
+ provisioned: source.provisioned,
125
+ };
126
+ const file = sourceFilePath(paths, source.schemaCode, source.ruleCode);
127
+ mkdirSync(dirname(file), { recursive: true });
128
+ writeFileSync(file, source.content, "utf8");
129
+ }
130
+ const manifest = {
131
+ manifestVersion: MANIFEST_VERSION,
132
+ environment: session.environmentName,
133
+ baseUrl: session.profile.baseUrl,
134
+ applicationCode,
135
+ pulledAtIso: new Date().toISOString(),
136
+ rules,
137
+ // Published knowledge survives a pull: only WE know what we last pushed.
138
+ published: previous?.published ?? {},
139
+ };
140
+ writeManifest(paths, manifest);
141
+ return {
142
+ status: "ok",
143
+ environment: session.environmentName,
144
+ applicationCode,
145
+ directory: paths.root,
146
+ pulledAtIso: manifest.pulledAtIso,
147
+ schemas: remote.schemas,
148
+ rules: Object.keys(rules).length,
149
+ ...(remote.gaps.length > 0 ? { gaps: remote.gaps } : {}),
150
+ nextCommand: "改 .omc-programming/sources/**/*.java → omc programming status → omc programming apply --publish",
151
+ };
152
+ }
153
+ /** Walk the tree; classify edits against the manifest hashes. */
154
+ export function diffLocalTree(paths, manifest) {
155
+ const diff = { modified: [], untracked: [], deleted: [], unpublished: [] };
156
+ const seen = new Set();
157
+ const walk = (dir) => {
158
+ if (!existsSync(dir))
159
+ return;
160
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
161
+ const full = join(dir, entry.name);
162
+ if (entry.isDirectory()) {
163
+ walk(full);
164
+ continue;
165
+ }
166
+ if (!entry.name.endsWith(".java"))
167
+ continue;
168
+ // Root-relative on purpose: apply() resolves targets against paths.root.
169
+ const rel = relative(paths.root, full);
170
+ const treeRel = relative(paths.sourcesDir, full);
171
+ const [schemaCode, file] = treeRel.split("/");
172
+ const ruleCode = file.replace(/\.java$/, "");
173
+ const key = ruleKey(schemaCode ?? "", ruleCode);
174
+ seen.add(key);
175
+ const content = readFileSync(full, "utf8");
176
+ const digest = sha256(content);
177
+ const known = manifest.rules[key];
178
+ if (!known)
179
+ diff.untracked.push(rel);
180
+ else if (known.draftSha256 !== digest)
181
+ diff.modified.push(rel);
182
+ const published = manifest.published[key];
183
+ if (known && (published === undefined || published.sha256 !== digest))
184
+ diff.unpublished.push(rel);
185
+ }
186
+ };
187
+ walk(paths.sourcesDir);
188
+ for (const [key, rule] of Object.entries(manifest.rules)) {
189
+ if (!seen.has(key))
190
+ diff.deleted.push(join("sources", rule.schemaCode, `${rule.ruleCode}.java`));
191
+ }
192
+ return diff;
193
+ }
194
+ /** `omc programming status` — local edits + optional remote drift. */
195
+ export async function cmdProgrammingStatus(options) {
196
+ const paths = workspacePaths(options.cwd);
197
+ const context = options.ctx ?? makeToolContext({ sessions: new SessionManager(new ConfigStore(defaultPaths())) });
198
+ const session = await context.sessions.session(options.environment);
199
+ const manifest = readManifest(paths);
200
+ if (!manifest)
201
+ return { status: "refused", blocker: "programming-workspace-absent", message: "本地没有 .omc-programming/。先 omc programming pull --env <env> --app <appCode>。" };
202
+ try {
203
+ assertManifestEnvironment(manifest, session.environmentName, session.profile.baseUrl);
204
+ }
205
+ catch (error) {
206
+ return { status: "refused", blocker: error.blocker ?? "programming-workspace-environment-mismatch", message: error.message };
207
+ }
208
+ const local = diffLocalTree(paths, manifest);
209
+ const base = {
210
+ environment: session.environmentName,
211
+ directory: paths.root,
212
+ pulledAtIso: manifest.pulledAtIso,
213
+ local: {
214
+ modified: local.modified,
215
+ untracked: local.untracked,
216
+ deleted: local.deleted,
217
+ unpublished: local.unpublished,
218
+ dirty: local.modified.length + local.untracked.length + local.deleted.length > 0,
219
+ },
220
+ };
221
+ if (!options.deep)
222
+ return { status: "ok", ...base, hint: "--deep 再比对远端草稿(内容哈希)" };
223
+ const remote = await readRemoteSources(session, options.appCode ?? manifest.applicationCode);
224
+ const remoteByKey = new Map(remote.sources.map((source) => [ruleKey(source.schemaCode, source.ruleCode), source]));
225
+ const ahead = [];
226
+ const fresh = [];
227
+ const missing = [];
228
+ for (const [key, rule] of Object.entries(manifest.rules)) {
229
+ const remoteRule = remoteByKey.get(key);
230
+ if (!remoteRule) {
231
+ missing.push(key);
232
+ continue;
233
+ }
234
+ if (sha256(remoteRule.content) !== rule.draftSha256)
235
+ ahead.push(relative(paths.root, sourceFilePath(paths, rule.schemaCode, rule.ruleCode)));
236
+ }
237
+ for (const key of remoteByKey.keys())
238
+ if (!manifest.rules[key])
239
+ fresh.push(key);
240
+ return {
241
+ status: "ok",
242
+ ...base,
243
+ remote: {
244
+ ahead,
245
+ new: fresh,
246
+ absent: missing,
247
+ note: "平台无乐观锁:apply 前会用内容哈希做漂移门(advisory,非原子锁)。ahead 非空时先 pull 再改。",
248
+ },
249
+ };
250
+ }
251
+ /**
252
+ * Minimal YAML-subset parser for fixtures. Supports exactly the shapes a test
253
+ * fixture needs: nested maps, sequences of maps (`- key: value`), sequences of
254
+ * scalars, and inline JSON values ({...} / [...]). Anything else is a parse
255
+ * error — a fixture that silently loses its `asserts` would report a vacuous
256
+ * pass, which is worse than refusing.
257
+ */
258
+ export function parseFixture(text) {
259
+ const lines = [];
260
+ for (const raw of text.split(/\r?\n/)) {
261
+ if (raw.trim() === "" || raw.trim().startsWith("#"))
262
+ continue;
263
+ lines.push({ indent: raw.length - raw.trimStart().length, text: raw.trim() });
264
+ }
265
+ let index = 0;
266
+ /** Split on top-level commas (nesting-aware, quote-aware). */
267
+ const splitInline = (inner) => {
268
+ const parts = [];
269
+ let depth = 0;
270
+ let quote = null;
271
+ let current = "";
272
+ for (const char of inner) {
273
+ if (quote !== null) {
274
+ current += char;
275
+ if (char === quote)
276
+ quote = null;
277
+ continue;
278
+ }
279
+ if (char === '"' || char === "'") {
280
+ quote = char;
281
+ current += char;
282
+ continue;
283
+ }
284
+ if (char === "{" || char === "[")
285
+ depth += 1;
286
+ if (char === "}" || char === "]")
287
+ depth -= 1;
288
+ if (char === "," && depth === 0) {
289
+ parts.push(current);
290
+ current = "";
291
+ continue;
292
+ }
293
+ current += char;
294
+ }
295
+ if (current.trim() !== "")
296
+ parts.push(current);
297
+ return parts.map((part) => part.trim()).filter((part) => part !== "");
298
+ };
299
+ const parseScalar = (raw) => {
300
+ const value = raw.trim();
301
+ if (value === "true")
302
+ return true;
303
+ if (value === "false")
304
+ return false;
305
+ if (value === "null" || value === "~")
306
+ return null;
307
+ if (value.startsWith("{") && value.endsWith("}")) {
308
+ try {
309
+ return JSON.parse(value);
310
+ }
311
+ catch { /* fall through to the YAML-ish form */ }
312
+ const out = {};
313
+ for (const part of splitInline(value.slice(1, -1))) {
314
+ const sep = part.indexOf(":");
315
+ if (sep === -1)
316
+ return value;
317
+ out[part.slice(0, sep).trim()] = parseScalar(part.slice(sep + 1));
318
+ }
319
+ return out;
320
+ }
321
+ if (value.startsWith("[") && value.endsWith("]")) {
322
+ try {
323
+ return JSON.parse(value);
324
+ }
325
+ catch { /* fall through */ }
326
+ return splitInline(value.slice(1, -1)).map(parseScalar);
327
+ }
328
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
329
+ return value.slice(1, -1);
330
+ if (/^-?\d+(\.\d+)?$/.test(value))
331
+ return Number(value);
332
+ return value;
333
+ };
334
+ const parseBlock = (indent) => {
335
+ if (index >= lines.length || lines[index].indent < indent)
336
+ return null;
337
+ const first = lines[index];
338
+ if (first.indent > indent)
339
+ throw new Error(`fixture 缩进异常(行 ${index + 1})`);
340
+ return first.text.startsWith("- ") || first.text === "-" ? parseSequence(first.indent) : parseMap(first.indent);
341
+ };
342
+ const parseSequence = (indent) => {
343
+ const out = [];
344
+ while (index < lines.length && lines[index].indent === indent && (lines[index].text.startsWith("- ") || lines[index].text === "-")) {
345
+ const text = lines[index].text;
346
+ const rest = text === "-" ? "" : text.slice(2).trim();
347
+ index += 1;
348
+ if (rest === "") {
349
+ out.push(index < lines.length && lines[index].indent > indent ? parseBlock(lines[index].indent) : null);
350
+ continue;
351
+ }
352
+ const colon = rest.indexOf(":");
353
+ if (colon === -1) {
354
+ out.push(parseScalar(rest));
355
+ continue;
356
+ }
357
+ // "- key: value" starts a map item; following deeper lines belong to it.
358
+ const item = {};
359
+ const key = rest.slice(0, colon).trim();
360
+ const valueRaw = rest.slice(colon + 1).trim();
361
+ item[key] = valueRaw === "" ? null : parseScalar(valueRaw);
362
+ if (valueRaw === "" && index < lines.length && lines[index].indent > indent)
363
+ item[key] = parseBlock(lines[index].indent);
364
+ while (index < lines.length && lines[index].indent > indent && !lines[index].text.startsWith("- ")) {
365
+ const line = lines[index];
366
+ const sep = line.text.indexOf(":");
367
+ if (sep === -1)
368
+ throw new Error(`fixture 行无法解析:${line.text}`);
369
+ const childKey = line.text.slice(0, sep).trim();
370
+ const childValue = line.text.slice(sep + 1).trim();
371
+ index += 1;
372
+ item[childKey] = childValue === "" ? (index < lines.length && lines[index].indent > line.indent ? parseBlock(lines[index].indent) : null) : parseScalar(childValue);
373
+ }
374
+ out.push(item);
375
+ }
376
+ return out;
377
+ };
378
+ const parseMap = (indent) => {
379
+ const out = {};
380
+ while (index < lines.length && lines[index].indent === indent && !lines[index].text.startsWith("- ")) {
381
+ const line = lines[index];
382
+ const sep = line.text.indexOf(":");
383
+ if (sep === -1)
384
+ throw new Error(`fixture 行无法解析:${line.text}`);
385
+ const key = line.text.slice(0, sep).trim();
386
+ const valueRaw = line.text.slice(sep + 1).trim();
387
+ index += 1;
388
+ if (valueRaw === "") {
389
+ out[key] = index < lines.length && lines[index].indent > indent ? parseBlock(lines[index].indent) : {};
390
+ }
391
+ else {
392
+ out[key] = parseScalar(valueRaw);
393
+ }
394
+ }
395
+ return out;
396
+ };
397
+ const parsed = parseBlock(0);
398
+ if (index !== lines.length)
399
+ throw new Error("fixture 有未消费的行(缩进不一致)");
400
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
401
+ throw new Error("fixture 顶层必须是映射");
402
+ return parsed;
403
+ }
404
+ /**
405
+ * Invoke a registry tool through the governed pipeline, returning its result.
406
+ * Reads run with the readback-cache short-circuit disabled: a composer calling
407
+ * `programming.compile` twice with identical content must get the REAL verdict
408
+ * both times, not the `{unchanged:true}` stub the single-call diet returns.
409
+ */
410
+ async function callTool(tool, args, ctx) {
411
+ const run = async () => {
412
+ const { result } = await runToolCall(tool, { args, print: () => undefined, ...(ctx ? { context: ctx } : {}) });
413
+ return result;
414
+ };
415
+ return ctx ? withoutReadbackCache(ctx, run) : run();
416
+ }
417
+ function okOrThrow(tool, outcome) {
418
+ if (outcome.status === "ok" || outcome.status === "dry-run")
419
+ return outcome;
420
+ throw new ToolRefusalError({
421
+ status: "refused",
422
+ blocker: `programming-apply-${tool.replace(/\./g, "-")}-${String(outcome.blocker ?? outcome.status ?? "failed")}`,
423
+ message: `按文件应用时 ${tool} 失败:${String(outcome.message ?? "")}`,
424
+ cause: outcome,
425
+ });
426
+ }
427
+ /** `omc programming apply` — changed files → governed save (→ publish). */
428
+ export async function cmdProgrammingApply(options) {
429
+ const paths = workspacePaths(options.cwd);
430
+ const manifest = readManifest(paths);
431
+ if (!manifest)
432
+ return { status: "refused", blocker: "programming-workspace-absent", message: "本地没有 .omc-programming/。先 omc programming pull --env <env> --app <appCode>。" };
433
+ const context = options.ctx ?? makeToolContext({ sessions: new SessionManager(new ConfigStore(defaultPaths())) });
434
+ const session = await context.sessions.session(options.environment);
435
+ try {
436
+ assertManifestEnvironment(manifest, session.environmentName, session.profile.baseUrl);
437
+ }
438
+ catch (error) {
439
+ return { status: "refused", blocker: error.blocker ?? "programming-workspace-environment-mismatch", message: error.message };
440
+ }
441
+ // Target selection: explicit files, else modified + untracked.
442
+ const diff = diffLocalTree(paths, manifest);
443
+ const targets = options.files !== undefined
444
+ ? options.files.map((file) => {
445
+ const relativePath = relative(paths.root, file.startsWith("/") ? file : join(options.cwd ?? process.cwd(), file));
446
+ return relativePath.startsWith("sources/") ? relativePath : relativePath;
447
+ })
448
+ : [...diff.modified, ...diff.untracked];
449
+ if (targets.length === 0)
450
+ return { status: "ok", changed: 0, message: "工作区无改动(本地与 manifest 一致)。", directory: paths.root };
451
+ const results = [];
452
+ for (const target of targets) {
453
+ const absolute = target.startsWith("/") ? target : join(paths.root, target);
454
+ if (!existsSync(absolute)) {
455
+ results.push({ file: target, schemaCode: "", ruleCode: "", error: "文件不存在" });
456
+ continue;
457
+ }
458
+ const content = readFileSync(absolute, "utf8");
459
+ const rel = relative(paths.sourcesDir, absolute);
460
+ const [schemaCode = "", file = ""] = rel.split("/");
461
+ const ruleCode = file.replace(/\.java$/, "");
462
+ const key = ruleKey(schemaCode, ruleCode);
463
+ const known = manifest.rules[key];
464
+ if (!known) {
465
+ results.push({ file: target, schemaCode, ruleCode, error: "未在 manifest 中:先 pull(或 adopt)登记该规则,避免无主写入" });
466
+ continue;
467
+ }
468
+ // Advisory drift gate (the platform has no optimistic lock): the remote
469
+ // draft must still hash to what we pulled, else someone else edited it.
470
+ try {
471
+ const remote = await callTool("programming.get", { environment: session.environmentName, schemaCode, businessRuleCode: ruleCode }, context);
472
+ const remoteSha = typeof remote.contentSha256 === "string" ? remote.contentSha256 : null;
473
+ const provisioned = remote.provisioned === true;
474
+ if (known.draftSha256 !== null && provisioned && remoteSha !== known.draftSha256) {
475
+ results.push({
476
+ file: target,
477
+ schemaCode,
478
+ ruleCode,
479
+ error: `remote-ahead:远端草稿已在 pull 之后被改动(manifest ${known.draftSha256.slice(0, 12)}… ≠ 远端 ${String(remoteSha).slice(0, 12)}…)。先 omc programming pull 再改(平台无乐观锁,这是 CLI 的 advisory 门)。`,
480
+ });
481
+ continue;
482
+ }
483
+ }
484
+ catch (error) {
485
+ results.push({ file: target, schemaCode, ruleCode, error: `漂移门读取失败:${error.message}` });
486
+ continue;
487
+ }
488
+ try {
489
+ const save = await callTool("programming.save", {
490
+ environment: session.environmentName,
491
+ schemaCode,
492
+ businessRuleCode: ruleCode,
493
+ content,
494
+ ...(options.dryRun === true ? { dryRun: true } : {}),
495
+ }, context);
496
+ const outcome = { file: target, schemaCode, ruleCode, save };
497
+ if (save.status !== "ok" && save.status !== "dry-run") {
498
+ outcome.error = String(save.message ?? save.blocker ?? save.status);
499
+ results.push(outcome);
500
+ continue;
501
+ }
502
+ if (options.dryRun !== true) {
503
+ known.draftSha256 = sha256(content);
504
+ manifest.rules[key] = known;
505
+ }
506
+ if (options.publish === true && options.dryRun !== true) {
507
+ const published = await callTool("programming.publish", {
508
+ environment: session.environmentName,
509
+ schemaCode,
510
+ businessRuleCode: ruleCode,
511
+ content,
512
+ }, context);
513
+ outcome.publish = published;
514
+ if (published.status !== "ok")
515
+ outcome.error = String(published.message ?? published.blocker ?? published.status);
516
+ else {
517
+ manifest.published[key] = { sha256: sha256(content), atIso: new Date().toISOString(), environment: session.environmentName };
518
+ }
519
+ }
520
+ results.push(outcome);
521
+ }
522
+ catch (error) {
523
+ results.push({ file: target, schemaCode, ruleCode, error: error.message });
524
+ }
525
+ }
526
+ if (options.dryRun !== true)
527
+ writeManifest(paths, manifest);
528
+ const failed = results.filter((entry) => entry.error !== undefined);
529
+ return {
530
+ status: failed.length === 0 ? "ok" : failed.length === results.length ? "refused" : "partial",
531
+ environment: session.environmentName,
532
+ applied: results.length - failed.length,
533
+ failed: failed.length,
534
+ results,
535
+ ...(options.publish === true ? { published: true } : {}),
536
+ ...(options.dryRun === true ? { dryRun: true } : {}),
537
+ };
538
+ }
539
+ /** `omc programming adopt` — graphical rule → coding mode (template into the tree). */
540
+ export async function cmdProgrammingAdopt(options) {
541
+ const paths = workspacePaths(options.cwd);
542
+ const context = options.ctx ?? makeToolContext({ sessions: new SessionManager(new ConfigStore(defaultPaths())) });
543
+ const session = await context.sessions.session(options.environment);
544
+ const { schemaCode, ruleCode } = options;
545
+ // The rule must exist; publish carries its full model and flips it to CODING.
546
+ const rules = wireRows((await wireCall(session, { path: ENDPOINTS.businessRuleListBySchemaCode, query: { schemaCode } })).data);
547
+ const rule = rules.find((row) => String(row.code ?? row.ruleCode ?? "") === ruleCode);
548
+ if (!rule) {
549
+ return { status: "refused", blocker: "programming-rule-absent", message: `模型 ${schemaCode} 没有规则 ${ruleCode}——先 rule.list 核对 code,或 rule.save 新建。` };
550
+ }
551
+ const source = await callTool("programming.get", { environment: session.environmentName, schemaCode, businessRuleCode: ruleCode }, context);
552
+ if (source.status !== "ok")
553
+ return { status: source.status, blocker: source.blocker, message: source.message, source };
554
+ const content = String(source.content ?? "");
555
+ if (content === "")
556
+ return { status: "refused", blocker: "programming-template-empty", message: "平台没有返回该规则的模板源码。" };
557
+ const file = sourceFilePath(paths, schemaCode, ruleCode);
558
+ mkdirSync(dirname(file), { recursive: true });
559
+ writeFileSync(file, content, "utf8");
560
+ const manifest = readManifest(paths) ?? {
561
+ manifestVersion: MANIFEST_VERSION,
562
+ environment: session.environmentName,
563
+ baseUrl: session.profile.baseUrl,
564
+ applicationCode: String(rule.appCode ?? session.profile.applicationCode ?? ""),
565
+ pulledAtIso: new Date().toISOString(),
566
+ rules: {},
567
+ published: {},
568
+ };
569
+ try {
570
+ assertManifestEnvironment(manifest, session.environmentName, session.profile.baseUrl);
571
+ }
572
+ catch (error) {
573
+ return { status: "refused", blocker: error.blocker ?? "programming-workspace-environment-mismatch", message: error.message };
574
+ }
575
+ const key = ruleKey(schemaCode, ruleCode);
576
+ manifest.rules[key] = {
577
+ schemaCode,
578
+ ruleCode,
579
+ appCode: manifest.applicationCode,
580
+ code: `${schemaCode}${ruleCode}Handler`,
581
+ draftSha256: typeof source.contentSha256 === "string" ? source.contentSha256 : sha256(content),
582
+ provisioned: source.provisioned === true,
583
+ };
584
+ writeManifest(paths, manifest);
585
+ return {
586
+ status: "ok",
587
+ environment: session.environmentName,
588
+ schemaCode,
589
+ ruleCode,
590
+ file: relative(paths.root, file),
591
+ provisioned: source.provisioned === true,
592
+ developmentMode: rule.developmentMode ?? null,
593
+ nextCommand: `改 ${relative(paths.root, file)} → omc programming apply --env ${session.environmentName} --file ${relative(paths.root, file)} --publish(publish 内部会把规则切 CODING+enable)`,
594
+ };
595
+ }
596
+ function matchExpectation(row, expect) {
597
+ const mismatches = [];
598
+ const data = (row.data ?? row);
599
+ for (const [field, expected] of Object.entries(expect)) {
600
+ const actual = data[field];
601
+ if (expected !== undefined && String(actual) !== String(expected))
602
+ mismatches.push({ field, expected, actual });
603
+ }
604
+ return { ok: mismatches.length === 0, mismatches };
605
+ }
606
+ function filterOf(filter) {
607
+ const entries = Object.entries(filter);
608
+ if (entries.length === 0)
609
+ return {};
610
+ if (typeof filter.op === "string")
611
+ return { filters: [filter] };
612
+ return { filters: entries.map(([field, value]) => ({ op: "eq", field, value })) };
613
+ }
614
+ /** `omc programming test` — fixture-driven end-to-end verification. */
615
+ export async function cmdProgrammingTest(options) {
616
+ const paths = workspacePaths(options.cwd);
617
+ const manifest = readManifest(paths);
618
+ if (!manifest)
619
+ return { status: "refused", blocker: "programming-workspace-absent", message: "本地没有 .omc-programming/。先 omc programming pull --env <env> --app <appCode>。" };
620
+ const context = options.ctx ?? makeToolContext({ sessions: new SessionManager(new ConfigStore(defaultPaths())) });
621
+ const session = await context.sessions.session(options.environment);
622
+ try {
623
+ assertManifestEnvironment(manifest, session.environmentName, session.profile.baseUrl);
624
+ }
625
+ catch (error) {
626
+ return { status: "refused", blocker: error.blocker ?? "programming-workspace-environment-mismatch", message: error.message };
627
+ }
628
+ // Resolve target rule → source file + fixture.
629
+ let schemaCode = options.schemaCode;
630
+ let ruleCode = options.ruleCode;
631
+ if ((!schemaCode || !ruleCode) && options.file) {
632
+ const absolute = options.file.startsWith("/") ? options.file : join(paths.root, options.file);
633
+ const rel = relative(paths.sourcesDir, absolute);
634
+ const [schema, file] = rel.split("/");
635
+ schemaCode = schemaCode ?? schema;
636
+ ruleCode = ruleCode ?? file?.replace(/\.java$/, "");
637
+ }
638
+ if (!schemaCode || !ruleCode)
639
+ return { status: "refused", blocker: "programming-test-target-required", message: "指定被测规则:--schema <code> --rule <code>,或 --file .omc-programming/sources/<schema>/<rule>.java。" };
640
+ const sourceFile = sourceFilePath(paths, schemaCode, ruleCode);
641
+ if (!existsSync(sourceFile))
642
+ return { status: "refused", blocker: "programming-test-source-absent", message: `本地没有源码文件 ${relative(paths.root, sourceFile)}——先 pull 或 adopt。` };
643
+ const content = readFileSync(sourceFile, "utf8");
644
+ const fixtureFile = options.fixture ?? fixtureFilePath(paths, schemaCode, ruleCode);
645
+ if (!existsSync(fixtureFile))
646
+ return { status: "refused", blocker: "programming-test-fixture-absent", message: `没有测试 fixture:${fixtureFile}。创建 trigger/asserts/cleanup 三段后重试。` };
647
+ let fixture;
648
+ try {
649
+ fixture = parseFixture(readFileSync(fixtureFile, "utf8"));
650
+ }
651
+ catch (error) {
652
+ return { status: "refused", blocker: "programming-test-fixture-invalid", message: `fixture 解析失败:${error.message}` };
653
+ }
654
+ const trigger = (fixture.trigger ?? {});
655
+ const declaredAsserts = fixture.asserts;
656
+ if (declaredAsserts !== undefined && !Array.isArray(declaredAsserts)) {
657
+ return { status: "refused", blocker: "programming-test-fixture-asserts-object", message: "fixture.asserts 必须是列表(`asserts:` 下用 `- data: {...}`),对象形状无法逐条断言。" };
658
+ }
659
+ if (Array.isArray(declaredAsserts) && declaredAsserts.length === 0) {
660
+ return { status: "refused", blocker: "programming-test-fixture-asserts-empty", message: "fixture.asserts 为空:没有断言的测试只证明「调用没抛异常」,请至少声明一条 data 断言。" };
661
+ }
662
+ const action = String(trigger.action ?? "create");
663
+ const report = { environment: session.environmentName, schemaCode, ruleCode, action };
664
+ // 1. Static gate.
665
+ const compiled = await callTool("programming.compile", { environment: session.environmentName, schemaCode, businessRuleCode: ruleCode, content }, context);
666
+ report.compile = { success: compiled.success === true, ...(compiled.success === true ? {} : { errors: compiled.errors }) };
667
+ if (compiled.success !== true)
668
+ return { status: "refused", blocker: "programming-compile-failed", message: "编译失败,测试短路。", ...report };
669
+ // 2. Publish (skip when this exact content is already what we published).
670
+ const key = ruleKey(schemaCode, ruleCode);
671
+ const digest = sha256(content);
672
+ const alreadyPublished = manifest.published[key]?.sha256 === digest && manifest.published[key]?.environment === session.environmentName;
673
+ if (options.forcePublish === true || !alreadyPublished) {
674
+ const published = await callTool("programming.publish", { environment: session.environmentName, schemaCode, businessRuleCode: ruleCode, content }, context);
675
+ report.publish = { status: published.status, ...(published.status === "ok" ? { developmentMode: published.readback?.developmentMode ?? null } : { blocker: published.blocker, message: published.message }) };
676
+ if (published.status !== "ok")
677
+ return { status: "refused", blocker: String(published.blocker ?? "programming-publish-failed"), message: String(published.message ?? "发布失败。"), ...report };
678
+ manifest.published[key] = { sha256: digest, atIso: new Date().toISOString(), environment: session.environmentName };
679
+ writeManifest(paths, manifest);
680
+ }
681
+ else {
682
+ report.publish = { status: "ok", reused: true, note: "本地记录该内容已是最近发布版;--force-publish 强制重发。" };
683
+ }
684
+ // 3. Trigger.
685
+ const data = (trigger.data ?? {});
686
+ const seededIds = [];
687
+ let triggerOutcome;
688
+ try {
689
+ switch (action) {
690
+ case "create": {
691
+ triggerOutcome = await callTool("data.save", { environment: session.environmentName, schemaCode, formData: data }, context);
692
+ const performed = (triggerOutcome.performed ?? {});
693
+ const readback = (triggerOutcome.readback ?? {});
694
+ const objectId = String(performed.bizObjectId ?? performed.objectId ?? readback.objectId ?? readback.bizObjectId ?? "");
695
+ if (objectId)
696
+ seededIds.push(objectId);
697
+ break;
698
+ }
699
+ case "update": {
700
+ const id = String(data.id ?? "");
701
+ if (!id)
702
+ return { status: "refused", blocker: "programming-test-update-id-required", message: "update 触发需要在 fixture.trigger.data.id 指定既有业务数据 id。", ...report };
703
+ seededIds.push(id);
704
+ triggerOutcome = await callTool("data.save", { environment: session.environmentName, schemaCode, formData: data }, context);
705
+ break;
706
+ }
707
+ case "delete": {
708
+ const id = String(data.id ?? "");
709
+ if (!id)
710
+ return { status: "refused", blocker: "programming-test-delete-id-required", message: "delete 触发需要在 fixture.trigger.data.id 指定业务数据 id。", ...report };
711
+ seededIds.push(id);
712
+ triggerOutcome = await callTool("data.delete", { environment: session.environmentName, schemaCode, bizObjectId: id }, context);
713
+ break;
714
+ }
715
+ case "load": {
716
+ const id = String(data.id ?? "");
717
+ if (!id)
718
+ return { status: "refused", blocker: "programming-test-load-id-required", message: "load 触发需要在 fixture.trigger.data.id 指定业务数据 id。", ...report };
719
+ triggerOutcome = await callTool("data.load", { environment: session.environmentName, schemaCode, bizObjectId: id }, context);
720
+ break;
721
+ }
722
+ case "custom": {
723
+ const ids = Array.isArray(trigger.ids) ? trigger.ids.map(String) : seededIds;
724
+ if (ids.length === 0)
725
+ return { status: "refused", blocker: "programming-test-custom-ids-required", message: "custom 触发需要在 fixture.trigger.ids 指定要执行规则的业务数据 id。", ...report };
726
+ triggerOutcome = await callTool("rule.run", { environment: session.environmentName, schemaCode, ruleCode, ids }, context);
727
+ break;
728
+ }
729
+ default:
730
+ return { status: "refused", blocker: "programming-test-trigger-unknown", message: `不支持的 trigger.action: ${action}(create|update|delete|load|custom)`, ...report };
731
+ }
732
+ }
733
+ catch (error) {
734
+ return { status: "refused", blocker: "programming-test-trigger-threw", message: error.message, ...report };
735
+ }
736
+ // The coded rule runs INLINE: the trigger response is the primary success
737
+ // signal (coded rules write no RuleLog — see recon §7.6).
738
+ report.trigger = { tool: action === "custom" ? "rule.run" : `data.${action === "create" || action === "update" ? "save" : action}`, status: triggerOutcome.status, ...(triggerOutcome.status !== "ok" ? { blocker: triggerOutcome.blocker, message: triggerOutcome.message, errcode: triggerOutcome.errcode } : {}) };
739
+ if (triggerOutcome.status !== "ok") {
740
+ return { status: "refused", blocker: `programming-test-trigger-${String(triggerOutcome.blocker ?? triggerOutcome.status)}`, message: `规则/触发执行失败:${String(triggerOutcome.message ?? "")}`, ...report };
741
+ }
742
+ // 4. Assertions.
743
+ const asserts = Array.isArray(fixture.asserts ?? undefined) ? fixture.asserts : [];
744
+ const assertReports = [];
745
+ let assertsFailed = 0;
746
+ for (const entry of asserts) {
747
+ const spec = entry;
748
+ const dataSpec = (spec.data ?? {});
749
+ const targetSchema = String(dataSpec.schema ?? schemaCode);
750
+ const filter = (dataSpec.filter ?? {});
751
+ const expect = (dataSpec.expect ?? {});
752
+ const listed = await callTool("data.list", {
753
+ environment: session.environmentName,
754
+ schemaCode: targetSchema,
755
+ ...filterOf(filter),
756
+ displayFields: Object.keys(expect),
757
+ }, context);
758
+ const rows = (listed.rows ?? listed.items ?? []);
759
+ const matched = rows.filter((row) => matchExpectation(row, expect).ok);
760
+ const ok = matched.length > 0;
761
+ if (!ok)
762
+ assertsFailed += 1;
763
+ assertReports.push({ schema: targetSchema, filter, expect, rows: rows.length, ok, ...(ok ? {} : { sample: rows.slice(0, 3) }) });
764
+ }
765
+ report.asserts = assertReports;
766
+ // 5. Cleanup — only rows the fixture names (seed filter), never a blanket key.
767
+ const cleanups = Array.isArray(fixture.cleanup ?? undefined) ? fixture.cleanup : [];
768
+ const cleanupReports = [];
769
+ for (const entry of cleanups) {
770
+ const spec = (entry.delete ?? {});
771
+ const targetSchema = String(spec.schema ?? schemaCode);
772
+ const filter = (spec.filter ?? {});
773
+ const listed = await callTool("data.list", { environment: session.environmentName, schemaCode: targetSchema, ...filterOf(filter), displayFields: ["name"] }, context);
774
+ const rows = (listed.rows ?? listed.items ?? []);
775
+ const ids = rows.map((row) => String(row.bizObjectId ?? row.id ?? "")).filter((id) => id !== "");
776
+ let deleted = 0;
777
+ for (const id of ids) {
778
+ const outcome = await callTool("data.delete", { environment: session.environmentName, schemaCode: targetSchema, bizObjectId: id }, context);
779
+ if (outcome.status === "ok")
780
+ deleted += 1;
781
+ }
782
+ cleanupReports.push({ schema: targetSchema, filter, matched: ids.length, deleted });
783
+ }
784
+ report.cleanup = cleanupReports;
785
+ const verdict = assertsFailed === 0 ? "pass" : "fail";
786
+ return {
787
+ status: "ok",
788
+ verdict,
789
+ ...report,
790
+ ...(verdict === "fail" ? { hint: "断言未命中:核对 fixture.expect 与规则实际写入;规则执行失败会在 trigger 段返回 Java errmsg。" } : {}),
791
+ };
792
+ }
793
+ //# sourceMappingURL=programming-workflow.js.map