@stalfh233/omc-cli 0.0.0-stage → 0.4.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +293 -3
- package/coverage/m1-coverage-manifest-v1.json +1278 -0
- package/dist/approval-token.js +102 -0
- package/dist/args.js +25 -0
- package/dist/artifacts.js +119 -0
- package/dist/bench/call-face-eval.js +256 -0
- package/dist/bench/context-attribution.js +151 -0
- package/dist/bench/discovery-cost-eval.js +230 -0
- package/dist/bench/driver.js +81 -0
- package/dist/bench/evals.js +236 -0
- package/dist/bench/fake-http-server.js +65 -0
- package/dist/bench/instrument.js +87 -0
- package/dist/bench/intent-face-eval.js +343 -0
- package/dist/bench/run.js +166 -0
- package/dist/bench/scenario.js +343 -0
- package/dist/bench/types.js +76 -0
- package/dist/bi-wire.js +41 -0
- package/dist/bizservice-config.js +581 -0
- package/dist/call.js +153 -0
- package/dist/capability-absences.js +23 -0
- package/dist/capability-overview.js +492 -0
- package/dist/capability-shape.js +154 -0
- package/dist/cli-contract.js +70 -0
- package/dist/cli-output.js +73 -0
- package/dist/cli.js +1418 -0
- package/dist/code-rules.js +69 -0
- package/dist/command-transport.js +155 -0
- package/dist/config-store.js +195 -0
- package/dist/context.js +21 -0
- package/dist/contract-consistency.js +66 -0
- package/dist/contract-resources.js +62 -0
- package/dist/coverage-consistency.js +62 -0
- package/dist/coverage-registry.js +76 -0
- package/dist/coverage.js +130 -0
- package/dist/data-list-filter.js +114 -0
- package/dist/discovery.js +390 -0
- package/dist/endpoints.js +154 -0
- package/dist/environment-policy.js +26 -0
- package/dist/execution-metadata.js +1092 -0
- package/dist/fake/app.js +45 -0
- package/dist/fake/b2-registration.js +594 -0
- package/dist/fake/businessrule.js +242 -0
- package/dist/fake/datarule.js +72 -0
- package/dist/fake/dictionary.js +108 -0
- package/dist/fake/environment.js +30 -0
- package/dist/fake/field.js +167 -0
- package/dist/fake/form.js +101 -0
- package/dist/fake/index.js +121 -0
- package/dist/fake/list-view.js +289 -0
- package/dist/fake/model.js +136 -0
- package/dist/fake/online-js.js +18 -0
- package/dist/fake/report.js +253 -0
- package/dist/fake/routes.js +47 -0
- package/dist/fake/rule-lifecycle.js +37 -0
- package/dist/fake/runtime-data.js +367 -0
- package/dist/fake/state.js +67 -0
- package/dist/fake/workflow.js +364 -0
- package/dist/field-change.js +200 -0
- package/dist/field-families.js +896 -0
- package/dist/form-layout.js +111 -0
- package/dist/form-support.js +821 -0
- package/dist/goal-routes.js +468 -0
- package/dist/governed-execution.js +87 -0
- package/dist/human-summary.js +212 -0
- package/dist/identity.js +62 -0
- package/dist/intent/baseline.js +57 -0
- package/dist/intent/capabilities/bizservice.js +274 -0
- package/dist/intent/capabilities/businessrule.js +493 -0
- package/dist/intent/capabilities/datarule.js +187 -0
- package/dist/intent/capabilities/field.js +545 -0
- package/dist/intent/capabilities/form.js +136 -0
- package/dist/intent/capabilities/index.js +64 -0
- package/dist/intent/capabilities/listview.js +157 -0
- package/dist/intent/capabilities/model.js +100 -0
- package/dist/intent/capabilities/onlinejs.js +108 -0
- package/dist/intent/capabilities/report.js +355 -0
- package/dist/intent/capabilities/workflow.js +458 -0
- package/dist/intent/capability.js +6 -0
- package/dist/intent/cli.js +91 -0
- package/dist/intent/compare.js +56 -0
- package/dist/intent/compiler.js +79 -0
- package/dist/intent/dsl.js +129 -0
- package/dist/intent/plan-file.js +63 -0
- package/dist/intent/readback.js +65 -0
- package/dist/intent/schema.js +158 -0
- package/dist/intent/validation.js +30 -0
- package/dist/intent/yaml.js +315 -0
- package/dist/json-column.js +68 -0
- package/dist/lanes/app-contract.js +95 -0
- package/dist/lanes/app-coverage.js +16 -0
- package/dist/lanes/app.js +174 -0
- package/dist/lanes/apply-changes.js +231 -0
- package/dist/lanes/b2-registration-contract.js +292 -0
- package/dist/lanes/b2-registration-coverage.js +48 -0
- package/dist/lanes/b2-registration.js +1187 -0
- package/dist/lanes/businessrule-contract.js +232 -0
- package/dist/lanes/businessrule-coverage.js +16 -0
- package/dist/lanes/businessrule.js +221 -0
- package/dist/lanes/contract-support.js +65 -0
- package/dist/lanes/coverage-declaration.js +9 -0
- package/dist/lanes/datarule-contract.js +155 -0
- package/dist/lanes/datarule-coverage.js +19 -0
- package/dist/lanes/datarule-protocol.js +308 -0
- package/dist/lanes/datarule.js +818 -0
- package/dist/lanes/dictionary-contract.js +78 -0
- package/dist/lanes/dictionary-coverage.js +24 -0
- package/dist/lanes/dictionary.js +235 -0
- package/dist/lanes/environment-contract.js +70 -0
- package/dist/lanes/environment-coverage.js +14 -0
- package/dist/lanes/environment.js +163 -0
- package/dist/lanes/field-contract.js +223 -0
- package/dist/lanes/field-coverage.js +27 -0
- package/dist/lanes/field.js +374 -0
- package/dist/lanes/form-contract.js +97 -0
- package/dist/lanes/form-coverage.js +16 -0
- package/dist/lanes/form.js +185 -0
- package/dist/lanes/lane-ids.js +34 -0
- package/dist/lanes/list-view-contract.js +175 -0
- package/dist/lanes/list-view-coverage.js +20 -0
- package/dist/lanes/list-view-shapes.js +1207 -0
- package/dist/lanes/list-view.js +578 -0
- package/dist/lanes/meta-contract.js +85 -0
- package/dist/lanes/meta.js +255 -0
- package/dist/lanes/model-contract.js +168 -0
- package/dist/lanes/model-coverage.js +20 -0
- package/dist/lanes/model.js +986 -0
- package/dist/lanes/online-js-contract.js +99 -0
- package/dist/lanes/online-js-coverage.js +28 -0
- package/dist/lanes/online-js.js +127 -0
- package/dist/lanes/report-contract.js +144 -0
- package/dist/lanes/report-coverage.js +21 -0
- package/dist/lanes/report.js +476 -0
- package/dist/lanes/rule-graph.js +1846 -0
- package/dist/lanes/rule-lifecycle-contract.js +92 -0
- package/dist/lanes/rule-lifecycle-coverage.js +20 -0
- package/dist/lanes/rule-lifecycle.js +176 -0
- package/dist/lanes/runtime-data-contract.js +264 -0
- package/dist/lanes/runtime-data-coverage.js +25 -0
- package/dist/lanes/runtime-data.js +1054 -0
- package/dist/lanes/workflow-contract.js +223 -0
- package/dist/lanes/workflow-coverage.js +40 -0
- package/dist/lanes/workflow.js +1813 -0
- package/dist/online-js-layout.js +58 -0
- package/dist/online-js-source.js +276 -0
- package/dist/package-tool.js +51 -0
- package/dist/package.js +73 -0
- package/dist/plan.js +73 -0
- package/dist/read.js +144 -0
- package/dist/redact.js +28 -0
- package/dist/rule-support.js +587 -0
- package/dist/runtime-support.js +134 -0
- package/dist/server.js +92 -0
- package/dist/session-manager.js +30 -0
- package/dist/session.js +149 -0
- package/dist/skills.js +112 -0
- package/dist/support.js +98 -0
- package/dist/tool-types.js +127 -0
- package/dist/tools.js +59 -0
- package/dist/usage-log.js +197 -0
- package/dist/wire.js +287 -0
- package/dist/workflow-support.js +99 -0
- package/dist/write-lease.js +26 -0
- package/dist/write-lock.js +109 -0
- package/dist/write.js +176 -0
- package/dist/zip.js +156 -0
- package/docs/tool-surface-map.md +42 -0
- package/package.json +71 -6
- package/skills/omc-acceptance-criteria.md +47 -0
- package/skills/omc-business-configuration.md +257 -0
- package/skills/omc-capabilities.md +197 -0
- package/skills/omc-capability-scouting.md +55 -0
- package/skills/omc-config-draft-review.md +171 -0
- package/skills/omc-five-piece-flow.md +35 -0
- package/skills/omc-glossary.md +86 -0
- package/skills/omc-refusals.md +110 -0
- package/skills/omc-requirement-analysis.md +161 -0
- package/skills/omc-requirement-vocabulary.md +51 -0
- package/skills/omc-start-here.md +82 -0
- package/skills/omc-tool-selection.md +119 -0
- package/skills/omc-write-hazards.md +87 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { ENDPOINTS } from "../../endpoints.js";
|
|
2
|
+
import { decodeJsonColumn, JSON_COLUMN_NULL_COALESCE } from "../../json-column.js";
|
|
3
|
+
import { wireCall, wireRows } from "../../wire.js";
|
|
4
|
+
import { countConditionLeaves, prepareRuleGraph, RULE_IDENTITY_FILTER, validateRuleNodeGraph } from "../../lanes/rule-graph.js";
|
|
5
|
+
import { canonicalJson } from "../dsl.js";
|
|
6
|
+
import { isScalarString, pushError } from "../validation.js";
|
|
7
|
+
import { toPlain } from "../yaml.js";
|
|
8
|
+
/** Keys a rule item accepts. */
|
|
9
|
+
const RULE_KEYS = new Set(["code", "name", "enabled", "bizRuleType", "customTrigger", "actions", "nodeGraph"]);
|
|
10
|
+
/** Keys a compact action accepts. */
|
|
11
|
+
const ACTION_KEYS = new Set(["type", "target", "filter", "when", "set"]);
|
|
12
|
+
/** Keys a compact filter/assignment item accepts. */
|
|
13
|
+
const LEAF_KEYS = new Set(["field", "from", "equals", "notEquals", "gt", "gte", "lt", "lte", "value"]);
|
|
14
|
+
/**
|
|
15
|
+
* Compact-condition operator keys → the platform's `ruleConditionType`. `equals`
|
|
16
|
+
* existed alone before round 14 (FIXED EQ); the comparison operators were added
|
|
17
|
+
* because a threshold rule (`amount > 5000`) is otherwise INEXPRESSIBLE on the
|
|
18
|
+
* compact face and forced callers into a hand-built `nodeGraph` (bench37: ~46 KB
|
|
19
|
+
* of validate/retry churn from exactly that). `from` stays the DYNAMIC (EQ) form.
|
|
20
|
+
*/
|
|
21
|
+
const CONDITION_OPERATORS = Object.freeze({
|
|
22
|
+
equals: "EQ",
|
|
23
|
+
notEquals: "NEQ",
|
|
24
|
+
gt: "GT",
|
|
25
|
+
gte: "GTEQ",
|
|
26
|
+
lt: "LT",
|
|
27
|
+
lte: "LTEQ",
|
|
28
|
+
});
|
|
29
|
+
/** Geometry/display keys ignored when comparing a synthesized graph to the live one. */
|
|
30
|
+
const GEOMETRY_KEYS = new Set(["x", "y", "width", "height", "points", "originHeight", "originWidth", "edit"]);
|
|
31
|
+
function stripGeometry(value) {
|
|
32
|
+
if (Array.isArray(value))
|
|
33
|
+
return value.map(stripGeometry);
|
|
34
|
+
if (value && typeof value === "object") {
|
|
35
|
+
const out = {};
|
|
36
|
+
for (const [key, entry] of Object.entries(value))
|
|
37
|
+
if (!GEOMETRY_KEYS.has(key))
|
|
38
|
+
out[key] = stripGeometry(entry);
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Canonicalize a route for digesting: strip geometry and apply the same
|
|
45
|
+
* defaults `ensureRouteShape` applies to the wire (`routeCondition` absent ->
|
|
46
|
+
* true, `matchType` absent/empty -> "ANY"), so an equivalent live route that
|
|
47
|
+
* omits those keys is not reported as drift. An explicit `false`/custom value
|
|
48
|
+
* is preserved.
|
|
49
|
+
*/
|
|
50
|
+
function canonicalRoute(route) {
|
|
51
|
+
const stripped = stripGeometry(route);
|
|
52
|
+
if (!stripped || typeof stripped !== "object" || Array.isArray(stripped))
|
|
53
|
+
return stripped;
|
|
54
|
+
const out = { ...stripped };
|
|
55
|
+
if (out.routeCondition === undefined || out.routeCondition === null)
|
|
56
|
+
out.routeCondition = true;
|
|
57
|
+
if (out.matchType === undefined || out.matchType === null || String(out.matchType).trim() === "")
|
|
58
|
+
out.matchType = "ANY";
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Semantic digest of a rule graph: geometry/display-only keys removed, nodes
|
|
63
|
+
* sorted by nodeCode, routes defaulted and sorted by their canonical form.
|
|
64
|
+
* Stable across the platform's cosmetic normalization (layout, route ordering,
|
|
65
|
+
* omitted route defaults), so readback compares intent, not canvas pixels.
|
|
66
|
+
*/
|
|
67
|
+
export function ruleGraphDigest(nodes, routes) {
|
|
68
|
+
const nodeList = Array.isArray(nodes)
|
|
69
|
+
? [...nodes].sort((a, b) => String(a?.nodeCode ?? "").localeCompare(String(b?.nodeCode ?? "")))
|
|
70
|
+
: [];
|
|
71
|
+
const routeList = (Array.isArray(routes) ? routes : []).map(canonicalRoute).sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b)));
|
|
72
|
+
return canonicalJson({ nodes: stripGeometry(nodeList) ?? [], routes: routeList });
|
|
73
|
+
}
|
|
74
|
+
function conditionLeaf(condition) {
|
|
75
|
+
const raw = condition;
|
|
76
|
+
if (condition.from !== undefined)
|
|
77
|
+
return { targetSchemaDataItem: condition.field, ruleConditionType: "EQ", ruleValueType: "DYNAMIC", currentSchemaDataItem: condition.from };
|
|
78
|
+
const operator = Object.keys(CONDITION_OPERATORS).find((key) => raw[key] !== undefined) ?? "equals";
|
|
79
|
+
return { targetSchemaDataItem: condition.field, ruleConditionType: CONDITION_OPERATORS[operator], ruleValueType: "FIXED", value: raw[operator] ?? null };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* A `when` leaf compiles to the node's EXECUTION GATE (`dataCondition`), whose
|
|
83
|
+
* leaves use the platform's `Condition`/`DataFilterCondition` shape: the left
|
|
84
|
+
* value is `currentSchemaDataItem` (the input/current-data field) and the
|
|
85
|
+
* operator is `ruleDataConditionType` — NOT `filterCondition`'s
|
|
86
|
+
* `targetSchemaDataItem` + `ruleConditionType`. Verified against 8.6.26
|
|
87
|
+
* `DataCondition.java` (conditionGroup: List<List<DataFilterCondition>>),
|
|
88
|
+
* `DataFilterCondition.java` (ruleDataConditionType) and `Condition.java`
|
|
89
|
+
* (currentSchemaDataItem / ruleValueType / value).
|
|
90
|
+
*/
|
|
91
|
+
function gateConditionLeaf(condition) {
|
|
92
|
+
const raw = condition;
|
|
93
|
+
const operator = Object.keys(CONDITION_OPERATORS).find((key) => raw[key] !== undefined) ?? "equals";
|
|
94
|
+
return { currentSchemaDataItem: condition.field, ruleDataConditionType: CONDITION_OPERATORS[operator], ruleValueType: "FIXED", value: raw[operator] ?? null };
|
|
95
|
+
}
|
|
96
|
+
function assignment(action) {
|
|
97
|
+
if (action.from !== undefined)
|
|
98
|
+
return { targetDataItem: action.field, ruleValueType: "DYNAMIC", value: "", ruleActionType: "EQUALS", currentDataItemValue: action.from };
|
|
99
|
+
return { targetDataItem: action.field, ruleValueType: "FIXED", value: action.value ?? null, ruleActionType: "EQUALS" };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Synthesize the raw node graph from compact actions: a linear
|
|
103
|
+
* Start → UPDATE_1..N → End chain. The platform's own normalization
|
|
104
|
+
* (prepareRuleGraph: preset system node / custom trigger / layout / route
|
|
105
|
+
* geometry) is applied on top, exactly as `rule.save` will apply it again.
|
|
106
|
+
*/
|
|
107
|
+
function graphFromActions(actions, modelCode) {
|
|
108
|
+
const nodes = [{ nodeCode: "Start", nodeName: "开始", nodeType: "START" }];
|
|
109
|
+
actions.forEach((action, index) => {
|
|
110
|
+
const filter = action.filter ?? [];
|
|
111
|
+
const when = action.when ?? [];
|
|
112
|
+
// `filter` is the row LOCATOR (filterCondition). An omitted filter on a
|
|
113
|
+
// SAME-MODEL action defaults to the self-identity match (`id = id`) — the
|
|
114
|
+
// "update the current record" intent. A cross-model action must name its
|
|
115
|
+
// locator explicitly; an empty one is refused fail-closed by lintRuleGraph
|
|
116
|
+
// (empty filterCondition = unconditional whole-target-table update).
|
|
117
|
+
const filterCondition = filter.length > 0
|
|
118
|
+
? { mainConditions: [filter.map(conditionLeaf)], sheetConditions: [] }
|
|
119
|
+
: action.target === modelCode
|
|
120
|
+
? RULE_IDENTITY_FILTER
|
|
121
|
+
: { mainConditions: [], sheetConditions: [] };
|
|
122
|
+
nodes.push({
|
|
123
|
+
nodeCode: `Update_${index + 1}`,
|
|
124
|
+
nodeName: "数据更新",
|
|
125
|
+
nodeType: "UPDATE",
|
|
126
|
+
targetObjectCode: action.target,
|
|
127
|
+
inputParam: "_input",
|
|
128
|
+
updateNodeType: "UPDATE_MODEL",
|
|
129
|
+
execFormMapping: false,
|
|
130
|
+
// `when` is the node EXECUTION GATE (`dataCondition`): the node runs only
|
|
131
|
+
// when the input data satisfies it. Distinct from `filter` (which rows).
|
|
132
|
+
...(when.length > 0 ? { dataCondition: { conditionGroup: [when.map(gateConditionLeaf)] } } : {}),
|
|
133
|
+
filterCondition,
|
|
134
|
+
dataActions: action.set.map(assignment),
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
nodes.push({ nodeCode: "End", nodeName: "结束", nodeType: "END" });
|
|
138
|
+
const routes = nodes.slice(0, -1).map((node, index) => ({ preNode: String(node.nodeCode), postNode: String(nodes[index + 1].nodeCode) }));
|
|
139
|
+
return { nodes, routes };
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The graph the compiler expects on the platform for a declared rule.
|
|
143
|
+
* `modelCode` (the declaring model) lets a compact same-model action default its
|
|
144
|
+
* locator to self-identity; it is optional because a raw `nodeGraph` needs no
|
|
145
|
+
* synthesis (and callers that only compare digests still pass it).
|
|
146
|
+
*/
|
|
147
|
+
export function expectedRuleGraph(rule, modelCode = "") {
|
|
148
|
+
const raw = rule.nodeGraph ?? graphFromActions(rule.actions ?? [], modelCode);
|
|
149
|
+
return prepareRuleGraph(rule.code, { ...raw, ...(rule.bizRuleType !== undefined ? { bizRuleType: rule.bizRuleType } : {}) }, rule.customTrigger, modelCode || undefined).graph;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Offline dry-lint of a declared rule's PREPARED graph. Runs the exact validator
|
|
153
|
+
* `rule.save` runs at apply time, so `intent validate`/`plan` fail with the same
|
|
154
|
+
* blockers before any approval or wire (2026-09-27: a CREATE node missing
|
|
155
|
+
* `originatorValueType` passed plan and only failed at apply). Pure — no
|
|
156
|
+
* environment contact. Shape errors are already reported by the readers, so a
|
|
157
|
+
* prepare throw is swallowed rather than double-reported.
|
|
158
|
+
*/
|
|
159
|
+
function lintRuleGraph(rule, ctx, base, schemaCode) {
|
|
160
|
+
try {
|
|
161
|
+
const graph = expectedRuleGraph(rule, schemaCode);
|
|
162
|
+
for (const blocker of validateRuleNodeGraph(graph, rule.code, schemaCode)) {
|
|
163
|
+
// Warning-severity findings (e.g. cross-model-unverified, defaulted keys)
|
|
164
|
+
// ride the payload but never gate — same rule the tool preflight applies.
|
|
165
|
+
if (blocker.severity === "warning")
|
|
166
|
+
continue;
|
|
167
|
+
pushError(ctx, blocker.code, blocker.message, `${base}.${rule.nodeGraph ? "nodeGraph" : "actions"}`, "value");
|
|
168
|
+
}
|
|
169
|
+
// Cross-model UPDATE with an empty filter is fail-closed on the declarative
|
|
170
|
+
// face: the platform semantic is "find the target rows matching these
|
|
171
|
+
// conditions" (empty ⇒ update the WHOLE target table). The tool's own lint
|
|
172
|
+
// rates this a warning (legal at the wire), but for a target model OTHER than
|
|
173
|
+
// the rule's own, an unconditional update is almost certainly the "write back
|
|
174
|
+
// to the related row" intent with the locate-filter forgotten — bench52/53
|
|
175
|
+
// saved+publish passed and the runtime silently wrote nothing, costing a full
|
|
176
|
+
// end-to-end cycle each. Make the declarative face require the filter.
|
|
177
|
+
for (const raw of graph.nodes ?? []) {
|
|
178
|
+
if (String(raw.nodeType ?? "") !== "UPDATE")
|
|
179
|
+
continue;
|
|
180
|
+
const target = typeof raw.targetObjectCode === "string" ? raw.targetObjectCode : "";
|
|
181
|
+
const crossModel = target.length > 0 && target !== schemaCode && !target.startsWith(`${schemaCode}.`);
|
|
182
|
+
if (crossModel && countConditionLeaves(raw.filterCondition) === 0)
|
|
183
|
+
pushError(ctx, "intent-rule-cross-model-filter-required", `跨模型 UPDATE 节点 ${String(raw.nodeCode ?? "")}(目标 ${target})的 filterCondition 为空——空条件=无条件下更新目标模型整表。回写关联行的规则必须给出定位条件,例如 filter: [{field: id, from: <本模型保存目标 id 的字段>}]`, `${base}.${rule.nodeGraph ? "nodeGraph" : "actions"}`, "value");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
/* shape errors already reported */
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** The exact `rule.save` args for a declared rule (nodeGraph is the prepared graph). */
|
|
191
|
+
function ruleSaveArgs(schemaCode, rule) {
|
|
192
|
+
return {
|
|
193
|
+
schemaCode,
|
|
194
|
+
ruleCode: rule.code,
|
|
195
|
+
name: rule.name,
|
|
196
|
+
nodeGraph: expectedRuleGraph(rule, schemaCode),
|
|
197
|
+
...(rule.customTrigger !== undefined ? { customTrigger: rule.customTrigger } : {}),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Read a compact condition/assignment leaf off a YAML map.
|
|
202
|
+
*
|
|
203
|
+
* Fail-closed (round 14): before, a leaf with `field` but no `from`/`equals`
|
|
204
|
+
* silently became `{..., value: null}` and the platform normalized it to
|
|
205
|
+
* "field EQ null" — a rule that saves + publishes fine but never matches
|
|
206
|
+
* (bench37: an agent wrote a threshold intent, got `EQ null`, and burned ~46 KB
|
|
207
|
+
* of validate/apply retries chasing it). A condition now needs EXACTLY ONE of
|
|
208
|
+
* `from` (DYNAMIC EQ) or an operator key; an assignment needs `from` XOR `value`.
|
|
209
|
+
* Zero or ambiguous sources are a semantic error, never a silent null.
|
|
210
|
+
*/
|
|
211
|
+
function readLeaf(leaf, base, ctx, kind) {
|
|
212
|
+
for (const entry of leaf.entries)
|
|
213
|
+
if (!LEAF_KEYS.has(entry.key))
|
|
214
|
+
pushError(ctx, "intent-key-unknown", `${kind} 不支持键 "${entry.key}"(条件接受 field / from / equals / notEquals / gt / gte / lt / lte;赋值接受 field / from / value)`, `${base}.${entry.key}`);
|
|
215
|
+
const fieldNode = leaf.entries.find((entry) => entry.key === "field")?.value;
|
|
216
|
+
const fromNode = leaf.entries.find((entry) => entry.key === "from")?.value;
|
|
217
|
+
if (!isScalarString(fieldNode))
|
|
218
|
+
pushError(ctx, "intent-rule-leaf-field-invalid", `${kind} 缺 field(非空字符串)`, `${base}.field`, "value");
|
|
219
|
+
if (fromNode !== undefined && !isScalarString(fromNode))
|
|
220
|
+
pushError(ctx, "intent-rule-leaf-from-invalid", `${kind}.from 必须是非空字符串(触发字段 code)`, `${base}.from`, "value");
|
|
221
|
+
const literalKeys = kind === "condition" ? Object.keys(CONDITION_OPERATORS) : ["value"];
|
|
222
|
+
const present = literalKeys.filter((key) => leaf.entries.some((entry) => entry.key === key));
|
|
223
|
+
for (const key of present) {
|
|
224
|
+
const node = leaf.entries.find((entry) => entry.key === key).value;
|
|
225
|
+
const value = node.kind === "scalar" ? node.value : undefined;
|
|
226
|
+
if (value !== undefined && value !== null && typeof value === "object")
|
|
227
|
+
pushError(ctx, "intent-rule-leaf-literal-invalid", `${kind}.${key} 必须是标量`, `${base}.${key}`, "value");
|
|
228
|
+
}
|
|
229
|
+
const sources = present.length + (fromNode !== undefined ? 1 : 0);
|
|
230
|
+
if (sources === 0)
|
|
231
|
+
pushError(ctx, "intent-rule-leaf-source-missing", kind === "condition"
|
|
232
|
+
? `filter 项缺条件来源:给 from: <字段>(DYNAMIC)或一个比较键(equals/notEquals/gt/gte/lt/lte: <值>)。空来源会被平台归一化成 "EQ null",规则永不命中`
|
|
233
|
+
: `set 项缺赋值来源:给 value: <值>(FIXED)或 from: <字段>(DYNAMIC)`, base, "value");
|
|
234
|
+
else if (sources > 1)
|
|
235
|
+
pushError(ctx, "intent-rule-leaf-source-ambiguous", `${kind} 只能有一个来源(from 或 ${literalKeys.join("/")} 之一),得到 ${sources} 个`, base, "value");
|
|
236
|
+
if (!isScalarString(fieldNode))
|
|
237
|
+
return null;
|
|
238
|
+
if (sources !== 1)
|
|
239
|
+
return { field: fieldNode.value.trim() };
|
|
240
|
+
if (fromNode !== undefined)
|
|
241
|
+
return { field: fieldNode.value.trim(), from: fromNode.value.trim() };
|
|
242
|
+
const operator = present[0];
|
|
243
|
+
const literalNode = leaf.entries.find((entry) => entry.key === operator).value;
|
|
244
|
+
const literal = literalNode.kind === "scalar" ? literalNode.value : null;
|
|
245
|
+
return { field: fieldNode.value.trim(), [operator]: literal };
|
|
246
|
+
}
|
|
247
|
+
function readActions(actionsNode, base, ctx, modelCode) {
|
|
248
|
+
if (actionsNode.kind !== "seq") {
|
|
249
|
+
pushError(ctx, "intent-rule-actions-not-a-list", `actions 必须是列表(得到 ${actionsNode.kind})`, `${base}.actions`, "value");
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
const actions = [];
|
|
253
|
+
actionsNode.items.forEach((actionNode, index) => {
|
|
254
|
+
const actionBase = `${base}.actions[${index}]`;
|
|
255
|
+
if (actionNode.kind !== "map") {
|
|
256
|
+
pushError(ctx, "intent-rule-action-not-a-map", "action 必须是映射", actionBase, "value");
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
for (const entry of actionNode.entries)
|
|
260
|
+
if (!ACTION_KEYS.has(entry.key))
|
|
261
|
+
pushError(ctx, "intent-key-unknown", `action 不支持键 "${entry.key}"(只接受 type / target / filter / when / set)`, `${actionBase}.${entry.key}`);
|
|
262
|
+
const typeNode = actionNode.entries.find((entry) => entry.key === "type")?.value;
|
|
263
|
+
const targetNode = actionNode.entries.find((entry) => entry.key === "target")?.value;
|
|
264
|
+
const filterNode = actionNode.entries.find((entry) => entry.key === "filter")?.value;
|
|
265
|
+
const whenNode = actionNode.entries.find((entry) => entry.key === "when")?.value;
|
|
266
|
+
const setNode = actionNode.entries.find((entry) => entry.key === "set")?.value;
|
|
267
|
+
const type = isScalarString(typeNode) ? typeNode.value.trim() : "";
|
|
268
|
+
if (type !== "UPDATE")
|
|
269
|
+
pushError(ctx, "intent-rule-action-type-unsupported", `action.type 目前只支持 "UPDATE"(得到 "${type}")。action.filter 是「定位要操作哪些数据」(filterCondition,改哪条),action.when 是「输入数据满足条件才执行」(dataCondition,要不要执行)——两者是两码事。非 UPDATE 节点(CREATE/DELETE/BIZ_ACTION/MESSAGE…)用 nodeGraph 透传。`, `${actionBase}.type`, "value");
|
|
270
|
+
if (!isScalarString(targetNode) && targetNode !== undefined && !(targetNode.kind === "scalar" && targetNode.value === null))
|
|
271
|
+
pushError(ctx, "intent-rule-action-target-invalid", `第 ${index + 1} 个动作的 target 必须是字符串(目标模型 code);同模型动作可省略 target,默认当前模型 ${modelCode}`, `${actionBase}.target`, "value");
|
|
272
|
+
// `target` defaults to the rule's OWN model: the common case is a same-model
|
|
273
|
+
// update, and forcing the code to be repeated is redundant friction (bench61
|
|
274
|
+
// feedback). Cross-model writes still name the other model explicitly.
|
|
275
|
+
const targetCode = isScalarString(targetNode) ? targetNode.value.trim() : modelCode;
|
|
276
|
+
const readList = (listNode, listKey) => {
|
|
277
|
+
if (listNode === undefined || (listNode.kind === "scalar" && listNode.value === null))
|
|
278
|
+
return [];
|
|
279
|
+
if (listNode.kind !== "seq") {
|
|
280
|
+
pushError(ctx, "intent-rule-action-list-invalid", `action.${listKey} 必须是列表`, `${actionBase}.${listKey}`, "value");
|
|
281
|
+
return [];
|
|
282
|
+
}
|
|
283
|
+
const rows = [];
|
|
284
|
+
listNode.items.forEach((item, itemIndex) => {
|
|
285
|
+
if (item.kind !== "map") {
|
|
286
|
+
pushError(ctx, "intent-rule-action-item-invalid", `action.${listKey} 项必须是映射`, `${actionBase}.${listKey}[${itemIndex}]`, "value");
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const row = readLeaf(item, `${actionBase}.${listKey}[${itemIndex}]`, ctx, listKey === "set" ? "assignment" : "condition");
|
|
290
|
+
if (row)
|
|
291
|
+
rows.push(row);
|
|
292
|
+
});
|
|
293
|
+
return rows;
|
|
294
|
+
};
|
|
295
|
+
const filter = readList(filterNode, "filter");
|
|
296
|
+
const when = readList(whenNode, "when");
|
|
297
|
+
const set = readList(setNode, "set");
|
|
298
|
+
// The gate (`when`) compiles to `dataCondition`, whose leaf has no DYNAMIC
|
|
299
|
+
// "compare against another field" form in this compact face (the platform's
|
|
300
|
+
// DataFilterCondition DYNAMIC value shape is not the same as filter's). Fail
|
|
301
|
+
// closed rather than emit a leaf the engine misreads; nodeGraph can carry it.
|
|
302
|
+
when.forEach((row, whenIndex) => {
|
|
303
|
+
if (row.from !== undefined)
|
|
304
|
+
pushError(ctx, "intent-rule-when-from-unsupported", `action.when 不支持 from(执行门只支持与字面量比较:equals/notEquals/gt/gte/lt/lte);需要与另一字段比较时用 nodeGraph 传 dataCondition`, `${actionBase}.when[${whenIndex}].from`, "value");
|
|
305
|
+
});
|
|
306
|
+
if (type === "UPDATE") {
|
|
307
|
+
// Never DROP a declared action. Before round 15, an action with an empty
|
|
308
|
+
// filter or set was silently discarded, so the rule compiled with no
|
|
309
|
+
// UPDATE node and validate/plan passed — bench52/53 lost a whole end-to-end
|
|
310
|
+
// cycle to a cross-model writeback whose filter was omitted and whose
|
|
311
|
+
// action therefore never existed. An empty `set` is always an error; an
|
|
312
|
+
// empty `filter` compiles and is gated by lintRuleGraph (cross-model).
|
|
313
|
+
if (set.length === 0)
|
|
314
|
+
pushError(ctx, "intent-rule-action-set-required", "action.set 不能为空(UPDATE 至少要有一个赋值;平台 UPDATE 节点必须有 dataActions)", `${actionBase}.set`, "value");
|
|
315
|
+
actions.push({
|
|
316
|
+
type: "UPDATE",
|
|
317
|
+
target: targetCode,
|
|
318
|
+
filter: filter,
|
|
319
|
+
...(whenNode !== undefined && !(whenNode.kind === "scalar" && whenNode.value === null) ? { when: when } : {}),
|
|
320
|
+
set: set,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
return actions;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* The business-rule capability. Rules are model-scoped and declared as either
|
|
328
|
+
* compact `actions` (the compiler synthesizes the nodeGraph) or a raw
|
|
329
|
+
* `nodeGraph` escape hatch. It requires the field capability because a rule
|
|
330
|
+
* binds declared fields. Liveness is `rule.enable` (publish + enable), never
|
|
331
|
+
* `rule.save` alone.
|
|
332
|
+
*/
|
|
333
|
+
export const businessruleCapability = {
|
|
334
|
+
id: "businessrule",
|
|
335
|
+
title: "业务规则",
|
|
336
|
+
requires: ["field"],
|
|
337
|
+
operations: ["save", "enable", "disable"],
|
|
338
|
+
validate(node, draft, ctx) {
|
|
339
|
+
const rulesNode = node.entries.find((entry) => entry.key === "rules")?.value;
|
|
340
|
+
if (rulesNode === undefined || (rulesNode.kind === "scalar" && rulesNode.value === null))
|
|
341
|
+
return;
|
|
342
|
+
if (rulesNode.kind !== "seq") {
|
|
343
|
+
pushError(ctx, "intent-rules-not-a-list", `rules 必须是列表(得到 ${rulesNode.kind})`, `${ctx.base}.rules`, "value");
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const seen = new Set();
|
|
347
|
+
rulesNode.items.forEach((ruleNode, index) => {
|
|
348
|
+
const base = `${ctx.base}.rules[${index}]`;
|
|
349
|
+
if (ruleNode.kind !== "map") {
|
|
350
|
+
pushError(ctx, "intent-rule-not-a-map", "规则项必须是映射", base, "value");
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
for (const entry of ruleNode.entries)
|
|
354
|
+
if (!RULE_KEYS.has(entry.key))
|
|
355
|
+
pushError(ctx, "intent-key-unknown", `规则不支持键 "${entry.key}"(只接受 code / name / enabled / actions / nodeGraph / bizRuleType / customTrigger)`, `${base}.${entry.key}`);
|
|
356
|
+
const codeNode = ruleNode.entries.find((entry) => entry.key === "code")?.value;
|
|
357
|
+
const nameNode = ruleNode.entries.find((entry) => entry.key === "name")?.value;
|
|
358
|
+
const enabledNode = ruleNode.entries.find((entry) => entry.key === "enabled")?.value;
|
|
359
|
+
const bizRuleTypeNode = ruleNode.entries.find((entry) => entry.key === "bizRuleType")?.value;
|
|
360
|
+
const customTriggerNode = ruleNode.entries.find((entry) => entry.key === "customTrigger")?.value;
|
|
361
|
+
const actionsNode = ruleNode.entries.find((entry) => entry.key === "actions")?.value;
|
|
362
|
+
const nodeGraphNode = ruleNode.entries.find((entry) => entry.key === "nodeGraph")?.value;
|
|
363
|
+
let code = "";
|
|
364
|
+
if (!isScalarString(codeNode))
|
|
365
|
+
pushError(ctx, "intent-rule-code-invalid", "规则 code 必须是非空字符串", `${base}.code`, "value");
|
|
366
|
+
else {
|
|
367
|
+
code = codeNode.value.trim();
|
|
368
|
+
if (seen.has(code))
|
|
369
|
+
pushError(ctx, "intent-rule-code-duplicate", `规则 code 在模型内重复:${code}`, `${base}.code`, "value");
|
|
370
|
+
seen.add(code);
|
|
371
|
+
}
|
|
372
|
+
if (!isScalarString(nameNode))
|
|
373
|
+
pushError(ctx, "intent-rule-name-invalid", "规则 name 必须是非空字符串", `${base}.name`, "value");
|
|
374
|
+
let enabled = false;
|
|
375
|
+
if (enabledNode !== undefined) {
|
|
376
|
+
if (enabledNode.kind !== "scalar" || typeof enabledNode.value !== "boolean")
|
|
377
|
+
pushError(ctx, "intent-rule-enabled-invalid", "rule.enabled 必须是布尔", `${base}.enabled`, "value");
|
|
378
|
+
else
|
|
379
|
+
enabled = enabledNode.value;
|
|
380
|
+
}
|
|
381
|
+
let bizRuleType;
|
|
382
|
+
if (bizRuleTypeNode !== undefined) {
|
|
383
|
+
if (bizRuleTypeNode.kind !== "scalar" || typeof bizRuleTypeNode.value !== "number")
|
|
384
|
+
pushError(ctx, "intent-rule-bizruletype-invalid", "rule.bizRuleType 必须是数字", `${base}.bizRuleType`, "value");
|
|
385
|
+
else
|
|
386
|
+
bizRuleType = bizRuleTypeNode.value;
|
|
387
|
+
}
|
|
388
|
+
let customTrigger;
|
|
389
|
+
if (customTriggerNode !== undefined) {
|
|
390
|
+
if (customTriggerNode.kind !== "scalar" || typeof customTriggerNode.value !== "boolean")
|
|
391
|
+
pushError(ctx, "intent-rule-customtrigger-invalid", "rule.customTrigger 必须是布尔", `${base}.customTrigger`, "value");
|
|
392
|
+
else
|
|
393
|
+
customTrigger = customTriggerNode.value;
|
|
394
|
+
}
|
|
395
|
+
const hasActions = actionsNode !== undefined && !(actionsNode.kind === "scalar" && actionsNode.value === null);
|
|
396
|
+
const hasNodeGraph = nodeGraphNode !== undefined && !(nodeGraphNode.kind === "scalar" && nodeGraphNode.value === null);
|
|
397
|
+
if (hasActions && hasNodeGraph)
|
|
398
|
+
pushError(ctx, "intent-rule-actions-and-nodegraph", "规则不能同时声明 actions 与 nodeGraph(二选一)", base, "value");
|
|
399
|
+
if (!hasActions && !hasNodeGraph)
|
|
400
|
+
pushError(ctx, "intent-rule-body-missing", "规则必须声明 actions(紧凑)或 nodeGraph(透传)之一", base, "value");
|
|
401
|
+
const actions = hasActions && actionsNode ? readActions(actionsNode, base, ctx, draft.code) : undefined;
|
|
402
|
+
let nodeGraph;
|
|
403
|
+
if (hasNodeGraph && nodeGraphNode) {
|
|
404
|
+
if (nodeGraphNode.kind !== "map")
|
|
405
|
+
pushError(ctx, "intent-rule-nodegraph-invalid", "rule.nodeGraph 必须是映射", `${base}.nodeGraph`, "value");
|
|
406
|
+
else
|
|
407
|
+
nodeGraph = toPlain(nodeGraphNode);
|
|
408
|
+
}
|
|
409
|
+
if (code.length === 0 || !isScalarString(nameNode))
|
|
410
|
+
return;
|
|
411
|
+
const rule = {
|
|
412
|
+
code,
|
|
413
|
+
name: nameNode.value,
|
|
414
|
+
enabled,
|
|
415
|
+
...(bizRuleType !== undefined ? { bizRuleType } : {}),
|
|
416
|
+
...(customTrigger !== undefined ? { customTrigger } : {}),
|
|
417
|
+
...(actions ? { actions } : {}),
|
|
418
|
+
...(nodeGraph ? { nodeGraph } : {}),
|
|
419
|
+
};
|
|
420
|
+
lintRuleGraph(rule, ctx, base, draft.code);
|
|
421
|
+
draft.rules.push(rule);
|
|
422
|
+
});
|
|
423
|
+
},
|
|
424
|
+
async read(session, intent, draft) {
|
|
425
|
+
if (!intent.rules?.length)
|
|
426
|
+
return;
|
|
427
|
+
const rows = wireRows((await wireCall(session, { path: ENDPOINTS.businessRuleListBySchemaCode, query: { schemaCode: draft.code } })).data);
|
|
428
|
+
for (const row of rows) {
|
|
429
|
+
const code = String(row.code ?? "");
|
|
430
|
+
if (!code)
|
|
431
|
+
continue;
|
|
432
|
+
const declared = intent.rules.find((rule) => rule.code === code);
|
|
433
|
+
if (!declared) {
|
|
434
|
+
draft.rules.push({ code, present: true, enabled: row.enabled === true, nodes: undefined, routes: undefined });
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const record = (await wireCall(session, { path: ENDPOINTS.businessRuleGet, query: { id: String(row.id) } })).data;
|
|
438
|
+
draft.rules.push({
|
|
439
|
+
code,
|
|
440
|
+
present: true,
|
|
441
|
+
enabled: row.enabled === true,
|
|
442
|
+
nodes: record ? decodeJsonColumn(record.node, JSON_COLUMN_NULL_COALESCE) : undefined,
|
|
443
|
+
routes: record ? decodeJsonColumn(record.route, JSON_COLUMN_NULL_COALESCE) : undefined,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
plan(intent, baseline, emit) {
|
|
448
|
+
for (const rule of intent.rules ?? []) {
|
|
449
|
+
const live = (baseline.rules ?? []).find((entry) => entry.code === rule.code);
|
|
450
|
+
const expected = expectedRuleGraph(rule, intent.code);
|
|
451
|
+
const resource = `rule:${intent.code}.${rule.code}`;
|
|
452
|
+
const graphChanged = live === undefined || live.nodes === undefined || ruleGraphDigest(live.nodes, live.routes) !== ruleGraphDigest(expected.nodes, expected.routes);
|
|
453
|
+
if (live === undefined)
|
|
454
|
+
emit.change("rule.save", ruleSaveArgs(intent.code, rule), resource, "rule-absent");
|
|
455
|
+
else if (graphChanged)
|
|
456
|
+
emit.change("rule.save", ruleSaveArgs(intent.code, rule), resource, "rule-graph-drift");
|
|
457
|
+
else
|
|
458
|
+
emit.skip(resource, "rule-present");
|
|
459
|
+
if (rule.enabled) {
|
|
460
|
+
if (graphChanged || live?.enabled !== true)
|
|
461
|
+
emit.change("rule.enable", { schemaCode: intent.code, ruleCode: rule.code }, resource, live === undefined ? "rule-created-disabled" : "rule-not-enabled");
|
|
462
|
+
}
|
|
463
|
+
else if (live?.enabled === true) {
|
|
464
|
+
emit.change("rule.disable", { schemaCode: intent.code, ruleCode: rule.code }, resource, "rule-enabled-unexpected");
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
decode(baseline, draft) {
|
|
469
|
+
draft.rules = (baseline.rules ?? []).map((rule) => ({ code: rule.code, present: rule.present, enabled: rule.enabled, nodes: rule.nodes, routes: rule.routes }));
|
|
470
|
+
},
|
|
471
|
+
compare(intent, live, emit) {
|
|
472
|
+
const declared = new Set((intent.rules ?? []).map((rule) => rule.code));
|
|
473
|
+
for (const rule of intent.rules ?? []) {
|
|
474
|
+
const current = (live.rules ?? []).find((entry) => entry.code === rule.code);
|
|
475
|
+
const resource = `rule:${intent.code}.${rule.code}`;
|
|
476
|
+
if (current?.present !== true) {
|
|
477
|
+
emit.difference("rule-missing", resource, `规则 ${intent.code}.${rule.code} 不存在`);
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (rule.enabled && !current.enabled)
|
|
481
|
+
emit.difference("rule-not-enabled", resource, `规则 ${intent.code}.${rule.code} 未启用(enabled 应为 true)`);
|
|
482
|
+
if (!rule.enabled && current.enabled)
|
|
483
|
+
emit.difference("rule-enabled-unexpected", resource, `规则 ${intent.code}.${rule.code} 已启用但意图声明为未启用`);
|
|
484
|
+
const expected = expectedRuleGraph(rule, intent.code);
|
|
485
|
+
if (ruleGraphDigest(current.nodes, current.routes) !== ruleGraphDigest(expected.nodes, expected.routes))
|
|
486
|
+
emit.difference("rule-graph-mismatch", resource, `规则 ${intent.code}.${rule.code} 的节点图与意图不等价`);
|
|
487
|
+
}
|
|
488
|
+
for (const current of live.rules ?? [])
|
|
489
|
+
if (!declared.has(current.code))
|
|
490
|
+
emit.ignored(`rule:${intent.code}.${current.code}`);
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
//# sourceMappingURL=businessrule.js.map
|