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.
@@ -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
- if (${safeAccessor(arrayParent)} && Array.isArray(${safeAccessor(arrayParent)})) {
220
- for (const _el of ${safeAccessor(arrayParent)}) {
221
- const _arr = elements(at(_el, ${remainingAccessor.split('?.').map(s => `'${s}'`).join(', ')}));
222
- if (Array.isArray(_arr)) {
223
- const ${varName}Matched = _arr.filter((item) =>
224
- elements(at(item, 'extension')).some((ext) => ext.url === "${extensionUrl}")
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?.get(extensionUrl);
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
- // Required extension slice validation for ${relPath}:${sliceLabel} (url discriminator, nested in array)
305
- if (${safeAccessor(arrayParent)} && Array.isArray(${safeAccessor(arrayParent)})) {
306
- for (const _el of ${safeAccessor(arrayParent)}) {
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
- const elseGuard = parentPath ? `else if (${safeAccessor(parentPath)})` : 'else';
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
- // Required extension slice validation for ${relPath}:${sliceLabel} (url discriminator)
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
- if (${varName}Elements.length < ${min}) {
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
- if (!extMeta || extMeta.valueTypes.length === 0)
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
- return;
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
- // Only validate required slices (min > 0)
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 if (Array.isArray(${safeAccessor(relPath)})) {`;
266
- code += `\n for (const _el of ${safeAccessor(relPath)}) {`;
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 if (${m.matchExpr}) _matchedSlices.push("${m.label}");`;
293
+ code += `\n if (${m.matchExpr}) _matchedSlices.push("${m.label}");`;
270
294
  }
271
- code += `\n if (_matchedSlices.length > 1) {`;
272
- code += `\n errors.push("Element matches more than one slice - " + _matchedSlices.join(", "));`;
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 subFieldBlock = subFieldChecks ? `
468
- for (const matched of ${varName}Elements) {${subFieldChecks}
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
- fieldPath: concreteParts[concreteParts.length - 1],
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
  });