babelfhir-ts 1.5.20 → 1.5.21
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/out/src/cli/updateCommand.js +19 -12
- package/out/src/generator/core/extensionSdReader.js +99 -0
- package/out/src/generator/core/utils.js +57 -2
- package/out/src/generator/emitters/class/classTemplate.js +8 -2
- package/out/src/generator/emitters/client/clientGenerator.js +22 -6
- package/out/src/generator/emitters/client/smartAuthGenerator.js +4 -1
- package/out/src/generator/emitters/interface/backboneSliceTyping.js +2 -2
- package/out/src/generator/emitters/interface/postProcessExtensions.js +1 -0
- package/out/src/generator/emitters/validator/closedSlicingValidation.js +146 -0
- package/out/src/generator/emitters/validator/sliceExtensionValidation.js +200 -35
- package/out/src/generator/emitters/validator/sliceValidatorGenerator.js +98 -14
- package/out/src/generator/emitters/validator/subExtensionScope.js +100 -0
- package/out/src/generator/emitters/validator/validatorConstraintBuilders.js +13 -1
- package/out/src/generator/emitters/validator/validatorFieldBuilders.js +218 -10
- package/out/src/generator/emitters/validator/validatorGenerator.js +4 -2
- package/out/src/generator/emitters/validator/validatorRuntime.js +17 -7
- package/out/src/generator/index.js +21 -11
- package/out/src/generator/parser/sdParser.js +1 -0
- package/out/src/generator/sdProcessor.js +209 -21
- package/out/src/generator/sdProcessorRequiredFields.js +32 -7
- package/package.json +2 -1
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
* Extension and ValueSet slice validation strategies.
|
|
3
3
|
*/
|
|
4
4
|
import { safeAccessor, safeList, sliceElseBranch, detectNestedArray, findArrayAncestor, isArrayPath, FHIR_PRIMITIVE_STRING_TYPES } from './sliceValidatorUtils.js';
|
|
5
|
+
import { stripVersionFromCanonicalUrl } from '../../core/utils.js';
|
|
6
|
+
import { buildSubExtensionScope, renderScopeHops } from './subExtensionScope.js';
|
|
7
|
+
/**
|
|
8
|
+
* Read pre-resolved Extension SD metadata for a canonical.
|
|
9
|
+
*
|
|
10
|
+
* The map is keyed by the version-stripped canonical because a slice names its
|
|
11
|
+
* extension in two spellings — `type.profile` with a `|x.y.z` suffix, the pinned
|
|
12
|
+
* `url` child without one — and either may reach this lookup. Reading with the
|
|
13
|
+
* raw url missed every versioned entry, and a miss is indistinguishable from an
|
|
14
|
+
* unreadable SD, so the emitter silently skipped the slice.
|
|
15
|
+
*/
|
|
16
|
+
export function lookupExtensionMetadata(metadata, extensionUrl) {
|
|
17
|
+
return metadata?.get(stripVersionFromCanonicalUrl(extensionUrl));
|
|
18
|
+
}
|
|
5
19
|
export function generateValueSetValidation(slice, baseName, relPath, errorPath, sliceLabel, varName, min, minText, getValueSetForBinding, valueSetImports, fields, out) {
|
|
6
20
|
const vsInfo = getValueSetForBinding(slice.binding.uri);
|
|
7
21
|
const vsInfoFull = vsInfo && !('systemOnly' in vsInfo && vsInfo.systemOnly) ? vsInfo : undefined;
|
|
@@ -216,17 +230,12 @@ export function generateExistsExtensionValidation(slice, relPath, errorPath, sli
|
|
|
216
230
|
const remainingAccessor = remaining.includes('.') ? remaining.split('.').join('?.') : remaining;
|
|
217
231
|
out.push(`
|
|
218
232
|
// Required slice validation for ${relPath}:${sliceLabel} (exists extension discriminator, nested in array)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
);
|
|
226
|
-
if (${varName}Matched.length < ${min}) {
|
|
227
|
-
errors.push("No elements matched required slice: '${errorPath}:${sliceLabel}'");
|
|
228
|
-
}
|
|
229
|
-
}
|
|
233
|
+
for (const _el of arr(${safeAccessor(arrayParent)})) {
|
|
234
|
+
const ${varName}Matched = elements(at(_el, ${remainingAccessor.split('?.').map(s => `'${s}'`).join(', ')})).filter((item) =>
|
|
235
|
+
elements(at(item, 'extension')).some((ext) => ext.url === "${extensionUrl}")
|
|
236
|
+
);
|
|
237
|
+
if (${varName}Matched.length < ${min}) {
|
|
238
|
+
errors.push("No elements matched required slice: '${errorPath}:${sliceLabel}'");
|
|
230
239
|
}
|
|
231
240
|
}`);
|
|
232
241
|
return;
|
|
@@ -278,12 +287,17 @@ export function detectExtensionUrlSlice(slice, relPath, fields) {
|
|
|
278
287
|
}
|
|
279
288
|
export function generateExtensionUrlSliceValidation(slice, relPath, errorPath, sliceLabel, varName, min, extensionUrl, fieldPathMap, arrayFieldPaths, out, ctx) {
|
|
280
289
|
const baseFieldIsArray = isArrayPath(relPath, fieldPathMap, arrayFieldPaths);
|
|
281
|
-
const extMeta = ctx?.extensionMetadata
|
|
290
|
+
const extMeta = lookupExtensionMetadata(ctx?.extensionMetadata, extensionUrl);
|
|
282
291
|
// Narrow extension metadata value types with profile-level constraints.
|
|
283
292
|
// The profile differential may restrict value[x] types more strictly than
|
|
284
293
|
// the extension SD (e.g., extension allows [boolean, url] but profile allows only [url]).
|
|
294
|
+
//
|
|
295
|
+
// An empty type list is not a list to narrow: it means the extension is complex and
|
|
296
|
+
// forbids a value outright. The profile still carries the inherited value[x] element
|
|
297
|
+
// with its full base type list, so narrowing would read that as "any of ~50 types
|
|
298
|
+
// allowed" and cancel the prohibition.
|
|
285
299
|
let effectiveMeta = extMeta;
|
|
286
|
-
if (extMeta && ctx?.fields && slice.elementId) {
|
|
300
|
+
if (extMeta && extMeta.valueTypes.length > 0 && ctx?.fields && slice.elementId) {
|
|
287
301
|
const sliceElemId = slice.elementId.replace(/^[^.]+\./, '');
|
|
288
302
|
const valueChild = ctx.fields.find(f => {
|
|
289
303
|
if (!f.elementId || !f.typeOptions?.length)
|
|
@@ -296,22 +310,55 @@ export function generateExtensionUrlSliceValidation(slice, relPath, errorPath, s
|
|
|
296
310
|
effectiveMeta = { ...extMeta, valueTypes: profileTypes };
|
|
297
311
|
}
|
|
298
312
|
}
|
|
313
|
+
// An optional slice asserts no count, but a present instance must still conform.
|
|
314
|
+
// Omitting the assertion rather than emitting `length < 0` keeps a branch that can
|
|
315
|
+
// never fire out of the generated output.
|
|
316
|
+
const countAssertion = min > 0
|
|
317
|
+
? `
|
|
318
|
+
if (${varName}Matches.length < ${min}) {
|
|
319
|
+
errors.push("Slice '${errorPath}:${sliceLabel}': a matching slice is required, but not found");
|
|
320
|
+
}`
|
|
321
|
+
: '';
|
|
322
|
+
const headline = min > 0 ? 'Required' : 'Optional';
|
|
323
|
+
// With no count to assert and no resolved metadata there is nothing to check, and
|
|
324
|
+
// the block would filter a list and discard it. That happens whenever the
|
|
325
|
+
// extension SD could not be read, so emit only when the slice earns it.
|
|
326
|
+
if (min === 0 && !generateExtensionValueChecks('_probe', extensionUrl, effectiveMeta, ctx, '')) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
// A sub-extension of another extension slice needs the parent walked and filtered
|
|
330
|
+
// first; the generic paths below address it from the resource root and cannot.
|
|
331
|
+
const subScope = ctx?.fields
|
|
332
|
+
? buildSubExtensionScope(slice, ctx.fields)
|
|
333
|
+
: null;
|
|
334
|
+
if (subScope) {
|
|
335
|
+
const scopedElemId = (slice.elementId || '').replace(/^[^.]+\./, '');
|
|
336
|
+
const scopedErrorPath = scopedElemId || `${errorPath}:${sliceLabel}`;
|
|
337
|
+
// The slot the slice lives in: its own last segment, minus the slice qualifier.
|
|
338
|
+
const leafProp = scopedElemId.split('.').pop().split(':')[0];
|
|
339
|
+
const body = renderScopeHops(subScope, (container, indent) => {
|
|
340
|
+
const matches = `${varName}Matches`;
|
|
341
|
+
let inner = `\n${indent}const ${matches} = elements(at(${container}, '${leafProp}')).filter((item) => item.url === "${extensionUrl}");`;
|
|
342
|
+
if (min > 0) {
|
|
343
|
+
inner += `\n${indent}if (${matches}.length < ${min}) {`;
|
|
344
|
+
inner += `\n${indent} errors.push("Slice '${scopedErrorPath}': a matching slice is required, but not found");`;
|
|
345
|
+
inner += `\n${indent}}`;
|
|
346
|
+
}
|
|
347
|
+
inner += generateExtensionValueChecks(matches, extensionUrl, effectiveMeta, ctx, indent);
|
|
348
|
+
return inner;
|
|
349
|
+
});
|
|
350
|
+
out.push(`
|
|
351
|
+
// ${headline} extension slice validation for ${scopedErrorPath} (sub-extension, scoped to its parent slice)${body}`);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
299
354
|
const nested = findArrayAncestor(relPath, fieldPathMap, arrayFieldPaths);
|
|
300
355
|
if (nested) {
|
|
301
356
|
const { arrayParent, remaining } = nested;
|
|
302
357
|
const remainingAccessor = remaining.includes('.') ? remaining.split('.').join('?.') : remaining;
|
|
303
358
|
out.push(`
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const _extArr = elements(at(_el, ${remainingAccessor.split('?.').map(s => `'${s}'`).join(', ')}));
|
|
308
|
-
if (Array.isArray(_extArr)) {
|
|
309
|
-
const ${varName}Matches = _extArr.filter((item) => item.url === "${extensionUrl}");
|
|
310
|
-
if (${varName}Matches.length < ${min}) {
|
|
311
|
-
errors.push("Slice '${errorPath}:${sliceLabel}': a matching slice is required, but not found");
|
|
312
|
-
}${generateExtensionValueChecks(varName + 'Matches', extensionUrl, effectiveMeta, ctx, ' ')}
|
|
313
|
-
}
|
|
314
|
-
}
|
|
359
|
+
// ${headline} extension slice validation for ${relPath}:${sliceLabel} (url discriminator, nested in array)
|
|
360
|
+
for (const _el of arr(${safeAccessor(arrayParent)})) {
|
|
361
|
+
const ${varName}Matches = elements(at(_el, ${remainingAccessor.split('?.').map(s => `'${s}'`).join(', ')})).filter((item) => item.url === "${extensionUrl}");${countAssertion}${generateExtensionValueChecks(varName + 'Matches', extensionUrl, effectiveMeta, ctx, ' ')}
|
|
315
362
|
}`);
|
|
316
363
|
return;
|
|
317
364
|
}
|
|
@@ -319,41 +366,159 @@ export function generateExtensionUrlSliceValidation(slice, relPath, errorPath, s
|
|
|
319
366
|
const pathParts = relPath.split('.');
|
|
320
367
|
const parentPath = pathParts.length > 1 ? pathParts.slice(0, -1).join('.') : null;
|
|
321
368
|
const parentGuard = parentPath ? `${safeAccessor(parentPath)} && ` : '';
|
|
322
|
-
|
|
369
|
+
// The absent branch asserts the slice is missing. For min=0 that is simply false,
|
|
370
|
+
// and on an empty resource the element is never an array, so it fired every time:
|
|
371
|
+
// this is what cost empty parity 613/613 -> 599/613 across mcode, ndh, pas and
|
|
372
|
+
// physical-activity. Two earlier attempts patched only the nested path above and
|
|
373
|
+
// never reached this one.
|
|
374
|
+
const absentBranch = min > 0
|
|
375
|
+
? ` ${parentPath ? `else if (${safeAccessor(parentPath)})` : 'else'} {
|
|
376
|
+
errors.push("Slice '${errorPath}:${sliceLabel}': a matching slice is required, but not found");
|
|
377
|
+
}`
|
|
378
|
+
: '';
|
|
379
|
+
const arrayCountAssertion = min > 0
|
|
380
|
+
? `
|
|
381
|
+
if (${varName}Elements.length < ${min}) {
|
|
382
|
+
errors.push("Slice '${errorPath}:${sliceLabel}': a matching slice is required, but not found");
|
|
383
|
+
}`
|
|
384
|
+
: '';
|
|
323
385
|
out.push(`
|
|
324
|
-
//
|
|
386
|
+
// ${headline} extension slice validation for ${relPath}:${sliceLabel} (url discriminator)
|
|
325
387
|
if (${parentGuard}Array.isArray(${safeAccessor(relPath)})) {
|
|
326
388
|
const ${varName}Elements = ${safeList(relPath)}.filter((item) =>
|
|
327
389
|
item.url === "${extensionUrl}"
|
|
328
|
-
)
|
|
329
|
-
|
|
330
|
-
errors.push("Slice '${errorPath}:${sliceLabel}': a matching slice is required, but not found");
|
|
331
|
-
}${generateExtensionValueChecks(varName + 'Elements', extensionUrl, effectiveMeta, ctx, ' ')}
|
|
332
|
-
} ${elseGuard} {
|
|
333
|
-
errors.push("Slice '${errorPath}:${sliceLabel}': a matching slice is required, but not found");
|
|
334
|
-
}`);
|
|
390
|
+
);${arrayCountAssertion}${generateExtensionValueChecks(varName + 'Elements', extensionUrl, effectiveMeta, ctx, ' ')}
|
|
391
|
+
}${absentBranch}`);
|
|
335
392
|
}
|
|
336
|
-
else {
|
|
393
|
+
else if (min > 0) {
|
|
337
394
|
out.push(`
|
|
338
395
|
// Required extension slice validation for ${relPath}:${sliceLabel} (url discriminator)
|
|
339
396
|
if (!${safeAccessor(relPath)} || (${safeAccessor(relPath)} as { url?: string }).url !== "${extensionUrl}") {
|
|
340
397
|
errors.push("Slice '${errorPath}:${sliceLabel}': a matching slice is required, but not found");
|
|
341
398
|
}`);
|
|
342
399
|
}
|
|
400
|
+
// A non-array optional slice has nothing to assert: its only check was that the
|
|
401
|
+
// slice is present, which min=0 does not require.
|
|
343
402
|
}
|
|
344
403
|
// ── Extension value type + binding validation helper ─────────────────────
|
|
345
404
|
/** Map FHIR type code → JSON property name suffix (e.g., 'code' → 'Code'). */
|
|
346
405
|
function valuePropertySuffix(typeCode) {
|
|
347
406
|
return typeCode.charAt(0).toUpperCase() + typeCode.slice(1);
|
|
348
407
|
}
|
|
408
|
+
/** A ValueSet resolved to an emitted validator function, or nothing to check against. */
|
|
409
|
+
function resolveBindingValueSet(binding, ctx) {
|
|
410
|
+
if (!binding || !ctx)
|
|
411
|
+
return undefined;
|
|
412
|
+
if (binding.strength !== 'required' && binding.strength !== 'extensible')
|
|
413
|
+
return undefined;
|
|
414
|
+
const vsInfo = ctx.getValueSetForBinding(binding.uri);
|
|
415
|
+
// A system-only ValueSet cannot decide membership of a code, only of its system.
|
|
416
|
+
if (!vsInfo || ('systemOnly' in vsInfo && vsInfo.systemOnly))
|
|
417
|
+
return undefined;
|
|
418
|
+
const full = vsInfo;
|
|
419
|
+
ctx.valueSetImports.set(full.sanitizedName, `./valuesets/ValueSet-${full.sanitizedName}.js`);
|
|
420
|
+
return full;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Check a sub-extension's value against its bound ValueSet.
|
|
424
|
+
*
|
|
425
|
+
* `elemVar` is `unknown`, so the value is reached through `at()` and only the shape
|
|
426
|
+
* actually read is asserted. Codings are the case KBV needs — the Payor extension
|
|
427
|
+
* binds `Extension.extension:Kostentraegertyp.value[x]:valueCoding` required — and
|
|
428
|
+
* primitives and CodeableConcepts are handled alongside so one helper covers the
|
|
429
|
+
* shapes an extension value can take.
|
|
430
|
+
*/
|
|
431
|
+
function subExtensionBindingCheck(sub, vs, elemVar, indent) {
|
|
432
|
+
const lines = [];
|
|
433
|
+
const notFound = (valueExpr) => `errors.push("The value provided ('" + ${valueExpr} + "') was not found in the value set '${vs.displayName}' (${sub.binding.uri})");`;
|
|
434
|
+
if (sub.valueTypes.includes('Coding')) {
|
|
435
|
+
lines.push(`${indent}const _seCode = at(${elemVar}, 'valueCoding', 'code');`);
|
|
436
|
+
lines.push(`${indent}if (typeof _seCode === 'string' && !${vs.validatorFn}(_seCode)) {`);
|
|
437
|
+
lines.push(`${indent} ${notFound('_seCode')}`);
|
|
438
|
+
lines.push(`${indent}}`);
|
|
439
|
+
}
|
|
440
|
+
else if (sub.valueTypes.includes('CodeableConcept')) {
|
|
441
|
+
lines.push(`${indent}const _seCodings = codings(at(${elemVar}, 'valueCodeableConcept'));`);
|
|
442
|
+
lines.push(`${indent}if (_seCodings.length > 0 && !_seCodings.some((c) => !!c.code && ${vs.validatorFn}(c.code))) {`);
|
|
443
|
+
lines.push(`${indent} errors.push("None of the codings in the extension value are from the value set '${vs.displayName}' (${sub.binding.uri})");`);
|
|
444
|
+
lines.push(`${indent}}`);
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
// Primitive: whichever value* property is present carries the code.
|
|
448
|
+
lines.push(`${indent}const _seEntry = Object.entries(node(${elemVar})).find(([k]) => k.startsWith("value"));`);
|
|
449
|
+
lines.push(`${indent}if (_seEntry && typeof _seEntry[1] === 'string' && !${vs.validatorFn}(_seEntry[1])) {`);
|
|
450
|
+
lines.push(`${indent} ${notFound('_seEntry[1]')}`);
|
|
451
|
+
lines.push(`${indent}}`);
|
|
452
|
+
}
|
|
453
|
+
return lines.join('\n');
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Checks on the sub-extensions of a matched complex extension.
|
|
457
|
+
*
|
|
458
|
+
* `_ext` is the matched member; its own `extension` array holds the sub-extensions.
|
|
459
|
+
* Emits the cardinality on `Extension.extension`, the presence of each required
|
|
460
|
+
* slice, and the bound ValueSet of each slice that declares one — all in HL7's
|
|
461
|
+
* wording so parity attributes them to the same fields.
|
|
462
|
+
*/
|
|
463
|
+
function generateSubExtensionChecks(extMeta, ctx, indent) {
|
|
464
|
+
const subs = extMeta.subExtensions ?? [];
|
|
465
|
+
const min = extMeta.subExtensionMin ?? 0;
|
|
466
|
+
const lines = [];
|
|
467
|
+
if (min > 0) {
|
|
468
|
+
lines.push(`${indent}const _seAll = elements(at(_ext, 'extension'));`);
|
|
469
|
+
lines.push(`${indent}if (_seAll.length < ${min}) {`);
|
|
470
|
+
lines.push(`${indent} errors.push("Extension.extension: minimum required = ${min}, but only found " + _seAll.length);`);
|
|
471
|
+
lines.push(`${indent}}`);
|
|
472
|
+
}
|
|
473
|
+
for (const sub of subs) {
|
|
474
|
+
const needsPresence = sub.min > 0;
|
|
475
|
+
const vs = resolveBindingValueSet(sub.binding, ctx);
|
|
476
|
+
if (!needsPresence && !vs)
|
|
477
|
+
continue;
|
|
478
|
+
const varName = `_se_${sub.sliceName.replace(/[^a-zA-Z0-9_]/g, '_')}`;
|
|
479
|
+
lines.push(`${indent}const ${varName} = elements(at(_ext, 'extension')).filter((e) => e.url === "${sub.url}");`);
|
|
480
|
+
if (needsPresence) {
|
|
481
|
+
lines.push(`${indent}if (${varName}.length < ${sub.min}) {`);
|
|
482
|
+
lines.push(`${indent} errors.push("Slice 'Extension.extension:${sub.sliceName}': a matching slice is required, but not found");`);
|
|
483
|
+
lines.push(`${indent}}`);
|
|
484
|
+
}
|
|
485
|
+
if (vs) {
|
|
486
|
+
lines.push(`${indent}for (const _seElem of ${varName}) {`);
|
|
487
|
+
lines.push(subExtensionBindingCheck(sub, vs, '_seElem', indent + ' '));
|
|
488
|
+
lines.push(`${indent}}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return lines.length ? '\n' + lines.join('\n') : '';
|
|
492
|
+
}
|
|
349
493
|
/**
|
|
350
494
|
* Generate inline JS code that validates extension value type and binding
|
|
351
495
|
* for each matched extension in the given array variable.
|
|
352
496
|
* Returns an empty string when no metadata is available.
|
|
353
497
|
*/
|
|
354
498
|
function generateExtensionValueChecks(matchedArrayVar, extensionUrl, extMeta, ctx, indent) {
|
|
355
|
-
|
|
499
|
+
// No entry means the extension SD could not be read, so nothing can be concluded.
|
|
500
|
+
if (!extMeta)
|
|
356
501
|
return '';
|
|
502
|
+
// An entry with no permitted types means the opposite of unknown: the extension
|
|
503
|
+
// is complex, pins value[x] to max=0, and carries sub-extensions instead. A value
|
|
504
|
+
// of any type is forbidden, and since no single property can be named — every
|
|
505
|
+
// `value*` variant is disallowed — the check is on the presence of any of them.
|
|
506
|
+
if (extMeta.valueTypes.length === 0) {
|
|
507
|
+
const forbidUrl = ctx?.profileUrl || extensionUrl;
|
|
508
|
+
// HL7 reports one defect under two headings — the cardinality breach and the
|
|
509
|
+
// empty allowed-type list — and parity classifies them into different fields
|
|
510
|
+
// ('value' and 'constraint'), so emitting only the first left `constraint`
|
|
511
|
+
// showing as HL7-only on a finding we had already detected.
|
|
512
|
+
return `
|
|
513
|
+
${indent}// Complex extension: value[x] is max=0, so no value* property is allowed
|
|
514
|
+
${indent}for (const _ext of ${matchedArrayVar}) {
|
|
515
|
+
${indent} const _forbidden = Object.keys(node(_ext)).find((k) => k.startsWith("value"));
|
|
516
|
+
${indent} if (_forbidden) {
|
|
517
|
+
${indent} errors.push("Extension.value[x]: max allowed = 0, but found 1 (${forbidUrl})");
|
|
518
|
+
${indent} errors.push("The Extension '${extensionUrl}' definition allows for the types [] but found type " + _forbidden.slice(5));
|
|
519
|
+
${indent} }${generateSubExtensionChecks(extMeta, ctx, indent + ' ')}
|
|
520
|
+
${indent}}`;
|
|
521
|
+
}
|
|
357
522
|
const lines = [];
|
|
358
523
|
const allowedProps = extMeta.valueTypes.map(t => `"value${valuePropertySuffix(t)}"`);
|
|
359
524
|
const allowedSet = `[${allowedProps.join(', ')}]`;
|
|
@@ -31,17 +31,37 @@ export function generateSliceValidations(ctx) {
|
|
|
31
31
|
slicesByBase.forEach((slices, baseName) => {
|
|
32
32
|
slices.forEach(slice => {
|
|
33
33
|
const min = typeof slice.min === "number" ? slice.min : (slice.isOptional ? 0 : 1);
|
|
34
|
+
// An optional extension slice is still validated when an instance is present.
|
|
35
|
+
// Only this shape is admitted below at min=0, mirroring the isExtensionUrlOnly
|
|
36
|
+
// test inside the body, so every other slice kind keeps its existing gate and
|
|
37
|
+
// no other emitter is reached with a count it cannot assert.
|
|
38
|
+
const isOptionalExtensionUrlSlice = min === 0 && isUrlDiscriminatedExtensionSlice(slice, baseName, fields);
|
|
34
39
|
// An optional slice still needs its profiled resource validated. HL7 checks a
|
|
35
40
|
// present entry whether or not the slice itself is required, so skipping the
|
|
36
41
|
// whole slice on min=0 lost every finding inside it — DTR's qpackage output
|
|
37
42
|
// Parameters pins parameter:packagebundle.resource to a Bundle profile with
|
|
38
43
|
// min=0, and none of that Bundle's requirements were being checked.
|
|
39
44
|
if (min === 0) {
|
|
45
|
+
const emittedBefore = sliceValidations.length;
|
|
40
46
|
generateOptionalProfiledSliceDelegation(slice, baseName, fields, sliceValidations, ctx);
|
|
41
|
-
|
|
47
|
+
const alreadyDelegated = sliceValidations.length > emittedBefore;
|
|
48
|
+
// An extension slice carries on into the body, where the url discriminator
|
|
49
|
+
// branch emits its value[x] type and max=0 checks. Returning here left
|
|
50
|
+
// DTRStdQuestionnaire's item.extension:contextExpression unvalidated: it is
|
|
51
|
+
// min=0, so nothing about a present instance was ever checked.
|
|
52
|
+
//
|
|
53
|
+
// Only when the delegation emitted nothing, though. A top-level extension
|
|
54
|
+
// slice naming a profile gets a discriminator validation from the call above,
|
|
55
|
+
// and continuing into the body added a second check over the same slice — so
|
|
56
|
+
// one finding was reported twice and parity broke on both empty and random
|
|
57
|
+
// resources across us-core, mcode, ndh and pas. Falling through is for the
|
|
58
|
+
// slices the delegation declines, which is why the earlier attempt looked
|
|
59
|
+
// safe on the profiles I sampled: theirs are nested, and
|
|
60
|
+
// generateOptionalProfiledSliceDelegation returns early on a dotted path.
|
|
61
|
+
if (!isOptionalExtensionUrlSlice || alreadyDelegated)
|
|
62
|
+
return;
|
|
42
63
|
}
|
|
43
|
-
|
|
44
|
-
if (min > 0) {
|
|
64
|
+
if (min > 0 || isOptionalExtensionUrlSlice) {
|
|
45
65
|
let relPath = baseName.replace(/^[^.]+\./, "");
|
|
46
66
|
// Skip value[x] slices that are nested within parent elements.
|
|
47
67
|
if (relPath.includes('.') && relPath.includes('value[x]')) {
|
|
@@ -126,7 +146,7 @@ export function generateSliceValidations(ctx) {
|
|
|
126
146
|
const fId = (f.elementId || f.name).replace(/^[^.]+\./, '');
|
|
127
147
|
return fId.substring(sliceElemId.length + 1).split('.')[0];
|
|
128
148
|
});
|
|
129
|
-
generatePatternCodingValidation(slice, relPath, errorPath, sliceLabel, varName, min, fields, sliceValidations, requiredSubFields);
|
|
149
|
+
generatePatternCodingValidation(slice, relPath, errorPath, sliceLabel, varName, min, fields, sliceValidations, requiredSubFields, ctx.baseResourceType, ctx.profileUrl);
|
|
130
150
|
}
|
|
131
151
|
else if (childFieldsWithFixedValue.length > 0) {
|
|
132
152
|
// For extension slices where the only fixedValue child is 'url', prefer the
|
|
@@ -261,16 +281,19 @@ function generateCrossSliceExclusivityValidations(slicesByBase, fields, fieldPat
|
|
|
261
281
|
return;
|
|
262
282
|
if (!isArrayPath(relPath, fieldPathMap, arrayFieldPaths))
|
|
263
283
|
return;
|
|
284
|
+
// `elements()`, not the raw accessor: a multi-segment path compiles to
|
|
285
|
+
// `at(resource, …)`, which is `unknown`, and an enclosing `Array.isArray()`
|
|
286
|
+
// does not narrow a fresh call expression — so `for…of` on it fails to
|
|
287
|
+
// typecheck. The matchers read `_el.coding`, so the list must stay element-
|
|
288
|
+
// typed rather than going through `arr()`.
|
|
264
289
|
let code = `\n // Cross-slice exclusivity validation for ${relPath}`;
|
|
265
|
-
code += `\n
|
|
266
|
-
code += `\n
|
|
267
|
-
code += `\n const _matchedSlices: string[] = [];`;
|
|
290
|
+
code += `\n for (const _el of ${safeList(relPath)}) {`;
|
|
291
|
+
code += `\n const _matchedSlices: string[] = [];`;
|
|
268
292
|
for (const m of matchers) {
|
|
269
|
-
code += `\n
|
|
293
|
+
code += `\n if (${m.matchExpr}) _matchedSlices.push("${m.label}");`;
|
|
270
294
|
}
|
|
271
|
-
code += `\n
|
|
272
|
-
code += `\n
|
|
273
|
-
code += `\n }`;
|
|
295
|
+
code += `\n if (_matchedSlices.length > 1) {`;
|
|
296
|
+
code += `\n errors.push("Element matches more than one slice - " + _matchedSlices.join(", "));`;
|
|
274
297
|
code += `\n }`;
|
|
275
298
|
code += `\n }`;
|
|
276
299
|
out.push(code);
|
|
@@ -411,7 +434,7 @@ function generatePatternCodeableConceptValidation(slice, relPath, errorPath, sli
|
|
|
411
434
|
}`);
|
|
412
435
|
}
|
|
413
436
|
}
|
|
414
|
-
function generatePatternCodingValidation(slice, relPath, errorPath, sliceLabel, varName, min, fields, out, requiredSubFields) {
|
|
437
|
+
function generatePatternCodingValidation(slice, relPath, errorPath, sliceLabel, varName, min, fields, out, requiredSubFields, baseResourceType, profileUrl) {
|
|
415
438
|
const pattern = slice.patternConstraint;
|
|
416
439
|
const hasCode = 'code' in pattern;
|
|
417
440
|
const hasSystem = 'system' in pattern;
|
|
@@ -431,6 +454,40 @@ function generatePatternCodingValidation(slice, relPath, errorPath, sliceLabel,
|
|
|
431
454
|
}`)
|
|
432
455
|
.join('')
|
|
433
456
|
: '';
|
|
457
|
+
// Fixed values pinned on the slice's own children.
|
|
458
|
+
//
|
|
459
|
+
// This validator only ever checked that a required child was *present*, so a
|
|
460
|
+
// pinned value went unenforced: KBV_PR_ERP_Medication_Compounding fixes
|
|
461
|
+
// code.coding:verordnungskategorieCode.code to 'rezeptur' and we reported
|
|
462
|
+
// nothing where HL7 reports the mismatch. The CodeableConcept sibling has done
|
|
463
|
+
// this for its descendants all along — the gap was only here.
|
|
464
|
+
const sliceIdRel = (slice.elementId || slice.name).replace(/^[^.]+\./, '');
|
|
465
|
+
const resType = baseResourceType || sliceIdRel.split('.')[0];
|
|
466
|
+
const fixedChildChecks = fields
|
|
467
|
+
.filter(f => {
|
|
468
|
+
if (typeof f.fixedValue !== 'string' && typeof f.fixedValue !== 'number' && f.fixedValue !== true)
|
|
469
|
+
return false;
|
|
470
|
+
const fId = (f.elementId || f.name).replace(/^[^.]+\./, '');
|
|
471
|
+
if (!fId.startsWith(sliceIdRel + '.'))
|
|
472
|
+
return false;
|
|
473
|
+
const rel = fId.substring(sliceIdRel.length + 1);
|
|
474
|
+
// Direct children only; a deeper pin needs per-level descent this loop
|
|
475
|
+
// does not do, and a sub-slice has its own validator.
|
|
476
|
+
return !rel.includes('.') && !rel.includes(':');
|
|
477
|
+
})
|
|
478
|
+
.map(f => {
|
|
479
|
+
const fId = (f.elementId || f.name).replace(/^[^.]+\./, '');
|
|
480
|
+
const child = fId.substring(sliceIdRel.length + 1);
|
|
481
|
+
const expected = String(f.fixedValue).replace(/'/g, "\\'");
|
|
482
|
+
// The fragment must end at the pinned child so parity attributes this to
|
|
483
|
+
// the same field HL7 does.
|
|
484
|
+
const profileRef = `${profileUrl ? `${profileUrl}#` : ''}${resType}.${sliceIdRel}.${child}`;
|
|
485
|
+
return `
|
|
486
|
+
if ((matched as Record<string, unknown>).${child} !== undefined && String((matched as Record<string, unknown>).${child}) !== '${expected}') {
|
|
487
|
+
errors.push("Value is '" + (matched as Record<string, unknown>).${child} + "' but is fixed to '${expected}' in the profile ${profileRef}");
|
|
488
|
+
}`;
|
|
489
|
+
})
|
|
490
|
+
.join('');
|
|
434
491
|
const pathParts = relPath.split('.');
|
|
435
492
|
let isNestedInArray = false;
|
|
436
493
|
let arrayParentPath = '';
|
|
@@ -464,8 +521,9 @@ function generatePatternCodingValidation(slice, relPath, errorPath, sliceLabel,
|
|
|
464
521
|
}
|
|
465
522
|
}
|
|
466
523
|
}
|
|
467
|
-
const
|
|
468
|
-
|
|
524
|
+
const matchedChecks = subFieldChecks + fixedChildChecks;
|
|
525
|
+
const subFieldBlock = matchedChecks ? `
|
|
526
|
+
for (const matched of ${varName}Elements) {${matchedChecks}
|
|
469
527
|
}` : '';
|
|
470
528
|
if (isNestedInArray && arrayParentPath) {
|
|
471
529
|
const remainingPath = relPath.substring(arrayParentPath.length + 1);
|
|
@@ -562,3 +620,29 @@ function generateSelfFixedValueSliceValidation(slice, relPath, errorPath, sliceL
|
|
|
562
620
|
errors.push("Slice '${errorPath}:${sliceLabel}': a matching slice is required, but not found");
|
|
563
621
|
}`);
|
|
564
622
|
}
|
|
623
|
+
/**
|
|
624
|
+
* Whether a slice's only pinned direct child is `url` — the shape of an extension
|
|
625
|
+
* slice discriminated by its canonical.
|
|
626
|
+
*
|
|
627
|
+
* Deliberately identical to the isExtensionUrlOnly test inside the generator body,
|
|
628
|
+
* so that admitting a min=0 slice past the required-slice gate admits exactly the
|
|
629
|
+
* ones that then take the extension-url branch. Any other slice kind reaching a
|
|
630
|
+
* count-based emitter with min=0 would produce an assertion that cannot fire.
|
|
631
|
+
*/
|
|
632
|
+
function isUrlDiscriminatedExtensionSlice(slice, baseName, fields) {
|
|
633
|
+
// The body tests hasPatternCodeableConcept and hasPatternCoding *before* any
|
|
634
|
+
// extension branch, so a slice carrying a pattern reaches a pattern branch and
|
|
635
|
+
// would emit pattern checks under a minimum of zero. Two regressions came from
|
|
636
|
+
// admitting those.
|
|
637
|
+
if (slice.patternConstraint !== undefined)
|
|
638
|
+
return false;
|
|
639
|
+
if (slice.binding?.codes?.length)
|
|
640
|
+
return false;
|
|
641
|
+
// The body detector, not a hand-rolled copy. My first version required a pinned
|
|
642
|
+
// `url` child, which DTRStdQuestionnaire's item.extension:contextExpression does
|
|
643
|
+
// not have — it names its canonical through type.profile, so detectExtensionUrlSlice
|
|
644
|
+
// reads it from profileUrls. The predicate said no, the slice bailed, and nothing
|
|
645
|
+
// about a present instance was checked.
|
|
646
|
+
const relPath = baseName.replace(/^[^.]+\./, '');
|
|
647
|
+
return detectExtensionUrlSlice(slice, relPath, fields) !== undefined;
|
|
648
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Addressing a sub-extension slice — an extension slice nested inside another.
|
|
3
|
+
*
|
|
4
|
+
* Split out of sliceExtensionValidation.ts to keep it under the 650 LOC gate, and
|
|
5
|
+
* because this is a self-contained concern: turning a slice's element id into the
|
|
6
|
+
* runtime walk that reaches the elements *containing* it.
|
|
7
|
+
*/
|
|
8
|
+
import { stripVersionFromCanonicalUrl } from '../../core/utils.js';
|
|
9
|
+
/**
|
|
10
|
+
* The url an *instance* of this extension slice carries.
|
|
11
|
+
*
|
|
12
|
+
* `detectExtensionUrlSlice` answers a different question — which canonical names the
|
|
13
|
+
* slice — and prefers `type.profile`, which may be version-pinned (`…|4.0.0`). An
|
|
14
|
+
* instance never writes the version, so matching on that form finds nothing. The
|
|
15
|
+
* pinned `url` child is what the instance actually carries; fall back to the profile
|
|
16
|
+
* canonical with the version stripped.
|
|
17
|
+
*/
|
|
18
|
+
export function instanceUrlForSlice(slice, fields) {
|
|
19
|
+
const sliceElementId = (slice.elementId || slice.name).replace(/^[^.]+\./, '');
|
|
20
|
+
const sliceFullId = slice.sliceName && !sliceElementId.includes(':')
|
|
21
|
+
? `${sliceElementId}:${slice.sliceName}`
|
|
22
|
+
: sliceElementId;
|
|
23
|
+
for (const f of fields) {
|
|
24
|
+
const childId = (f.elementId || f.name).replace(/^[^.]+\./, '');
|
|
25
|
+
if (childId === sliceFullId + '.url' && typeof f.fixedValue === 'string')
|
|
26
|
+
return f.fixedValue;
|
|
27
|
+
}
|
|
28
|
+
return slice.profileUrls?.length ? stripVersionFromCanonicalUrl(slice.profileUrls[0]) : undefined;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Build the walk down to the elements that *contain* a sub-extension slice.
|
|
32
|
+
*
|
|
33
|
+
* Only for a slice nested inside another extension slice
|
|
34
|
+
* (`item.extension:contextExpression.extension:label`). The generic path builds one
|
|
35
|
+
* `at(_el, 'extension', 'extension')` hop, which returns undefined the moment it
|
|
36
|
+
* meets the `extension` array — so the check read nothing and its `length < min`
|
|
37
|
+
* assertion fired on every parent element instead. It also ignored the parent slice
|
|
38
|
+
* entirely, applying contextExpression's requirements to every extension on the item.
|
|
39
|
+
*
|
|
40
|
+
* Returns null when the slice is not of that shape, leaving the existing paths alone.
|
|
41
|
+
*/
|
|
42
|
+
export function buildSubExtensionScope(slice, fields) {
|
|
43
|
+
const elemId = (slice.elementId || '').replace(/^[^.]+\./, '');
|
|
44
|
+
if (!elemId)
|
|
45
|
+
return null;
|
|
46
|
+
const segments = elemId.split('.');
|
|
47
|
+
// The slice itself is the last segment; everything before it is the container walk.
|
|
48
|
+
const containerSegments = segments.slice(0, -1);
|
|
49
|
+
const slicedAncestors = containerSegments.filter(s => {
|
|
50
|
+
const [prop, sliceName] = s.split(':');
|
|
51
|
+
return sliceName && (prop === 'extension' || prop === 'modifierExtension');
|
|
52
|
+
});
|
|
53
|
+
if (slicedAncestors.length === 0)
|
|
54
|
+
return null;
|
|
55
|
+
const hops = [];
|
|
56
|
+
for (let i = 0; i < containerSegments.length; i++) {
|
|
57
|
+
const [prop, sliceName] = containerSegments[i].split(':');
|
|
58
|
+
let url;
|
|
59
|
+
if (sliceName) {
|
|
60
|
+
const ancestorId = containerSegments.slice(0, i + 1).join('.');
|
|
61
|
+
const ancestor = fields.find(f => (f.elementId || '').replace(/^[^.]+\./, '') === ancestorId);
|
|
62
|
+
// Without a resolvable url the hop cannot be scoped, and an unscoped walk is
|
|
63
|
+
// what this function exists to avoid — bail rather than guess.
|
|
64
|
+
if (!ancestor)
|
|
65
|
+
return null;
|
|
66
|
+
url = instanceUrlForSlice(ancestor, fields);
|
|
67
|
+
if (!url)
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
hops.push({ prop, url });
|
|
71
|
+
}
|
|
72
|
+
return hops;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Render a scope walk as nested for-loops, innermost body supplied by the caller.
|
|
76
|
+
*
|
|
77
|
+
* Unfiltered hops go through `elementsAt`, which drops absent and null intermediates
|
|
78
|
+
* and flattens arrays. Reading a single element into a one-item list instead would
|
|
79
|
+
* carry `undefined` into the body, where a `length < min` assertion fires on it.
|
|
80
|
+
*/
|
|
81
|
+
export function renderScopeHops(hops, body) {
|
|
82
|
+
let code = '';
|
|
83
|
+
let indent = ' ';
|
|
84
|
+
let current = 'resource';
|
|
85
|
+
hops.forEach((hop, i) => {
|
|
86
|
+
const next = `_scope${i}`;
|
|
87
|
+
const list = hop.url
|
|
88
|
+
? `elements(at(${current}, '${hop.prop}')).filter((e) => e.url === "${hop.url}")`
|
|
89
|
+
: `elementsAt(${current}, ['${hop.prop}'])`;
|
|
90
|
+
code += `\n${indent}for (const ${next} of ${list}) {`;
|
|
91
|
+
indent += ' ';
|
|
92
|
+
current = next;
|
|
93
|
+
});
|
|
94
|
+
code += body(current, indent);
|
|
95
|
+
for (let i = hops.length - 1; i >= 0; i--) {
|
|
96
|
+
indent = indent.slice(2);
|
|
97
|
+
code += `\n${indent}}`;
|
|
98
|
+
}
|
|
99
|
+
return code;
|
|
100
|
+
}
|
|
@@ -174,7 +174,19 @@ export function buildForbiddenFieldConstraintEntries(fields) {
|
|
|
174
174
|
human: `${concretePath}: max allowed = 0, but found 1`,
|
|
175
175
|
severity: 'error',
|
|
176
176
|
},
|
|
177
|
-
|
|
177
|
+
// The full path, not the last segment. The expression above is written
|
|
178
|
+
// relative to the resource root, and the emitter wraps an expression in
|
|
179
|
+
// `${fieldPath}.all(...)` unless the expression already navigates from
|
|
180
|
+
// fieldPath. With a bare last segment it never did, so a max=0 on
|
|
181
|
+
// Medication.amount.numerator.value became
|
|
182
|
+
//
|
|
183
|
+
// value.all(amount.numerator.value.exists().not())
|
|
184
|
+
//
|
|
185
|
+
// and Medication has no `value` element: the collection is empty, FHIRPath
|
|
186
|
+
// all() on empty is true, and the constraint could never fire. Two of the
|
|
187
|
+
// three kbv-erp random-parity failures were this, with HL7 reporting the
|
|
188
|
+
// violation we had already written down and then scoped into oblivion.
|
|
189
|
+
fieldPath: concretePath,
|
|
178
190
|
isChoiceType: false,
|
|
179
191
|
});
|
|
180
192
|
});
|