@salesforce/plugin-bre-to-cml 1.2.17 → 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 +51 -3
- package/lib/commands/cml/convert/underwriting-rules.js +206 -13
- 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 +198 -5
- package/lib/shared/insurance/insurance-cml-merge.js +504 -28
- 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,5 +1,6 @@
|
|
|
1
1
|
import { CmlConstraint } from '../types/types.js';
|
|
2
|
-
import {
|
|
2
|
+
import { CONSTRAINT_TYPES } from '../constants/constants.js';
|
|
3
|
+
import { buildConstraintDeclaration, collectAttributeTypes, collectEmittedAttributes, collectEmittedContextTags, findUnconvertibleConditions, sanitizeName, buildStageTransition, } from './insurance-rule-generator.js';
|
|
3
4
|
import { quoteSoqlIdList } from './insurance-org.js';
|
|
4
5
|
/**
|
|
5
6
|
* MERGE MODE (insurance-only): instead of building a fresh single-type CML and PATCH-replacing
|
|
@@ -12,8 +13,11 @@ import { quoteSoqlIdList } from './insurance-org.js';
|
|
|
12
13
|
*
|
|
13
14
|
* Why pathed keys: when a ProductSurcharge is persisted as ConstraintEngine the platform
|
|
14
15
|
* auto-generates `RuleKey` as `SC` + sanitize(ProductCode) of every segment in ProductPath (in
|
|
15
|
-
* order) + sanitize(
|
|
16
|
-
*
|
|
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.
|
|
17
21
|
*/
|
|
18
22
|
export const SURCHARGE_RULE_ACTION = 'InsuranceSurchargeRule';
|
|
19
23
|
/**
|
|
@@ -27,8 +31,17 @@ export function buildPathedRuleKey(prefix, pathProductCodes, apiName, stageTrans
|
|
|
27
31
|
parts.push(sanitizeName(apiName));
|
|
28
32
|
return parts.join('__');
|
|
29
33
|
}
|
|
30
|
-
/**
|
|
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
|
+
*/
|
|
31
42
|
export function splitProductPath(productPath) {
|
|
43
|
+
if (!productPath)
|
|
44
|
+
return [];
|
|
32
45
|
return productPath
|
|
33
46
|
.split('/')
|
|
34
47
|
.map((s) => s.trim())
|
|
@@ -75,7 +88,33 @@ export async function fetchProductTypeTags(conn, productIds) {
|
|
|
75
88
|
}
|
|
76
89
|
return idToTag;
|
|
77
90
|
}
|
|
78
|
-
|
|
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
|
+
}
|
|
117
|
+
export function escapeRegExp(value) {
|
|
79
118
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
80
119
|
}
|
|
81
120
|
/**
|
|
@@ -85,7 +124,7 @@ function escapeRegExp(value) {
|
|
|
85
124
|
* a CRLF-curated model stays byte-clean after splicing. The L2 replace path preserves the line
|
|
86
125
|
* ending of the REPLACED span directly, so this only matters for new inserts.
|
|
87
126
|
*/
|
|
88
|
-
function detectDominantLineEnding(cml) {
|
|
127
|
+
export function detectDominantLineEnding(cml) {
|
|
89
128
|
const crlfCount = (cml.match(/\r\n/g) ?? []).length;
|
|
90
129
|
// Subtract CRLFs from the total LF count to get the BARE-LF count (an LF preceded by CR is part of a CRLF).
|
|
91
130
|
const totalLf = (cml.match(/\n/g) ?? []).length;
|
|
@@ -99,7 +138,7 @@ function detectDominantLineEnding(cml) {
|
|
|
99
138
|
* comments, and a stray `}` inside one (or inside a quoted rule value) must NOT be mistaken for the
|
|
100
139
|
* structural close, which would return a too-early closeIdx and splice a new rule mid-statement.
|
|
101
140
|
*/
|
|
102
|
-
function matchClosingBrace(cml, openIdx) {
|
|
141
|
+
export function matchClosingBrace(cml, openIdx) {
|
|
103
142
|
let depth = 0;
|
|
104
143
|
let inString = false;
|
|
105
144
|
let inLineComment = false;
|
|
@@ -188,6 +227,28 @@ function findTypeBlock(cml, typeName, scan = blankComments(cml)) {
|
|
|
188
227
|
return undefined;
|
|
189
228
|
return { openIdx, closeIdx };
|
|
190
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
|
+
}
|
|
191
252
|
/** Builds the `rule(...)` statement for a surcharge, using the same constraint generator as build mode. */
|
|
192
253
|
export function buildSurchargeRuleStatement(declaration, ruleKey) {
|
|
193
254
|
return CmlConstraint.createRuleConstraint(declaration, SURCHARGE_RULE_ACTION, ruleKey, 'True').generateCml();
|
|
@@ -195,22 +256,99 @@ export function buildSurchargeRuleStatement(declaration, ruleKey) {
|
|
|
195
256
|
/**
|
|
196
257
|
* Prepares the pathed-rule descriptors for a set of parsed surcharge records.
|
|
197
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}).
|
|
198
261
|
*/
|
|
199
|
-
export function buildPathedSurchargeRules(prefix, ruleDefs, productIdToCode, productIdToType) {
|
|
262
|
+
export function buildPathedSurchargeRules(prefix, ruleDefs, productIdToCode, productIdToType, options = {}) {
|
|
200
263
|
return ruleDefs.map(({ record, ruleDef }) => {
|
|
201
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);
|
|
202
271
|
const segments = splitProductPath(record.ProductPath);
|
|
203
272
|
const pathCodes = segments.map((id) => productIdToCode.get(id) ?? id);
|
|
204
273
|
const stageTransition = buildStageTransition(ruleDef.underwritingRuleGroup);
|
|
205
|
-
const ruleKey = buildPathedRuleKey(prefix, pathCodes,
|
|
274
|
+
const ruleKey = buildPathedRuleKey(prefix, pathCodes, keyLeaf, stageTransition);
|
|
206
275
|
const leafProductId = segments[segments.length - 1];
|
|
207
276
|
const typeName = leafProductId ? productIdToType.get(leafProductId) : undefined;
|
|
208
|
-
const
|
|
277
|
+
const tagTypes = options.contextTagBindings;
|
|
278
|
+
const declaration = buildConstraintDeclaration(ruleDef, options.attributeDataTypes, tagTypes);
|
|
209
279
|
const statement = buildSurchargeRuleStatement(declaration, ruleKey);
|
|
210
280
|
// M7: only attributes from conditions that actually emitted CML (non-null buildConditionExpression)
|
|
211
281
|
// count as "referenced". Attributes from conditions the safe-literal guard / unknown-operator
|
|
212
282
|
// filter dropped never appear in the declaration, so warning about them would be spurious noise.
|
|
213
|
-
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 }));
|
|
290
|
+
return {
|
|
291
|
+
recordId: record.Id,
|
|
292
|
+
recordName: record.Name,
|
|
293
|
+
apiName,
|
|
294
|
+
ruleKey,
|
|
295
|
+
pathProductCodes: pathCodes,
|
|
296
|
+
typeName,
|
|
297
|
+
statement,
|
|
298
|
+
referencedAttributes,
|
|
299
|
+
contextTagDeclarations,
|
|
300
|
+
unconvertibleReason: joinReasons(findUnconvertibleConditions(ruleDef, options.attributeDataTypes, tagTypes)),
|
|
301
|
+
};
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Builds the `constraint <name> = (...)` statement for underwriting eligibility, using the same
|
|
306
|
+
* constraint generator as build mode's constraint-form branch (see `buildCmlModel`).
|
|
307
|
+
*/
|
|
308
|
+
export function buildUnderwritingConstraintStatement(constraintName, declaration, label) {
|
|
309
|
+
const constraint = new CmlConstraint(CONSTRAINT_TYPES.CONSTRAINT, declaration, `"${label}"`);
|
|
310
|
+
constraint.name = constraintName;
|
|
311
|
+
return constraint.generateCml();
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Builds the constraint-form rule descriptors for underwriting eligibility, mirroring
|
|
315
|
+
* {@link buildPathedSurchargeRules} but emitting a named `constraint` statement (matching the
|
|
316
|
+
* constraint-form branch of `buildCmlModel` in insurance-rule-generator.ts) instead of a `rule(...)`
|
|
317
|
+
* action statement. `productIdToCode` and `productIdToType` must already cover every ProductPath
|
|
318
|
+
* segment.
|
|
319
|
+
*/
|
|
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) {
|
|
324
|
+
return ruleDefs.map(({ record, ruleDef }) => {
|
|
325
|
+
const apiName = ruleDef.apiName ?? record.Name;
|
|
326
|
+
const segments = splitProductPath(record.ProductPath);
|
|
327
|
+
const pathCodes = segments.map((id) => productIdToCode.get(id) ?? id);
|
|
328
|
+
const stageTransition = buildStageTransition(ruleDef.underwritingRuleGroup);
|
|
329
|
+
const ruleKey = buildPathedRuleKey(keyPrefix, pathCodes, apiName, stageTransition);
|
|
330
|
+
const leafProductId = segments[segments.length - 1];
|
|
331
|
+
const typeName = leafProductId ? productIdToType.get(leafProductId) : undefined;
|
|
332
|
+
const declaration = buildConstraintDeclaration(ruleDef, attributeDataTypes);
|
|
333
|
+
// Mirrors buildCmlModel's constraint naming: sanitized apiName, with the stage transition
|
|
334
|
+
// appended so two rules sharing an apiName under the same product (gated on different
|
|
335
|
+
// transitions) don't collide.
|
|
336
|
+
const constraintName = sanitizeName(stageTransition ? `${apiName}_${stageTransition}` : apiName);
|
|
337
|
+
const statement = buildUnderwritingConstraintStatement(constraintName, declaration, `${constraintLabel}: ${record.Name}`);
|
|
338
|
+
// Same emitted-vs-collected reconciliation buildPathedSurchargeRules uses for
|
|
339
|
+
// referencedAttributes, but ALSO carrying each attribute's derived CML type:
|
|
340
|
+
// collectAttributeTypes keys its map by the RAW attribute name, whereas
|
|
341
|
+
// collectEmittedAttributes returns SANITIZED names, so we sanitize each raw key before
|
|
342
|
+
// matching it against the emitted-name set.
|
|
343
|
+
const emittedSanitized = collectEmittedAttributes([{ ruleDef }], attributeDataTypes);
|
|
344
|
+
const attrTypesByRawName = collectAttributeTypes([{ ruleDef }], attributeDataTypes);
|
|
345
|
+
const referencedAttributes = [];
|
|
346
|
+
for (const [rawName, cmlType] of attrTypesByRawName) {
|
|
347
|
+
const sanitized = sanitizeName(rawName);
|
|
348
|
+
if (emittedSanitized.has(sanitized)) {
|
|
349
|
+
referencedAttributes.push({ name: sanitized, cmlType });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
214
352
|
return {
|
|
215
353
|
recordId: record.Id,
|
|
216
354
|
recordName: record.Name,
|
|
@@ -218,8 +356,10 @@ export function buildPathedSurchargeRules(prefix, ruleDefs, productIdToCode, pro
|
|
|
218
356
|
ruleKey,
|
|
219
357
|
pathProductCodes: pathCodes,
|
|
220
358
|
typeName,
|
|
359
|
+
constraintName,
|
|
221
360
|
statement,
|
|
222
361
|
referencedAttributes,
|
|
362
|
+
unconvertibleReason: joinReasons(findUnconvertibleConditions(ruleDef, attributeDataTypes)),
|
|
223
363
|
};
|
|
224
364
|
});
|
|
225
365
|
}
|
|
@@ -254,6 +394,12 @@ export function mergeSurchargeRules(existingCml, rules) {
|
|
|
254
394
|
// just-inserted statement (which would silently drop the second record's distinct declaration).
|
|
255
395
|
const placedKeys = new Set();
|
|
256
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
|
+
}
|
|
257
403
|
if (placedKeys.has(rule.ruleKey)) {
|
|
258
404
|
skips.push({
|
|
259
405
|
rule,
|
|
@@ -281,7 +427,73 @@ export function mergeSurchargeRules(existingCml, rules) {
|
|
|
281
427
|
// [Fix #1] One comment-blanked view of the current `cml` reused by both the replace-anchor
|
|
282
428
|
// search and the type-block search this iteration. Recomputed per-iteration because a prior
|
|
283
429
|
// rule's splice may have mutated `cml`.
|
|
284
|
-
|
|
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
|
+
}
|
|
285
497
|
// C2/M4/L1: only a REAL surcharge `rule(...)` statement carrying this exact key in the
|
|
286
498
|
// action-scope slot counts as "present". A bare quoted-key substring inside an unrelated rule's
|
|
287
499
|
// value, a longer key, or a comment must NOT trigger a destructive line replace.
|
|
@@ -296,9 +508,79 @@ export function mergeSurchargeRules(existingCml, rules) {
|
|
|
296
508
|
cml = cml.slice(0, stmt.start) + rule.statement + cml.slice(stmt.end);
|
|
297
509
|
placements.push({ rule, status: 'replaced' });
|
|
298
510
|
placedKeys.add(rule.ruleKey);
|
|
299
|
-
collectAttributeWarning(baseCml, rule, attributeWarnings, baseScan);
|
|
300
511
|
continue;
|
|
301
512
|
}
|
|
513
|
+
// Insert before the closing brace, indented one level (4 spaces), with a leading blank line.
|
|
514
|
+
// [Fix #4] Use the dominant line ending of the original model, not a hardcoded `\n`.
|
|
515
|
+
const insertion = `${eol} ${rule.statement}${eol}`;
|
|
516
|
+
cml = cml.slice(0, block.closeIdx) + insertion + cml.slice(block.closeIdx);
|
|
517
|
+
placements.push({ rule, status: 'inserted' });
|
|
518
|
+
placedKeys.add(rule.ruleKey);
|
|
519
|
+
}
|
|
520
|
+
return { mergedCml: cml, placements, skips, attributeWarnings };
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Merges the underwriting `constraint <name> = (...)` statements into the existing CML text.
|
|
524
|
+
* Mirrors {@link mergeSurchargeRules}'s insert/replace/skip shape, but for the constraint form
|
|
525
|
+
* underwriting eligibility uses instead of a `rule(...)` action statement:
|
|
526
|
+
*
|
|
527
|
+
* - If a constraint with the same name already exists in the rule's leaf `type` block, its full
|
|
528
|
+
* statement (from the `constraint` keyword through the terminating `;`) is replaced in place
|
|
529
|
+
* (idempotent — re-running the merge with the same input reproduces the same output).
|
|
530
|
+
* - Otherwise the statement is inserted just before the closing brace of the leaf `type` block.
|
|
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.
|
|
536
|
+
*
|
|
537
|
+
* Rules with a blank ProductPath, no resolved leaf type tag, an ambiguous/duplicate type block, or
|
|
538
|
+
* an intra-run duplicate (typeName, constraintName) pair are skipped (reported, never silently
|
|
539
|
+
* dropped) — never throws on a single rule's failure.
|
|
540
|
+
*/
|
|
541
|
+
export function mergeUnderwritingConstraints(existingCml, rules) {
|
|
542
|
+
let cml = existingCml;
|
|
543
|
+
const placements = [];
|
|
544
|
+
const skips = [];
|
|
545
|
+
const attributeWarnings = [];
|
|
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);
|
|
552
|
+
// Mirrors mergeSurchargeRules' placedKeys guard: a second rule resolving to the same
|
|
553
|
+
// (typeName, constraintName) pair must be reported as a collision skip, not treated as an
|
|
554
|
+
// idempotent replace of the first rule's just-placed statement.
|
|
555
|
+
const placedNames = new Set();
|
|
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
|
+
}
|
|
562
|
+
if (rule.pathProductCodes.length === 0) {
|
|
563
|
+
skips.push({
|
|
564
|
+
rule,
|
|
565
|
+
reason: `empty ProductPath for ${rule.recordName}; refusing to merge a non-pathed underwriting rule`,
|
|
566
|
+
});
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (!rule.typeName) {
|
|
570
|
+
skips.push({ rule, reason: `no CML type tag found for the leaf product of ${rule.recordName}` });
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
const placementKey = `${rule.typeName}::${rule.constraintName}`;
|
|
574
|
+
if (placedNames.has(placementKey)) {
|
|
575
|
+
skips.push({
|
|
576
|
+
rule,
|
|
577
|
+
reason: `duplicate constraint name '${rule.constraintName}' in type '${rule.typeName}' collides with another rule in this run (${rule.recordName} skipped)`,
|
|
578
|
+
});
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
// Recomputed per-iteration because a prior rule's splice (attribute insert, constraint
|
|
582
|
+
// replace/insert) may have mutated `cml`.
|
|
583
|
+
const scan = blankComments(cml);
|
|
302
584
|
const block = findTypeBlock(cml, rule.typeName, scan);
|
|
303
585
|
if (!block) {
|
|
304
586
|
skips.push({ rule, reason: `type block '${rule.typeName}' not found in existing model` });
|
|
@@ -311,16 +593,64 @@ export function mergeSurchargeRules(existingCml, rules) {
|
|
|
311
593
|
});
|
|
312
594
|
continue;
|
|
313
595
|
}
|
|
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;
|
|
613
|
+
}
|
|
614
|
+
const stmt = findConstraintStatement(cml, block, rule.constraintName, scan);
|
|
615
|
+
if (stmt) {
|
|
616
|
+
// Splice ONLY the matched statement span (`constraint ... ;`), mirroring the precise-span
|
|
617
|
+
// splice mergeSurchargeRules uses for its rule(...) replace path.
|
|
618
|
+
cml = cml.slice(0, stmt.start) + rule.statement + cml.slice(stmt.end);
|
|
619
|
+
placements.push({ rule, status: 'replaced' });
|
|
620
|
+
placedNames.add(placementKey);
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
314
623
|
// Insert before the closing brace, indented one level (4 spaces), with a leading blank line.
|
|
315
|
-
// [Fix #4] Use the dominant line ending of the original model, not a hardcoded `\n`.
|
|
316
624
|
const insertion = `${eol} ${rule.statement}${eol}`;
|
|
317
625
|
cml = cml.slice(0, block.closeIdx) + insertion + cml.slice(block.closeIdx);
|
|
318
626
|
placements.push({ rule, status: 'inserted' });
|
|
319
|
-
|
|
320
|
-
collectAttributeWarning(baseCml, rule, attributeWarnings, baseScan);
|
|
627
|
+
placedNames.add(placementKey);
|
|
321
628
|
}
|
|
322
629
|
return { mergedCml: cml, placements, skips, attributeWarnings };
|
|
323
630
|
}
|
|
631
|
+
/**
|
|
632
|
+
* Finds the single `constraint <name> = (...)` statement carrying `constraintName` INSIDE the
|
|
633
|
+
* given `block` (the rule's leaf `type` block) and returns the precise start/end offsets of the
|
|
634
|
+
* whole statement (from the `constraint` keyword through the terminating `;`), so the caller can
|
|
635
|
+
* splice only that statement in place. Scoped to `block` so a same-named constraint in a sibling
|
|
636
|
+
* type block is never mistaken for a match. Runs against the comment-blanked `scan` view (see
|
|
637
|
+
* {@link findSurchargeStatement}'s companion rationale) so a constraint-shaped string sitting
|
|
638
|
+
* inside a comment can't be clobbered.
|
|
639
|
+
*/
|
|
640
|
+
function findConstraintStatement(cml, block, constraintName, scan) {
|
|
641
|
+
const bodyScan = scan.slice(block.openIdx, block.closeIdx + 1);
|
|
642
|
+
const anchor = new RegExp(`constraint\\s+${escapeRegExp(constraintName)}\\s*=\\s*\\(`);
|
|
643
|
+
const m = anchor.exec(bodyScan);
|
|
644
|
+
if (!m)
|
|
645
|
+
return undefined;
|
|
646
|
+
const start = block.openIdx + m.index;
|
|
647
|
+
const semi = findStructuralSemicolon(scan, start + m[0].length);
|
|
648
|
+
if (semi === undefined) {
|
|
649
|
+
// Malformed unterminated constraint(...) — refuse to splice rather than guessing.
|
|
650
|
+
return undefined;
|
|
651
|
+
}
|
|
652
|
+
return { start, end: semi + 1 };
|
|
653
|
+
}
|
|
324
654
|
/**
|
|
325
655
|
* C2/M4/L1: finds the single-line surcharge `rule(...)` statement that carries `ruleKey` in the
|
|
326
656
|
* action-scope slot — i.e. `rule(<decl>, "InsuranceSurchargeRule", "<ruleKey>", ...`. Only such a
|
|
@@ -399,7 +729,7 @@ function findStructuralSemicolon(scan, from) {
|
|
|
399
729
|
* literals are left intact: the anchor itself requires the literal `"InsuranceSurchargeRule"` action
|
|
400
730
|
* token, so a same-key VALUE inside another rule's string still won't satisfy the action-scope shape.
|
|
401
731
|
*/
|
|
402
|
-
function blankComments(cml) {
|
|
732
|
+
export function blankComments(cml) {
|
|
403
733
|
const out = cml.split('');
|
|
404
734
|
let inString = false;
|
|
405
735
|
let inLineComment = false;
|
|
@@ -454,16 +784,153 @@ function blankComments(cml) {
|
|
|
454
784
|
return out.join('');
|
|
455
785
|
}
|
|
456
786
|
/**
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
* unknown attribute
|
|
460
|
-
*
|
|
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.
|
|
461
797
|
*
|
|
462
798
|
* IMPORTANT: `baseCml` must be the ORIGINAL model text, captured before any surcharge statement was
|
|
463
799
|
* spliced in. Checking the post-insertion text would always find the attribute inside the rule's own
|
|
464
800
|
* just-inserted declaration (which sanitizes attribute names identically), suppressing every warning.
|
|
465
801
|
*/
|
|
466
|
-
|
|
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 = [];
|
|
467
934
|
// H5/M3: scope the presence check to the leaf type block plus its `: Parent` ancestry, with
|
|
468
935
|
// comments and string literals stripped. CML attribute visibility is hierarchy-scoped, so an
|
|
469
936
|
// unscoped whole-file `\battr\b` test gives false negatives (an attribute named only in a comment,
|
|
@@ -476,13 +943,22 @@ function collectAttributeWarning(baseCml, rule, warnings, baseScan) {
|
|
|
476
943
|
// (that re-introduces the sibling-type false negative on exactly the records that hit the replace
|
|
477
944
|
// path before type resolution). Fail VISIBLE instead: treat the scope as empty so an attribute we
|
|
478
945
|
// cannot prove visible is reported, never silently suppressed.
|
|
479
|
-
const scope =
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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);
|
|
485
960
|
}
|
|
961
|
+
return absent;
|
|
486
962
|
}
|
|
487
963
|
/**
|
|
488
964
|
* Removes `//` line comments, `/* *\/` block comments, and double-quoted string-literal CONTENTS
|