@salesforce/plugin-bre-to-cml 1.3.0 → 1.3.2

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.
Files changed (50) hide show
  1. package/README.md +117 -4
  2. package/lib/commands/cml/convert/surcharge-rules.d.ts +12 -0
  3. package/lib/commands/cml/convert/surcharge-rules.js +79 -5
  4. package/lib/commands/cml/convert/surcharge-rules.js.map +1 -1
  5. package/lib/commands/cml/convert/underwriting-rules.d.ts +41 -2
  6. package/lib/commands/cml/convert/underwriting-rules.js +141 -17
  7. package/lib/commands/cml/convert/underwriting-rules.js.map +1 -1
  8. package/lib/commands/insurance/import/record-updates.d.ts +66 -0
  9. package/lib/commands/insurance/import/record-updates.js +239 -0
  10. package/lib/commands/insurance/import/record-updates.js.map +1 -0
  11. package/lib/shared/cml-operators.d.ts +9 -1
  12. package/lib/shared/cml-operators.js +23 -5
  13. package/lib/shared/cml-operators.js.map +1 -1
  14. package/lib/shared/insurance/confirm-org-changes.d.ts +49 -0
  15. package/lib/shared/insurance/confirm-org-changes.js +43 -0
  16. package/lib/shared/insurance/confirm-org-changes.js.map +1 -0
  17. package/lib/shared/insurance/insurance-cml-merge.d.ts +100 -13
  18. package/lib/shared/insurance/insurance-cml-merge.js +355 -54
  19. package/lib/shared/insurance/insurance-cml-merge.js.map +1 -1
  20. package/lib/shared/insurance/insurance-context-tags.d.ts +124 -0
  21. package/lib/shared/insurance/insurance-context-tags.js +210 -0
  22. package/lib/shared/insurance/insurance-context-tags.js.map +1 -0
  23. package/lib/shared/insurance/insurance-org.d.ts +12 -0
  24. package/lib/shared/insurance/insurance-org.js +38 -2
  25. package/lib/shared/insurance/insurance-org.js.map +1 -1
  26. package/lib/shared/insurance/insurance-rule-convert-command.d.ts +27 -0
  27. package/lib/shared/insurance/insurance-rule-convert-command.js +57 -4
  28. package/lib/shared/insurance/insurance-rule-convert-command.js.map +1 -1
  29. package/lib/shared/insurance/insurance-rule-generator.d.ts +101 -8
  30. package/lib/shared/insurance/insurance-rule-generator.js +293 -42
  31. package/lib/shared/insurance/insurance-rule-generator.js.map +1 -1
  32. package/lib/shared/insurance/models.d.ts +2 -1
  33. package/lib/shared/insurance/planned-change-mapper.d.ts +15 -0
  34. package/lib/shared/insurance/planned-change-mapper.js +51 -0
  35. package/lib/shared/insurance/planned-change-mapper.js.map +1 -0
  36. package/lib/shared/insurance/planned-change.d.ts +83 -0
  37. package/lib/shared/insurance/planned-change.js +74 -0
  38. package/lib/shared/insurance/planned-change.js.map +1 -0
  39. package/lib/shared/insurance/record-update-apply.d.ts +100 -0
  40. package/lib/shared/insurance/record-update-apply.js +303 -0
  41. package/lib/shared/insurance/record-update-apply.js.map +1 -0
  42. package/lib/shared/insurance/record-update-plan.d.ts +104 -0
  43. package/lib/shared/insurance/record-update-plan.js +397 -0
  44. package/lib/shared/insurance/record-update-plan.js.map +1 -0
  45. package/messages/cml.convert.surcharge-rules.md +3 -1
  46. package/messages/cml.convert.underwriting-rules.md +1 -1
  47. package/messages/insurance.import.record-updates.md +176 -0
  48. package/oclif.lock +28 -28
  49. package/oclif.manifest.json +127 -35
  50. 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(apiName), joined by `__`. The Core surcharge engine matches the fired CML rule
17
- * key against that auto-generated RuleKey by exact string, so the CML rule key MUST be pathed too.
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
- /** Splits a `ProductPath` into its ordered Product2 ids (slash-separated). */
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, apiName, stageTransition);
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 declaration = buildConstraintDeclaration(ruleDef);
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
- const scan = blankComments(cml);
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
- * - Before placing a rule's constraint, any of its `referencedAttributes` not already declared
393
- * anywhere in the leaf `type` block are auto-inserted (as `<cmlType> <attrName>;`) right after
394
- * the block's opening brace, and reported via `attributeWarnings` so the caller can log them —
395
- * unlike surcharge merge (which only WARNS about missing attributes, since injecting into the
396
- * curated model there was judged too risky for a `rule(...)` action statement), underwriting
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
- // Warn (but do not auto-insert) when a referenced attribute is not declared in the type
450
- // block — matching the surcharge merge's behavior. The curated model is not mutated for
451
- // attributes; the operator must add them manually before import.
452
- for (const attr of rule.referencedAttributes) {
453
- const bodyScan = scan.slice(block.openIdx, block.closeIdx + 1);
454
- const declRe = new RegExp(`\\b${escapeRegExp(attr.cmlType)}\\s+${escapeRegExp(attr.name)}\\b`);
455
- if (declRe.test(bodyScan))
456
- continue;
457
- attributeWarnings.push(`${rule.recordName}: declaration references '${attr.name}' which is absent from type '${rule.typeName}'`);
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
- * Surfaces (does not auto-fix) rule declarations that reference an attribute not present anywhere in
633
- * the curated model. In merge mode we never inject `string <attr>;` into the curated model, so an
634
- * unknown attribute would fail to compile on import — better to warn the engineer than to mangle the
635
- * Gold Standard. Attributes already present are trusted to be visible via the coverage type hierarchy.
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
- function collectAttributeWarning(baseCml, rule, warnings, baseScan) {
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 = rule.typeName ? collectTypeScopeText(baseCml, rule.typeName, undefined, baseScan) ?? '' : '';
655
- for (const attr of rule.referencedAttributes) {
656
- const present = new RegExp(`\\b${escapeRegExp(attr)}\\b`).test(scope);
657
- if (!present) {
658
- warnings.push(`${rule.recordName}: declaration references '${attr}' which is absent from the model`);
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