@salesforce/plugin-bre-to-cml 1.3.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -4
- package/lib/commands/cml/convert/surcharge-rules.d.ts +12 -0
- package/lib/commands/cml/convert/surcharge-rules.js +79 -5
- package/lib/commands/cml/convert/surcharge-rules.js.map +1 -1
- package/lib/commands/cml/convert/underwriting-rules.d.ts +41 -2
- package/lib/commands/cml/convert/underwriting-rules.js +141 -17
- package/lib/commands/cml/convert/underwriting-rules.js.map +1 -1
- package/lib/commands/insurance/import/record-updates.d.ts +66 -0
- package/lib/commands/insurance/import/record-updates.js +239 -0
- package/lib/commands/insurance/import/record-updates.js.map +1 -0
- package/lib/shared/cml-operators.d.ts +9 -1
- package/lib/shared/cml-operators.js +23 -5
- package/lib/shared/cml-operators.js.map +1 -1
- package/lib/shared/insurance/confirm-org-changes.d.ts +49 -0
- package/lib/shared/insurance/confirm-org-changes.js +43 -0
- package/lib/shared/insurance/confirm-org-changes.js.map +1 -0
- package/lib/shared/insurance/insurance-cml-merge.d.ts +100 -13
- package/lib/shared/insurance/insurance-cml-merge.js +355 -54
- package/lib/shared/insurance/insurance-cml-merge.js.map +1 -1
- package/lib/shared/insurance/insurance-context-tags.d.ts +124 -0
- package/lib/shared/insurance/insurance-context-tags.js +210 -0
- package/lib/shared/insurance/insurance-context-tags.js.map +1 -0
- package/lib/shared/insurance/insurance-org.d.ts +12 -0
- package/lib/shared/insurance/insurance-org.js +38 -2
- package/lib/shared/insurance/insurance-org.js.map +1 -1
- package/lib/shared/insurance/insurance-rule-convert-command.d.ts +27 -0
- package/lib/shared/insurance/insurance-rule-convert-command.js +57 -4
- package/lib/shared/insurance/insurance-rule-convert-command.js.map +1 -1
- package/lib/shared/insurance/insurance-rule-generator.d.ts +101 -8
- package/lib/shared/insurance/insurance-rule-generator.js +293 -42
- package/lib/shared/insurance/insurance-rule-generator.js.map +1 -1
- package/lib/shared/insurance/models.d.ts +2 -1
- package/lib/shared/insurance/planned-change-mapper.d.ts +15 -0
- package/lib/shared/insurance/planned-change-mapper.js +51 -0
- package/lib/shared/insurance/planned-change-mapper.js.map +1 -0
- package/lib/shared/insurance/planned-change.d.ts +83 -0
- package/lib/shared/insurance/planned-change.js +74 -0
- package/lib/shared/insurance/planned-change.js.map +1 -0
- package/lib/shared/insurance/record-update-apply.d.ts +100 -0
- package/lib/shared/insurance/record-update-apply.js +303 -0
- package/lib/shared/insurance/record-update-apply.js.map +1 -0
- package/lib/shared/insurance/record-update-plan.d.ts +104 -0
- package/lib/shared/insurance/record-update-plan.js +397 -0
- package/lib/shared/insurance/record-update-plan.js.map +1 -0
- package/messages/cml.convert.surcharge-rules.md +3 -1
- package/messages/cml.convert.underwriting-rules.md +1 -1
- package/messages/insurance.import.record-updates.md +176 -0
- package/oclif.lock +25 -25
- package/oclif.manifest.json +95 -3
- package/package.json +16 -8
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CmlConstraint } from '../types/types.js';
|
|
2
2
|
import { CONSTRAINT_TYPES } from '../constants/constants.js';
|
|
3
|
-
import { buildConstraintDeclaration, collectAttributeTypes, collectEmittedAttributes, sanitizeName, buildStageTransition, } from './insurance-rule-generator.js';
|
|
3
|
+
import { buildConstraintDeclaration, collectAttributeTypes, collectEmittedAttributes, collectEmittedContextTags, findUnconvertibleConditions, sanitizeName, buildStageTransition, } from './insurance-rule-generator.js';
|
|
4
4
|
import { quoteSoqlIdList } from './insurance-org.js';
|
|
5
5
|
/**
|
|
6
6
|
* MERGE MODE (insurance-only): instead of building a fresh single-type CML and PATCH-replacing
|
|
@@ -13,8 +13,11 @@ import { quoteSoqlIdList } from './insurance-org.js';
|
|
|
13
13
|
*
|
|
14
14
|
* Why pathed keys: when a ProductSurcharge is persisted as ConstraintEngine the platform
|
|
15
15
|
* auto-generates `RuleKey` as `SC` + sanitize(ProductCode) of every segment in ProductPath (in
|
|
16
|
-
* order) + sanitize(
|
|
17
|
-
*
|
|
16
|
+
* order) + sanitize(leaf), joined by `__`. The leaf is the parent `Surcharge.Code` (resolved via
|
|
17
|
+
* ProductSurcharge.SurchargeId), NOT the rule apiName — see {@link fetchSurchargeCodes} and
|
|
18
|
+
* {@link buildPathedSurchargeRules}. The Core surcharge engine matches the fired CML rule key
|
|
19
|
+
* against that auto-generated RuleKey by exact string, so the CML rule key MUST be pathed and use
|
|
20
|
+
* the same Code-derived leaf.
|
|
18
21
|
*/
|
|
19
22
|
export const SURCHARGE_RULE_ACTION = 'InsuranceSurchargeRule';
|
|
20
23
|
/**
|
|
@@ -28,8 +31,17 @@ export function buildPathedRuleKey(prefix, pathProductCodes, apiName, stageTrans
|
|
|
28
31
|
parts.push(sanitizeName(apiName));
|
|
29
32
|
return parts.join('__');
|
|
30
33
|
}
|
|
31
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Splits a `ProductPath` into its ordered Product2 ids (slash-separated).
|
|
36
|
+
*
|
|
37
|
+
* ProductPath is nullable in the org even on records that carry a rule, so nullish input yields an
|
|
38
|
+
* empty path rather than throwing. Callers rely on that: an empty path is already a recognized skip
|
|
39
|
+
* (see the empty-ProductPath guard in mergeSurchargeRules), whereas throwing here aborted the whole
|
|
40
|
+
* conversion run before any record could be skipped.
|
|
41
|
+
*/
|
|
32
42
|
export function splitProductPath(productPath) {
|
|
43
|
+
if (!productPath)
|
|
44
|
+
return [];
|
|
33
45
|
return productPath
|
|
34
46
|
.split('/')
|
|
35
47
|
.map((s) => s.trim())
|
|
@@ -76,6 +88,32 @@ export async function fetchProductTypeTags(conn, productIds) {
|
|
|
76
88
|
}
|
|
77
89
|
return idToTag;
|
|
78
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Resolves Surcharge id -> Surcharge.Code. The platform builds the LEAF segment of
|
|
93
|
+
* `ProductSurcharge.RuleKey` from the parent Surcharge's Code (e.g. `basictaxcode`), NOT from the
|
|
94
|
+
* rule apiName. `buildPathedSurchargeRules` uses this map so the emitted CML rule key matches the
|
|
95
|
+
* platform-generated RuleKey exactly; without it the surcharge imports cleanly but never fires
|
|
96
|
+
* ("No active rule model found"). Ids with no resolvable Code are omitted (caller warns + falls
|
|
97
|
+
* back to apiName).
|
|
98
|
+
*/
|
|
99
|
+
export async function fetchSurchargeCodes(conn, surchargeIds) {
|
|
100
|
+
const idToCode = new Map();
|
|
101
|
+
const idList = quoteSoqlIdList(surchargeIds);
|
|
102
|
+
if (!idList)
|
|
103
|
+
return idToCode;
|
|
104
|
+
const result = await conn.query(`SELECT Id, Code FROM Surcharge WHERE Id IN (${idList})`);
|
|
105
|
+
for (const r of result.records) {
|
|
106
|
+
if (!r.Id || !r.Code)
|
|
107
|
+
continue;
|
|
108
|
+
// SOQL always returns 18-char Ids, but a RuleDefinition blob could carry a 15-char surchargeId.
|
|
109
|
+
// Index under both the full and 15-char-prefix forms so the caller's verbatim lookup hits either
|
|
110
|
+
// way (SOQL `IN` is 15/18 tolerant, so the returned key would otherwise not match a 15-char id).
|
|
111
|
+
idToCode.set(r.Id, r.Code);
|
|
112
|
+
if (r.Id.length === 18)
|
|
113
|
+
idToCode.set(r.Id.slice(0, 15), r.Code);
|
|
114
|
+
}
|
|
115
|
+
return idToCode;
|
|
116
|
+
}
|
|
79
117
|
export function escapeRegExp(value) {
|
|
80
118
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
81
119
|
}
|
|
@@ -189,6 +227,28 @@ function findTypeBlock(cml, typeName, scan = blankComments(cml)) {
|
|
|
189
227
|
return undefined;
|
|
190
228
|
return { openIdx, closeIdx };
|
|
191
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* Prefix every merge skip raised by {@link findUnconvertibleConditions} carries, so the commands'
|
|
232
|
+
* skip breakdown can bucket it the way it buckets the other reasons.
|
|
233
|
+
*/
|
|
234
|
+
export const UNCONVERTIBLE_SKIP_PREFIX = 'cannot be expressed in CML';
|
|
235
|
+
function joinReasons(reasons) {
|
|
236
|
+
return reasons.length > 0 ? `${UNCONVERTIBLE_SKIP_PREFIX}: ${reasons.join(' ')}` : undefined;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Whether a context tag title is safe to place verbatim inside the double-quoted slot of a
|
|
240
|
+
* `@(tagName = "...")` / `@(contextPath = "...")` annotation. Titles legitimately contain spaces
|
|
241
|
+
* ("Cause Of Loss"), but a quote, backslash or newline would break out of the literal and land raw
|
|
242
|
+
* in the curated model, so those are refused rather than escaped — the same reject-don't-escape
|
|
243
|
+
* stance {@link isSafeQuotableString} takes for condition values.
|
|
244
|
+
*
|
|
245
|
+
* A refused tag contributes no declaration, so the absent-reference gate withholds its rule. The
|
|
246
|
+
* fetch layer already declines to query such a title, making this defence in depth for a
|
|
247
|
+
* hand-constructed binding map.
|
|
248
|
+
*/
|
|
249
|
+
function isSafeCmlAnnotationValue(value) {
|
|
250
|
+
return value.trim().length > 0 && !/["\\\r\n]/.test(value);
|
|
251
|
+
}
|
|
192
252
|
/** Builds the `rule(...)` statement for a surcharge, using the same constraint generator as build mode. */
|
|
193
253
|
export function buildSurchargeRuleStatement(declaration, ruleKey) {
|
|
194
254
|
return CmlConstraint.createRuleConstraint(declaration, SURCHARGE_RULE_ACTION, ruleKey, 'True').generateCml();
|
|
@@ -196,22 +256,37 @@ export function buildSurchargeRuleStatement(declaration, ruleKey) {
|
|
|
196
256
|
/**
|
|
197
257
|
* Prepares the pathed-rule descriptors for a set of parsed surcharge records.
|
|
198
258
|
* `productIdToCode` and `productIdToType` must already cover every ProductPath segment.
|
|
259
|
+
* `options.surchargeIdToCode` should map every rule's parent Surcharge id to its Code so the key
|
|
260
|
+
* leaf matches the platform-generated RuleKey (see {@link fetchSurchargeCodes}).
|
|
199
261
|
*/
|
|
200
|
-
export function buildPathedSurchargeRules(prefix, ruleDefs, productIdToCode, productIdToType) {
|
|
262
|
+
export function buildPathedSurchargeRules(prefix, ruleDefs, productIdToCode, productIdToType, options = {}) {
|
|
201
263
|
return ruleDefs.map(({ record, ruleDef }) => {
|
|
202
264
|
const apiName = ruleDef.apiName ?? record.Name;
|
|
265
|
+
// The platform builds the RuleKey leaf from the parent Surcharge.Code, not the rule apiName.
|
|
266
|
+
// Prefer the resolved Code; fall back to apiName (and warn) only when it can't be resolved.
|
|
267
|
+
const surchargeCode = ruleDef.surchargeId ? options.surchargeIdToCode?.get(ruleDef.surchargeId) : undefined;
|
|
268
|
+
const keyLeaf = surchargeCode ?? apiName;
|
|
269
|
+
if (!surchargeCode)
|
|
270
|
+
options.onSurchargeCodeFallback?.(record.Name);
|
|
203
271
|
const segments = splitProductPath(record.ProductPath);
|
|
204
272
|
const pathCodes = segments.map((id) => productIdToCode.get(id) ?? id);
|
|
205
273
|
const stageTransition = buildStageTransition(ruleDef.underwritingRuleGroup);
|
|
206
|
-
const ruleKey = buildPathedRuleKey(prefix, pathCodes,
|
|
274
|
+
const ruleKey = buildPathedRuleKey(prefix, pathCodes, keyLeaf, stageTransition);
|
|
207
275
|
const leafProductId = segments[segments.length - 1];
|
|
208
276
|
const typeName = leafProductId ? productIdToType.get(leafProductId) : undefined;
|
|
209
|
-
const
|
|
277
|
+
const tagTypes = options.contextTagBindings;
|
|
278
|
+
const declaration = buildConstraintDeclaration(ruleDef, options.attributeDataTypes, tagTypes);
|
|
210
279
|
const statement = buildSurchargeRuleStatement(declaration, ruleKey);
|
|
211
280
|
// M7: only attributes from conditions that actually emitted CML (non-null buildConditionExpression)
|
|
212
281
|
// count as "referenced". Attributes from conditions the safe-literal guard / unknown-operator
|
|
213
282
|
// filter dropped never appear in the declaration, so warning about them would be spurious noise.
|
|
214
|
-
const referencedAttributes = Array.from(collectEmittedAttributes([{ ruleDef }]));
|
|
283
|
+
const referencedAttributes = Array.from(collectEmittedAttributes([{ ruleDef }], options.attributeDataTypes, tagTypes));
|
|
284
|
+
// Same discipline for tags, then narrowed to the ones that actually resolved: an unresolved tag
|
|
285
|
+
// contributes no declaration, so it stays absent from the model and its rule is withheld.
|
|
286
|
+
const contextTagDeclarations = Array.from(collectEmittedContextTags([{ ruleDef }], options.attributeDataTypes, tagTypes))
|
|
287
|
+
.map((tag) => options.contextTagBindings?.get(tag))
|
|
288
|
+
.filter((b) => b !== undefined && isSafeCmlAnnotationValue(b.tag))
|
|
289
|
+
.map((b) => ({ tag: b.tag, identifier: sanitizeName(b.tag), cmlType: b.cmlType, scope: b.scope }));
|
|
215
290
|
return {
|
|
216
291
|
recordId: record.Id,
|
|
217
292
|
recordName: record.Name,
|
|
@@ -221,6 +296,8 @@ export function buildPathedSurchargeRules(prefix, ruleDefs, productIdToCode, pro
|
|
|
221
296
|
typeName,
|
|
222
297
|
statement,
|
|
223
298
|
referencedAttributes,
|
|
299
|
+
contextTagDeclarations,
|
|
300
|
+
unconvertibleReason: joinReasons(findUnconvertibleConditions(ruleDef, options.attributeDataTypes, tagTypes)),
|
|
224
301
|
};
|
|
225
302
|
});
|
|
226
303
|
}
|
|
@@ -240,7 +317,10 @@ export function buildUnderwritingConstraintStatement(constraintName, declaration
|
|
|
240
317
|
* action statement. `productIdToCode` and `productIdToType` must already cover every ProductPath
|
|
241
318
|
* segment.
|
|
242
319
|
*/
|
|
243
|
-
export function buildUnderwritingConstraintRules(keyPrefix, constraintLabel, ruleDefs, productIdToCode, productIdToType
|
|
320
|
+
export function buildUnderwritingConstraintRules(keyPrefix, constraintLabel, ruleDefs, productIdToCode, productIdToType,
|
|
321
|
+
// Org-resolved attribute types, so a picklist attribute is compared as the type behind the
|
|
322
|
+
// picklist and declared with that same type in referencedAttributes.
|
|
323
|
+
attributeDataTypes) {
|
|
244
324
|
return ruleDefs.map(({ record, ruleDef }) => {
|
|
245
325
|
const apiName = ruleDef.apiName ?? record.Name;
|
|
246
326
|
const segments = splitProductPath(record.ProductPath);
|
|
@@ -249,7 +329,7 @@ export function buildUnderwritingConstraintRules(keyPrefix, constraintLabel, rul
|
|
|
249
329
|
const ruleKey = buildPathedRuleKey(keyPrefix, pathCodes, apiName, stageTransition);
|
|
250
330
|
const leafProductId = segments[segments.length - 1];
|
|
251
331
|
const typeName = leafProductId ? productIdToType.get(leafProductId) : undefined;
|
|
252
|
-
const declaration = buildConstraintDeclaration(ruleDef);
|
|
332
|
+
const declaration = buildConstraintDeclaration(ruleDef, attributeDataTypes);
|
|
253
333
|
// Mirrors buildCmlModel's constraint naming: sanitized apiName, with the stage transition
|
|
254
334
|
// appended so two rules sharing an apiName under the same product (gated on different
|
|
255
335
|
// transitions) don't collide.
|
|
@@ -260,8 +340,8 @@ export function buildUnderwritingConstraintRules(keyPrefix, constraintLabel, rul
|
|
|
260
340
|
// collectAttributeTypes keys its map by the RAW attribute name, whereas
|
|
261
341
|
// collectEmittedAttributes returns SANITIZED names, so we sanitize each raw key before
|
|
262
342
|
// matching it against the emitted-name set.
|
|
263
|
-
const emittedSanitized = collectEmittedAttributes([{ ruleDef }]);
|
|
264
|
-
const attrTypesByRawName = collectAttributeTypes([{ ruleDef }]);
|
|
343
|
+
const emittedSanitized = collectEmittedAttributes([{ ruleDef }], attributeDataTypes);
|
|
344
|
+
const attrTypesByRawName = collectAttributeTypes([{ ruleDef }], attributeDataTypes);
|
|
265
345
|
const referencedAttributes = [];
|
|
266
346
|
for (const [rawName, cmlType] of attrTypesByRawName) {
|
|
267
347
|
const sanitized = sanitizeName(rawName);
|
|
@@ -279,6 +359,7 @@ export function buildUnderwritingConstraintRules(keyPrefix, constraintLabel, rul
|
|
|
279
359
|
constraintName,
|
|
280
360
|
statement,
|
|
281
361
|
referencedAttributes,
|
|
362
|
+
unconvertibleReason: joinReasons(findUnconvertibleConditions(ruleDef, attributeDataTypes)),
|
|
282
363
|
};
|
|
283
364
|
});
|
|
284
365
|
}
|
|
@@ -313,6 +394,12 @@ export function mergeSurchargeRules(existingCml, rules) {
|
|
|
313
394
|
// just-inserted statement (which would silently drop the second record's distinct declaration).
|
|
314
395
|
const placedKeys = new Set();
|
|
315
396
|
for (const rule of rules) {
|
|
397
|
+
// Checked first: a rule CML cannot express must not be placed on any path, and placing it
|
|
398
|
+
// would mean placing a declaration its dropped conditions had already emptied to `true`.
|
|
399
|
+
if (rule.unconvertibleReason) {
|
|
400
|
+
skips.push({ rule, reason: rule.unconvertibleReason });
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
316
403
|
if (placedKeys.has(rule.ruleKey)) {
|
|
317
404
|
skips.push({
|
|
318
405
|
rule,
|
|
@@ -340,7 +427,73 @@ export function mergeSurchargeRules(existingCml, rules) {
|
|
|
340
427
|
// [Fix #1] One comment-blanked view of the current `cml` reused by both the replace-anchor
|
|
341
428
|
// search and the type-block search this iteration. Recomputed per-iteration because a prior
|
|
342
429
|
// rule's splice may have mutated `cml`.
|
|
343
|
-
|
|
430
|
+
let scan = blankComments(cml);
|
|
431
|
+
// Resolved BEFORE the replace path, for two reasons. It lets the attribute gate below report an
|
|
432
|
+
// accurate cause (an unresolvable leaf scope makes every attribute look absent, so running the
|
|
433
|
+
// gate first would blame a missing attribute for what is really a missing type block, sending
|
|
434
|
+
// the operator off to declare an attribute in a type that does not exist). And a rule whose
|
|
435
|
+
// leaf type we cannot pin down must not rewrite a curated statement on the strength of a key
|
|
436
|
+
// match alone.
|
|
437
|
+
let block = findTypeBlock(cml, rule.typeName, scan);
|
|
438
|
+
if (!block) {
|
|
439
|
+
skips.push({ rule, reason: `type block '${rule.typeName}' not found in existing model` });
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if ('ambiguous' in block) {
|
|
443
|
+
skips.push({
|
|
444
|
+
rule,
|
|
445
|
+
reason: `type block '${rule.typeName}' is ambiguous (multiple/duplicate declarations) in existing model; skipping ${rule.recordName} rather than guessing`,
|
|
446
|
+
});
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
// Runs BEFORE both the replace splice and the insert, so a rule we are going to withhold can
|
|
450
|
+
// never clobber a curated statement on its way out. Emitting an undeclared reference does not
|
|
451
|
+
// just produce a dead rule: the solver refuses to deploy the whole model, taking every other
|
|
452
|
+
// rule in the ExpressionSet down with it.
|
|
453
|
+
const absent = findAbsentAttributes(baseCml, rule, baseScan);
|
|
454
|
+
if (absent.length > 0) {
|
|
455
|
+
const names = absent.map((a) => `'${a}'`).join(', ');
|
|
456
|
+
attributeWarnings.push(`${rule.recordName}: declaration references ${names}, absent from the model`);
|
|
457
|
+
skips.push({
|
|
458
|
+
rule,
|
|
459
|
+
reason: `undeclared attribute ${names} referenced by ${rule.recordName}; withheld because the solver rejects the whole model at deploy, disabling every rule in it`,
|
|
460
|
+
});
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
// The replace path below finds the statement by key ANYWHERE in the model, so a statement that
|
|
464
|
+
// was previously written into the wrong type block gets rewritten there again, cementing the
|
|
465
|
+
// misplacement. Worse, the attribute gate above proved visibility against the RESOLVED block,
|
|
466
|
+
// not wherever the statement actually lives — so a mismatch can smuggle a reference that does
|
|
467
|
+
// not resolve at the real location past the gate, which is the failure that takes the whole
|
|
468
|
+
// model down. Refuse to rewrite a statement that sits outside the block it resolves to, and
|
|
469
|
+
// report it; relocating it would mean deleting a curated line, which is the operator's call.
|
|
470
|
+
// Checked before any declaration is spliced so a rule we withhold leaves no orphan behind.
|
|
471
|
+
const misplaced = findSurchargeStatement(cml, rule.ruleKey, scan);
|
|
472
|
+
if (misplaced && (misplaced.start < block.openIdx || misplaced.end > block.closeIdx)) {
|
|
473
|
+
skips.push({
|
|
474
|
+
rule,
|
|
475
|
+
reason: `misplaced statement for ${rule.recordName}: '${rule.ruleKey}' already exists outside its resolved type block '${rule.typeName}'; refusing to rewrite it where it stands`,
|
|
476
|
+
});
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
// The rule is going to be placed, so its resolved context tags must be declared first — the gate
|
|
480
|
+
// above passed them precisely because this runs. Declaring here (rather than after placement)
|
|
481
|
+
// also means a later rule sees the declaration and does not add a second one.
|
|
482
|
+
const withDeclarations = applyContextTagDeclarations(cml, rule, eol);
|
|
483
|
+
if (withDeclarations !== cml) {
|
|
484
|
+
// Every offset computed above is now stale, so re-derive them against the spliced text before
|
|
485
|
+
// the replace/insert below uses them.
|
|
486
|
+
cml = withDeclarations;
|
|
487
|
+
scan = blankComments(cml);
|
|
488
|
+
block = findTypeBlock(cml, rule.typeName, scan);
|
|
489
|
+
if (!block || 'ambiguous' in block) {
|
|
490
|
+
skips.push({
|
|
491
|
+
rule,
|
|
492
|
+
reason: `type block '${rule.typeName}' could not be re-resolved after declaring context tags for ${rule.recordName}`,
|
|
493
|
+
});
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
344
497
|
// C2/M4/L1: only a REAL surcharge `rule(...)` statement carrying this exact key in the
|
|
345
498
|
// action-scope slot counts as "present". A bare quoted-key substring inside an unrelated rule's
|
|
346
499
|
// value, a longer key, or a comment must NOT trigger a destructive line replace.
|
|
@@ -355,19 +508,6 @@ export function mergeSurchargeRules(existingCml, rules) {
|
|
|
355
508
|
cml = cml.slice(0, stmt.start) + rule.statement + cml.slice(stmt.end);
|
|
356
509
|
placements.push({ rule, status: 'replaced' });
|
|
357
510
|
placedKeys.add(rule.ruleKey);
|
|
358
|
-
collectAttributeWarning(baseCml, rule, attributeWarnings, baseScan);
|
|
359
|
-
continue;
|
|
360
|
-
}
|
|
361
|
-
const block = findTypeBlock(cml, rule.typeName, scan);
|
|
362
|
-
if (!block) {
|
|
363
|
-
skips.push({ rule, reason: `type block '${rule.typeName}' not found in existing model` });
|
|
364
|
-
continue;
|
|
365
|
-
}
|
|
366
|
-
if ('ambiguous' in block) {
|
|
367
|
-
skips.push({
|
|
368
|
-
rule,
|
|
369
|
-
reason: `type block '${rule.typeName}' is ambiguous (multiple/duplicate declarations) in existing model; skipping ${rule.recordName} rather than guessing`,
|
|
370
|
-
});
|
|
371
511
|
continue;
|
|
372
512
|
}
|
|
373
513
|
// Insert before the closing brace, indented one level (4 spaces), with a leading blank line.
|
|
@@ -376,7 +516,6 @@ export function mergeSurchargeRules(existingCml, rules) {
|
|
|
376
516
|
cml = cml.slice(0, block.closeIdx) + insertion + cml.slice(block.closeIdx);
|
|
377
517
|
placements.push({ rule, status: 'inserted' });
|
|
378
518
|
placedKeys.add(rule.ruleKey);
|
|
379
|
-
collectAttributeWarning(baseCml, rule, attributeWarnings, baseScan);
|
|
380
519
|
}
|
|
381
520
|
return { mergedCml: cml, placements, skips, attributeWarnings };
|
|
382
521
|
}
|
|
@@ -389,13 +528,11 @@ export function mergeSurchargeRules(existingCml, rules) {
|
|
|
389
528
|
* statement (from the `constraint` keyword through the terminating `;`) is replaced in place
|
|
390
529
|
* (idempotent — re-running the merge with the same input reproduces the same output).
|
|
391
530
|
* - Otherwise the statement is inserted just before the closing brace of the leaf `type` block.
|
|
392
|
-
* -
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
* constraints reference attributes that must resolve for the constraint to compile, so the
|
|
398
|
-
* declaration is added rather than merely flagged.
|
|
531
|
+
* - Any of a rule's `referencedAttributes` not already declared anywhere in the leaf `type` block
|
|
532
|
+
* are reported via `attributeWarnings`; the curated model is never mutated to add them. NOTE: this
|
|
533
|
+
* is weaker than the surcharge merge, which WITHHOLDS such a rule outright — an unresolvable
|
|
534
|
+
* reference makes the solver reject the whole model at deploy, so warning here still lets a
|
|
535
|
+
* model-wide outage through. See findAbsentAttributes.
|
|
399
536
|
*
|
|
400
537
|
* Rules with a blank ProductPath, no resolved leaf type tag, an ambiguous/duplicate type block, or
|
|
401
538
|
* an intra-run duplicate (typeName, constraintName) pair are skipped (reported, never silently
|
|
@@ -407,11 +544,21 @@ export function mergeUnderwritingConstraints(existingCml, rules) {
|
|
|
407
544
|
const skips = [];
|
|
408
545
|
const attributeWarnings = [];
|
|
409
546
|
const eol = detectDominantLineEnding(existingCml);
|
|
547
|
+
// Baseline snapshot for the attribute gate below. `cml` is mutated as rules are placed, and a
|
|
548
|
+
// placed constraint mentions the attributes it references — scanning the mutated text would let
|
|
549
|
+
// a rule vouch for its own references, which is the bug the gate exists to prevent.
|
|
550
|
+
const baseCml = existingCml;
|
|
551
|
+
const baseScan = blankComments(existingCml);
|
|
410
552
|
// Mirrors mergeSurchargeRules' placedKeys guard: a second rule resolving to the same
|
|
411
553
|
// (typeName, constraintName) pair must be reported as a collision skip, not treated as an
|
|
412
554
|
// idempotent replace of the first rule's just-placed statement.
|
|
413
555
|
const placedNames = new Set();
|
|
414
556
|
for (const rule of rules) {
|
|
557
|
+
// Checked first, for the same reason as in mergeSurchargeRules.
|
|
558
|
+
if (rule.unconvertibleReason) {
|
|
559
|
+
skips.push({ rule, reason: rule.unconvertibleReason });
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
415
562
|
if (rule.pathProductCodes.length === 0) {
|
|
416
563
|
skips.push({
|
|
417
564
|
rule,
|
|
@@ -446,15 +593,23 @@ export function mergeUnderwritingConstraints(existingCml, rules) {
|
|
|
446
593
|
});
|
|
447
594
|
continue;
|
|
448
595
|
}
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
596
|
+
// Withhold — not warn — when a referenced attribute cannot be resolved from this type. An
|
|
597
|
+
// unresolvable reference makes the solver reject the ENTIRE model at deploy, disabling every
|
|
598
|
+
// other rule in the same ExpressionSet, and neither import nor activation catches it first.
|
|
599
|
+
// Uses the same hierarchy-aware scoping as the surcharge gate (leaf block plus its `: Parent`
|
|
600
|
+
// ancestry, plus top-level externs) rather than the leaf block body alone: an attribute
|
|
601
|
+
// declared on a parent type is genuinely visible here, and withholding it would be a false
|
|
602
|
+
// positive. Checked against the BASELINE text, since a rule's own spliced-in constraint
|
|
603
|
+
// contains the very names being looked for.
|
|
604
|
+
const absent = findUnresolvableReferences(baseCml, rule.typeName, rule.referencedAttributes.map((a) => a.name), baseScan);
|
|
605
|
+
if (absent.length > 0) {
|
|
606
|
+
const names = absent.map((a) => `'${a}'`).join(', ');
|
|
607
|
+
attributeWarnings.push(`${rule.recordName}: declaration references ${names}, absent from type '${rule.typeName}'`);
|
|
608
|
+
skips.push({
|
|
609
|
+
rule,
|
|
610
|
+
reason: `undeclared attribute ${names} referenced by ${rule.recordName}; withheld because the solver rejects the whole model at deploy, disabling every rule in it`,
|
|
611
|
+
});
|
|
612
|
+
continue;
|
|
458
613
|
}
|
|
459
614
|
const stmt = findConstraintStatement(cml, block, rule.constraintName, scan);
|
|
460
615
|
if (stmt) {
|
|
@@ -629,16 +784,153 @@ export function blankComments(cml) {
|
|
|
629
784
|
return out.join('');
|
|
630
785
|
}
|
|
631
786
|
/**
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
* unknown attribute
|
|
635
|
-
*
|
|
787
|
+
* Returns the attributes a rule's declaration references that are not present anywhere in the
|
|
788
|
+
* curated model. In merge mode we never inject `string <attr>;` into the curated model, so an
|
|
789
|
+
* unknown attribute cannot resolve. Attributes already present are trusted to be visible via the
|
|
790
|
+
* coverage type hierarchy.
|
|
791
|
+
*
|
|
792
|
+
* The caller withholds any rule this reports on. Measured in sdb38-ins on 12 Aug 2026: a bare
|
|
793
|
+
* undeclared reference is not merely a rule that fails to fire — the solver rejects the ENTIRE
|
|
794
|
+
* model at deploy (`Couldn't find attribute 'TotalAmount' in AutoSilver`), which disables every
|
|
795
|
+
* other rule in the same ExpressionSet and makes the surcharge API return 500. Import and
|
|
796
|
+
* activation do not validate, so nothing catches it before first use in production.
|
|
636
797
|
*
|
|
637
798
|
* IMPORTANT: `baseCml` must be the ORIGINAL model text, captured before any surcharge statement was
|
|
638
799
|
* spliced in. Checking the post-insertion text would always find the attribute inside the rule's own
|
|
639
800
|
* just-inserted declaration (which sanitizes attribute names identically), suppressing every warning.
|
|
640
801
|
*/
|
|
641
|
-
|
|
802
|
+
/**
|
|
803
|
+
* A length-preserving view of `scan` with the BODY of every `type ... { ... }` block blanked, so a
|
|
804
|
+
* search can be restricted to the model's top level (where `extern` declarations live). Offsets are
|
|
805
|
+
* preserved, so an index found in this view still slices the real cml correctly.
|
|
806
|
+
*
|
|
807
|
+
* `matchClosingBrace` runs against the raw cml because it is comment-aware; the returned span is
|
|
808
|
+
* then blanked in the scan view. `lastIndex` is advanced past each block so a `type` nested inside
|
|
809
|
+
* another block is not scanned as a second top-level declaration.
|
|
810
|
+
*/
|
|
811
|
+
function blankTypeBlockBodies(cml, scan) {
|
|
812
|
+
const out = scan.split('');
|
|
813
|
+
const re = /(^|\n)[ \t]*type[ \t]+[A-Za-z_]\w*\b[^{;]*\{/g;
|
|
814
|
+
let m;
|
|
815
|
+
while ((m = re.exec(scan)) !== null) {
|
|
816
|
+
const openIdx = scan.indexOf('{', m.index);
|
|
817
|
+
if (openIdx < 0)
|
|
818
|
+
continue;
|
|
819
|
+
const closeIdx = matchClosingBrace(cml, openIdx);
|
|
820
|
+
if (closeIdx === undefined)
|
|
821
|
+
continue;
|
|
822
|
+
for (let i = openIdx; i <= closeIdx; i++)
|
|
823
|
+
out[i] = ' ';
|
|
824
|
+
re.lastIndex = closeIdx;
|
|
825
|
+
}
|
|
826
|
+
return out.join('');
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Matches a real top-level `extern <type> <identifier>;` declaration. Deliberately anchored on the
|
|
830
|
+
* `extern` keyword rather than accepting any bare mention: this pattern is what lets the
|
|
831
|
+
* absent-reference gate treat an identifier as visible, and a false positive there would let an
|
|
832
|
+
* undeclared reference through — which takes the whole model down at deploy. `<type>` may carry a
|
|
833
|
+
* scale (`decimal(2)`) or be an array (`string[]`).
|
|
834
|
+
*/
|
|
835
|
+
function externDeclarationPattern(identifier) {
|
|
836
|
+
return new RegExp(`\\bextern\\s+[A-Za-z_]\\w*\\s*(?:\\(\\s*\\d+\\s*\\)|\\[\\s*\\])?\\s+${escapeRegExp(identifier)}\\s*;`);
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Matches a declaration of `identifier` in any CML slot — `<type> <identifier>;` or
|
|
840
|
+
* `<type> <identifier> = ...`. Used ONLY for idempotency (do not emit a second declaration of
|
|
841
|
+
* something already declared), never to satisfy the absent-reference gate, so the looser shape is
|
|
842
|
+
* safe: at worst it suppresses a duplicate the model did not need.
|
|
843
|
+
*/
|
|
844
|
+
function declarationPattern(identifier) {
|
|
845
|
+
return new RegExp(`\\b[A-Za-z_]\\w*\\s*(?:\\(\\s*\\d+\\s*\\)|\\[\\s*\\])?\\s+${escapeRegExp(identifier)}\\s*[;=]`);
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Where a new top-level `extern` belongs: immediately after the last existing top-level extern (so
|
|
849
|
+
* the bindings stay grouped as the curated models write them), else immediately before the first
|
|
850
|
+
* top-level `type` header, else at end of file. Returns an index into `cml`.
|
|
851
|
+
*/
|
|
852
|
+
function findExternInsertIndex(cml, scan) {
|
|
853
|
+
const topLevel = blankTypeBlockBodies(cml, scan);
|
|
854
|
+
const externRe = /\bextern\b[^;]*;/g;
|
|
855
|
+
let lastExternEnd = -1;
|
|
856
|
+
let m;
|
|
857
|
+
while ((m = externRe.exec(topLevel)) !== null)
|
|
858
|
+
lastExternEnd = m.index + m[0].length;
|
|
859
|
+
if (lastExternEnd >= 0)
|
|
860
|
+
return lastExternEnd;
|
|
861
|
+
const typeMatch = /(^|\n)[ \t]*type[ \t]+[A-Za-z_]\w*/.exec(topLevel);
|
|
862
|
+
if (typeMatch)
|
|
863
|
+
return typeMatch.index + (typeMatch[1] ? typeMatch[1].length : 0);
|
|
864
|
+
return cml.length;
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Declares every context-tag reference this rule needs that the model does not already carry, and
|
|
868
|
+
* returns the updated CML.
|
|
869
|
+
*
|
|
870
|
+
* Two binding forms, chosen by the tag's position in the context tree (see
|
|
871
|
+
* insurance-context-tags.ts). Both mirror declarations a human already hand-wrote into the curated
|
|
872
|
+
* Auto_Silver model, which is the only evidence available that each form is correct.
|
|
873
|
+
*
|
|
874
|
+
* A transaction-level tag becomes a top-level
|
|
875
|
+
* `@(contextPath = "SalesTransaction.<Tag>", attributeSource = "ST") extern <type> <identifier>;`.
|
|
876
|
+
* An item-level tag becomes `@(tagName = "<Tag>") <type> <identifier>;` inside the leaf type block.
|
|
877
|
+
*
|
|
878
|
+
* The item-level declaration is placed just after the leaf block's opening brace rather than before
|
|
879
|
+
* its closing one (where rule statements go), so a declaration always precedes the statements that
|
|
880
|
+
* use it and the two insert paths cannot interleave confusingly.
|
|
881
|
+
*
|
|
882
|
+
* Idempotent: an identifier already declared — as a top-level extern for the transaction form, or
|
|
883
|
+
* anywhere in the leaf type block's own scope INCLUDING its `: Parent` ancestry for the item form —
|
|
884
|
+
* is left alone, so re-running the converter reproduces the same file.
|
|
885
|
+
*/
|
|
886
|
+
function applyContextTagDeclarations(cml, rule, eol) {
|
|
887
|
+
let out = cml;
|
|
888
|
+
for (const decl of rule.contextTagDeclarations) {
|
|
889
|
+
// Recomputed per declaration: each splice moves every offset after it.
|
|
890
|
+
const scan = blankComments(out);
|
|
891
|
+
if (decl.scope === 'transaction') {
|
|
892
|
+
if (externDeclarationPattern(decl.identifier).test(blankTypeBlockBodies(out, scan)))
|
|
893
|
+
continue;
|
|
894
|
+
const at = findExternInsertIndex(out, scan);
|
|
895
|
+
const annotation = `@(contextPath = "SalesTransaction.${decl.tag}", attributeSource = "ST")`;
|
|
896
|
+
const statement = `${annotation}${eol}extern ${decl.cmlType} ${decl.identifier};`;
|
|
897
|
+
// Appending after an existing extern only needs a leading newline; landing before a `type`
|
|
898
|
+
// header or at EOF needs to keep the following text on its own line.
|
|
899
|
+
const insertion = at > 0 && out[at - 1] === ';' ? `${eol}${statement}` : `${statement}${eol}${eol}`;
|
|
900
|
+
out = out.slice(0, at) + insertion + out.slice(at);
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
// Item scope. The caller has already resolved and validated the leaf type block, so a failure
|
|
904
|
+
// to re-find it here can only mean the model changed under us; skip rather than guess a home.
|
|
905
|
+
if (!rule.typeName)
|
|
906
|
+
continue;
|
|
907
|
+
const block = findTypeBlock(out, rule.typeName, scan);
|
|
908
|
+
if (!block || 'ambiguous' in block)
|
|
909
|
+
continue;
|
|
910
|
+
const visible = collectTypeScopeText(out, rule.typeName, undefined, scan) ?? '';
|
|
911
|
+
if (declarationPattern(decl.identifier).test(visible))
|
|
912
|
+
continue;
|
|
913
|
+
const at = block.openIdx + 1;
|
|
914
|
+
const statement = `${eol} @(tagName = "${decl.tag}")${eol} ${decl.cmlType} ${decl.identifier};`;
|
|
915
|
+
out = out.slice(0, at) + statement + out.slice(at);
|
|
916
|
+
}
|
|
917
|
+
return out;
|
|
918
|
+
}
|
|
919
|
+
function findAbsentAttributes(baseCml, rule, baseScan) {
|
|
920
|
+
// A context tag that resolved against the org is about to be DECLARED by
|
|
921
|
+
// {@link applyContextTagDeclarations}, so it counts as visible even though the baseline text does
|
|
922
|
+
// not mention it yet. Only RESOLVED tags appear here — an unresolved one is deliberately absent
|
|
923
|
+
// from the set, so it still fails the check and its rule is still withheld.
|
|
924
|
+
const willDeclare = new Set(rule.contextTagDeclarations.map((d) => d.identifier));
|
|
925
|
+
return findUnresolvableReferences(baseCml, rule.typeName, rule.referencedAttributes, baseScan, willDeclare);
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Shared by both merges: returns the names, out of `names`, that cannot be resolved from inside
|
|
929
|
+
* type `typeName`. See {@link findAbsentAttributes} for why an unresolvable name is withheld rather
|
|
930
|
+
* than warned about.
|
|
931
|
+
*/
|
|
932
|
+
function findUnresolvableReferences(baseCml, typeName, names, baseScan, willDeclare = new Set()) {
|
|
933
|
+
const absent = [];
|
|
642
934
|
// H5/M3: scope the presence check to the leaf type block plus its `: Parent` ancestry, with
|
|
643
935
|
// comments and string literals stripped. CML attribute visibility is hierarchy-scoped, so an
|
|
644
936
|
// unscoped whole-file `\battr\b` test gives false negatives (an attribute named only in a comment,
|
|
@@ -651,13 +943,22 @@ function collectAttributeWarning(baseCml, rule, warnings, baseScan) {
|
|
|
651
943
|
// (that re-introduces the sibling-type false negative on exactly the records that hit the replace
|
|
652
944
|
// path before type resolution). Fail VISIBLE instead: treat the scope as empty so an attribute we
|
|
653
945
|
// cannot prove visible is reported, never silently suppressed.
|
|
654
|
-
const scope =
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
946
|
+
const scope = typeName ? collectTypeScopeText(baseCml, typeName, undefined, baseScan) ?? '' : '';
|
|
947
|
+
// Type-scoped visibility misses a TOP-LEVEL `extern`, which lives outside every type block yet is
|
|
948
|
+
// referable from inside one (the curated model's own `extern string UserProfile` is used inside
|
|
949
|
+
// `type AutoSilver`). Without this, a rule referencing an already-correctly-bound tag would be
|
|
950
|
+
// withheld for an attribute that is in fact declared. Matched on the anchored `extern` form only.
|
|
951
|
+
const topLevel = blankTypeBlockBodies(baseCml, baseScan ?? blankComments(baseCml));
|
|
952
|
+
for (const attr of names) {
|
|
953
|
+
if (willDeclare.has(attr))
|
|
954
|
+
continue;
|
|
955
|
+
if (new RegExp(`\\b${escapeRegExp(attr)}\\b`).test(scope))
|
|
956
|
+
continue;
|
|
957
|
+
if (externDeclarationPattern(attr).test(topLevel))
|
|
958
|
+
continue;
|
|
959
|
+
absent.push(attr);
|
|
660
960
|
}
|
|
961
|
+
return absent;
|
|
661
962
|
}
|
|
662
963
|
/**
|
|
663
964
|
* Removes `//` line comments, `/* *\/` block comments, and double-quoted string-literal CONTENTS
|