babelfhir-ts 1.5.21 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +6 -3
  2. package/out/src/generator/core/extensionSdReader.js +2 -1
  3. package/out/src/generator/core/utils.js +69 -0
  4. package/out/src/generator/emitters/class/classGeneratorHelpers.js +10 -2
  5. package/out/src/generator/emitters/class/sliceElementDefaults.js +10 -1
  6. package/out/src/generator/emitters/interface/interfaceFieldProcessor.js +15 -12
  7. package/out/src/generator/emitters/interface/interfaceFieldUtils.js +6 -3
  8. package/out/src/generator/emitters/interface/postProcessExtensions.js +8 -2
  9. package/out/src/generator/emitters/interface/processNestedField.js +4 -6
  10. package/out/src/generator/emitters/namingsystem/namingSystemGenerator.js +136 -0
  11. package/out/src/generator/emitters/prefab/prefabEmitter.js +2 -15
  12. package/out/src/generator/emitters/prefab/prefabRenderer.js +2 -19
  13. package/out/src/generator/emitters/validator/closedSlicingValidation.js +5 -2
  14. package/out/src/generator/emitters/validator/fhirpathStubInstaller.js +7 -1
  15. package/out/src/generator/emitters/validator/sliceBackboneValidation.js +27 -12
  16. package/out/src/generator/emitters/validator/sliceValidatorGenerator.js +15 -27
  17. package/out/src/generator/emitters/validator/sliceValidatorUtils.js +5 -1
  18. package/out/src/generator/emitters/validator/subExtensionScope.js +10 -2
  19. package/out/src/generator/emitters/validator/validatorExpressions.js +44 -0
  20. package/out/src/generator/emitters/validator/validatorFieldBuilders.js +34 -17
  21. package/out/src/generator/emitters/validator/validatorGenerator.js +31 -40
  22. package/out/src/generator/emitters/validator/validatorRuntime.js +13 -2
  23. package/out/src/generator/emitters/valueset/valueSetGenerator.js +6 -10
  24. package/out/src/generator/emitters/zod/zodRefinementBuilder.js +39 -3
  25. package/out/src/generator/emitters/zod/zodSchemaGenerator.js +36 -8
  26. package/out/src/generator/generationHelpers.js +59 -7
  27. package/out/src/generator/index.js +90 -47
  28. package/out/src/generator/parser/packageParser.js +76 -0
  29. package/out/src/generator/parser/vsParser.js +4 -2
  30. package/out/src/generator/sdProcessor.js +30 -21
  31. package/out/src/main.js +5 -0
  32. package/package.json +12 -9
  33. package/parity-matrix.json +6 -0
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * BackboneElement and binding-codes slice validation strategies.
3
3
  */
4
+ import { firstSegment } from '../../core/utils.js';
4
5
  import { safeAccessor, safeList, sliceElseBranch, detectNestedArray, FHIR_PRIMITIVE_STRING_TYPES, collectWithinSliceMaxChildren, generateWithinSliceMaxChecks } from './sliceValidatorUtils.js';
5
6
  /** Collect required direct children of a BackboneElement slice for per-element validation. */
6
7
  export function collectRequiredSliceChildren(sliceElementId, fields, discriminatorProp, profileName, relPath) {
@@ -45,6 +46,11 @@ export function collectNestedRequiredSliceChildren(sliceElementId, fields, discr
45
46
  const parts = relativePath.split('.');
46
47
  if (parts.length !== 2)
47
48
  continue;
49
+ // Destructured so the two segments are values: the length check establishes
50
+ // there are exactly two, but indexing alone does not tell the compiler.
51
+ const [parentSegment, childSegment] = parts;
52
+ if (!parentSegment || !childSegment)
53
+ continue;
48
54
  if (relativePath.includes(':'))
49
55
  continue;
50
56
  if (f.max === 0)
@@ -52,20 +58,21 @@ export function collectNestedRequiredSliceChildren(sliceElementId, fields, discr
52
58
  const childMin = typeof f.min === 'number' ? f.min : (f.isOptional ? 0 : 1);
53
59
  if (childMin < 1)
54
60
  continue;
55
- const parentProp = parts[0].replace(/\[x\]$/, '');
61
+ const parentProp = parentSegment.replace(/\[x\]$/, '');
56
62
  if (parentProp === discriminatorProp)
57
63
  continue;
58
- const isChoiceType = parts[1].endsWith('[x]');
59
- const prop = parts[1].replace(/\[x\]$/, '');
64
+ const isChoiceType = childSegment.endsWith('[x]');
65
+ const prop = childSegment.replace(/\[x\]$/, '');
60
66
  const key = `${parentProp}.${prop}`;
61
67
  if (seen.has(key))
62
68
  continue;
63
69
  seen.add(key);
64
- const parentFieldId = `${sliceElementId}.${parts[0]}`;
70
+ const parentFieldId = `${sliceElementId}.${parentSegment}`;
65
71
  const parentField = fields.find(pf => (pf.elementId || pf.name) === parentFieldId);
66
72
  const isParentArray = parentField?.isArray ?? false;
67
- const baseParts = relPath.split('.');
68
- const lastPart = baseParts[baseParts.length - 1];
73
+ // relPath is non-empty, so split() yields a last segment; falling back to the
74
+ // whole path keeps the error path readable if it ever does not.
75
+ const lastPart = relPath.split('.').at(-1) ?? relPath;
69
76
  result.push({ parentProp, isParentArray, prop, errorPath: `${profileName}.${lastPart}.${parentProp}.${prop}`, isChoiceType });
70
77
  }
71
78
  return result;
@@ -87,7 +94,11 @@ export function collectFixedValueSliceChildren(sliceElementId, fields, discrimin
87
94
  // Handle choice type slicing: value[x]:valueString → prop "valueString"
88
95
  let prop = relativePath;
89
96
  if (prop.includes('[x]:')) {
90
- prop = prop.split(':')[1]; // "valueString", "valueCode", etc.
97
+ // The typed variant after the colon: "valueString", "valueCode", etc.
98
+ const typedVariant = prop.split(':')[1];
99
+ if (!typedVariant)
100
+ continue;
101
+ prop = typedVariant;
91
102
  }
92
103
  else if (prop.includes(':')) {
93
104
  continue; // Skip other slice references
@@ -117,13 +128,13 @@ export function generateBackboneElementValidation(slice, relPath, errorPath, sli
117
128
  const sliceId = (slice.elementId || slice.name).replace(/^[^.]+\./, '');
118
129
  let discriminatorProp = '';
119
130
  if (childFieldId.startsWith(sliceId + '.')) {
120
- discriminatorProp = childFieldId.substring(sliceId.length + 1).split('.')[0];
131
+ discriminatorProp = firstSegment(childFieldId.substring(sliceId.length + 1));
121
132
  }
122
133
  else {
123
134
  const childFieldName = discriminatorChild.name.replace(/^[^.]+\./, '');
124
135
  const sliceName = slice.name.replace(/^[^.]+\./, '');
125
136
  if (childFieldName.startsWith(sliceName + '.')) {
126
- discriminatorProp = childFieldName.substring(sliceName.length + 1).split('.')[0];
137
+ discriminatorProp = firstSegment(childFieldName.substring(sliceName.length + 1));
127
138
  }
128
139
  }
129
140
  if (discriminatorProp.includes('[x]')) {
@@ -167,7 +178,7 @@ export function generateBackboneElementValidation(slice, relPath, errorPath, sli
167
178
  }
168
179
  if (!checks)
169
180
  return;
170
- const resourceType = profileName || sliceElementId.split('.')[0];
181
+ const resourceType = profileName || firstSegment(sliceElementId);
171
182
  const requiredChildren = collectRequiredSliceChildren(sliceElementId, fields, discriminatorProp, resourceType, relPath);
172
183
  const nestedChildren = collectNestedRequiredSliceChildren(sliceElementId, fields, discriminatorProp, resourceType, relPath);
173
184
  const directChecksInner = requiredChildren.map(c => c.isChoiceType
@@ -257,12 +268,16 @@ export function generateBackboneElementValidation(slice, relPath, errorPath, sli
257
268
  */
258
269
  export function generateFixedValueSliceValidation(slice, relPath, errorPath, sliceLabel, varName, min, fields, childFieldsWithFixedValue, out, profileName) {
259
270
  const sliceElementId = slice.elementId || slice.name;
271
+ // The caller only reaches here with a fixed-value child, but an empty list would
272
+ // otherwise read its discriminator off undefined.
260
273
  const discriminatorChild = childFieldsWithFixedValue[0];
274
+ if (!discriminatorChild)
275
+ return;
261
276
  const childFieldId = (discriminatorChild.elementId || discriminatorChild.name).replace(/^[^.]+\./, '');
262
277
  const sliceId = (slice.elementId || slice.name).replace(/^[^.]+\./, '');
263
278
  let discriminatorProp = '';
264
279
  if (childFieldId.startsWith(sliceId + '.')) {
265
- discriminatorProp = childFieldId.substring(sliceId.length + 1).split('.')[0];
280
+ discriminatorProp = firstSegment(childFieldId.substring(sliceId.length + 1));
266
281
  }
267
282
  if (!discriminatorProp)
268
283
  return;
@@ -270,7 +285,7 @@ export function generateFixedValueSliceValidation(slice, relPath, errorPath, sli
270
285
  ? `"${discriminatorChild.fixedValue}"`
271
286
  : String(discriminatorChild.fixedValue);
272
287
  const checks = `at(item, '${discriminatorProp}') === ${fixedVal}`;
273
- const resourceType = profileName || sliceElementId.split('.')[0];
288
+ const resourceType = profileName || firstSegment(sliceElementId);
274
289
  const requiredChildren = collectRequiredSliceChildren(sliceElementId, fields, discriminatorProp, resourceType, relPath);
275
290
  const nestedChildren = collectNestedRequiredSliceChildren(sliceElementId, fields, discriminatorProp, resourceType, relPath);
276
291
  const fixedChildren = collectFixedValueSliceChildren(sliceElementId, fields, discriminatorProp);
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { addDiagnostic } from '../../core/sdDiagnostics.js';
7
7
  import { INLINE_RESOURCE_SLOTS } from './validatorFieldBuilders.js';
8
- import { safeAccessor, safeList, sliceElseBranch, rewritePrimitiveExtensionPath, isArrayPath, resolveNestedPatternChild } from './sliceValidatorUtils.js';
8
+ import { safeAccessor, safeList, sliceElseBranch, rewritePrimitiveExtensionPath, isArrayPath, resolveNestedPatternChild, collectWithinSliceMaxChildren, generateWithinSliceMaxChecks } from './sliceValidatorUtils.js';
9
9
  import { generateProfiledChildDelegation, detectReferenceProfileDiscriminator, generateReferenceProfileDiscriminatorValidation, generateOptionalProfiledSliceDelegation, detectProfileDiscriminator, generateProfileDiscriminatorValidation, detectSelfTypeDiscriminator, generateSelfTypeSliceValidation, generateDatatypeProfileSliceValidation, } from './sliceDelegation.js';
10
10
  import { generateBackboneElementValidation, generateBindingCodesValidation, generateFixedValueSliceValidation } from './sliceBackboneValidation.js';
11
11
  import { generateValueSetValidation, detectExistsExtensionDiscriminator, generateExistsExtensionValidation, detectExtensionUrlSlice, generateExtensionUrlSliceValidation, } from './sliceExtensionValidation.js';
@@ -311,27 +311,11 @@ function generatePatternCodeableConceptValidation(slice, relPath, errorPath, sli
311
311
  ? `${sliceElemId}:${slice.sliceName}`
312
312
  : sliceElemId;
313
313
  const resType = baseResourceType || sliceElemId.split('.')[0];
314
- const childMaxFields = fields.filter(f => {
315
- const fId = (f.elementId || f.name).replace(/^[^.]+\./, '');
316
- if (!fId.startsWith(sliceFullId + '.'))
317
- return false;
318
- const relChild = fId.substring(sliceFullId.length + 1);
319
- if (relChild.includes('.'))
320
- return false;
321
- return typeof f.max === 'number' && f.max < Infinity;
322
- });
323
- for (const child of childMaxFields) {
324
- const childFullId = (child.elementId || child.name).replace(/^[^.]+\./, '');
325
- const childProp = childFullId.substring(sliceFullId.length + 1);
326
- const maxVal = child.max;
327
- childMaxChecks += `
328
- for (const _wsElem of ${varName}Elements) {
329
- const _wsArr = at(_wsElem, '${childProp}');
330
- if (Array.isArray(_wsArr) && _wsArr.length > ${maxVal}) {
331
- errors.push("${resType}.${relPath}:${sliceLabel}.${childProp}: max allowed = ${maxVal}, but found " + _wsArr.length);
332
- }
333
- }`;
334
- }
314
+ // Shared with the backbone and delegation slice validators. The inline copy
315
+ // this replaces was array-only, so `max=0` on a singleton child could never
316
+ // fire, and it rejected any dotted path, so a grandchild like
317
+ // `entity:patient.what.reference` was never reached.
318
+ childMaxChecks += generateWithinSliceMaxChecks(collectWithinSliceMaxChildren(slice, fields), `${varName}Elements`, resType, relPath, sliceLabel);
335
319
  // Build within-slice fixedValue checks on nested descendants.
336
320
  // E.g. category:labCategory.coding.system fixed to a specific URI.
337
321
  const fixedValueDescendants = fields.filter(f => {
@@ -371,11 +355,11 @@ function generatePatternCodeableConceptValidation(slice, relPath, errorPath, sli
371
355
  // pattern coding values as effectively fixed on each coding element
372
356
  // within matched slice members. The HL7 validator treats pattern values
373
357
  // as fixed when combined with max=1 cardinality.
374
- const codingChild = childMaxFields.find(f => {
358
+ const codingChild = fields.find(f => {
375
359
  const fId = (f.elementId || f.name).replace(/^[^.]+\./, '');
376
- return fId.substring(sliceFullId.length + 1) === 'coding';
360
+ return fId.startsWith(sliceFullId + '.') && fId.substring(sliceFullId.length + 1) === 'coding';
377
361
  });
378
- if (codingChild && typeof codingChild.max === 'number' && codingChild.max === 1) {
362
+ if (codingChild?.max === 1) {
379
363
  const pRef = profileUrl ? `${profileUrl}#${resType}.${relPath}:${sliceLabel}` : `${resType}.${relPath}:${sliceLabel}`;
380
364
  for (const pc of patternCodings) {
381
365
  const pSys = pc.system ? String(pc.system) : undefined;
@@ -522,9 +506,13 @@ function generatePatternCodingValidation(slice, relPath, errorPath, sliceLabel,
522
506
  }
523
507
  }
524
508
  const matchedChecks = subFieldChecks + fixedChildChecks;
525
- const subFieldBlock = matchedChecks ? `
509
+ // Within-slice max cardinality on this slice's own children, through the same
510
+ // helper the backbone and delegation validators use. This validator never ran
511
+ // them, so a `max=0` child of a Coding slice went unreported.
512
+ const maxChecks = generateWithinSliceMaxChecks(collectWithinSliceMaxChildren(slice, fields), `${varName}Elements`, resType, relPath, sliceLabel);
513
+ const subFieldBlock = (matchedChecks ? `
526
514
  for (const matched of ${varName}Elements) {${matchedChecks}
527
- }` : '';
515
+ }` : '') + maxChecks;
528
516
  if (isNestedInArray && arrayParentPath) {
529
517
  const remainingPath = relPath.substring(arrayParentPath.length + 1);
530
518
  const optionalPath = remainingPath.split('.').join('?.');
@@ -209,7 +209,11 @@ export function collectWithinSliceMaxChildren(slice, fields) {
209
209
  .every((_, i) => isTraversableIntermediate(segments.slice(0, i + 1).join('.')));
210
210
  if (!intermediatesOk)
211
211
  continue;
212
- const leaf = segments[segments.length - 1];
212
+ const leaf = segments.at(-1);
213
+ // relChild is non-empty here, so split() always produced a leaf; the guard is
214
+ // what tells the compiler, and skipping is right if a path ever arrives empty.
215
+ if (!leaf)
216
+ continue;
213
217
  const isChoiceType = leaf.endsWith('[x]');
214
218
  const propPath = [...segments.slice(0, -1), isChoiceType ? leaf.replace(/\[x\]$/, '') : leaf];
215
219
  result.push({ propPath, fhirPath: relChild, maxVal: f.max, isArray: !!f.isArray, isChoiceType });
@@ -25,7 +25,8 @@ export function instanceUrlForSlice(slice, fields) {
25
25
  if (childId === sliceFullId + '.url' && typeof f.fixedValue === 'string')
26
26
  return f.fixedValue;
27
27
  }
28
- return slice.profileUrls?.length ? stripVersionFromCanonicalUrl(slice.profileUrls[0]) : undefined;
28
+ const firstProfile = slice.profileUrls?.[0];
29
+ return firstProfile ? stripVersionFromCanonicalUrl(firstProfile) : undefined;
29
30
  }
30
31
  /**
31
32
  * Build the walk down to the elements that *contain* a sub-extension slice.
@@ -54,7 +55,14 @@ export function buildSubExtensionScope(slice, fields) {
54
55
  return null;
55
56
  const hops = [];
56
57
  for (let i = 0; i < containerSegments.length; i++) {
57
- const [prop, sliceName] = containerSegments[i].split(':');
58
+ const segment = containerSegments[i];
59
+ if (segment === undefined)
60
+ return null;
61
+ const [prop, sliceName] = segment.split(':');
62
+ // split() on a non-empty segment always yields a first part; bailing keeps the
63
+ // "no unscoped walk" guarantee this function exists for.
64
+ if (!prop)
65
+ return null;
58
66
  let url;
59
67
  if (sliceName) {
60
68
  const ancestorId = containerSegments.slice(0, i + 1).join('.');
@@ -48,6 +48,50 @@ export function normalizeCollectionTypeFilters(expression) {
48
48
  * They are dropped for nested field paths, and kept at a single level for
49
49
  * recursive elements, rather than being walked through the whole recursion.
50
50
  */
51
+ /**
52
+ * Escape a constraint expression for embedding in a double-quoted emitted string,
53
+ * and scope it to its element.
54
+ *
55
+ * Shared by the validator and the Zod emitter. Both evaluate the same FHIRPath
56
+ * against the same resource, so a second copy of this scoping would make the two
57
+ * disagree on the same profile — which parity would report as a real mismatch.
58
+ *
59
+ * If the expression does not already navigate from `fieldPath`, it is wrapped in
60
+ * `<path>.all(...)`. Only the START of the expression is checked: built
61
+ * constraints (e.g. "action.exists()") begin with the fieldPath, while
62
+ * field-level constraints are relative to the element and start with child
63
+ * names. Matching the fieldPath anywhere would false-positive on recursive
64
+ * elements, where "resource.exists() != action.exists()" on fieldPath "action"
65
+ * mentions the name only as a child reference.
66
+ *
67
+ * Resource-root constraints (fieldPath === baseResourceType) need the wrapping
68
+ * too: FHIRPath `implies` yields empty when the antecedent is missing, and
69
+ * `.all()` converts that to false, which is what HL7 and Firely report.
70
+ */
71
+ export function scopeConstraintExpression(expression, fieldPath, opts = {}) {
72
+ let escaped = normalizeCollectionTypeFilters(expression || '')
73
+ .replace(/\s*\n\s*/g, ' ')
74
+ .replace(/\\/g, '\\\\')
75
+ .replace(/"/g, '\\"');
76
+ if (!fieldPath)
77
+ return escaped;
78
+ const escapedPath = fieldPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
79
+ const navigationPattern = new RegExp(`^${escapedPath}\\s*\\.`);
80
+ const standalonePattern = new RegExp(`^${escapedPath}(?:[^.\\w]|$)`);
81
+ if (navigationPattern.test(escaped) || standalonePattern.test(escaped))
82
+ return escaped;
83
+ const resourceTypePattern = /^[A-Z][a-zA-Z]+\./;
84
+ if (resourceTypePattern.test(escaped))
85
+ return escaped;
86
+ const walkRecursion = !!opts.isRecursiveElement?.(fieldPath)
87
+ && !isBaseElementInvariant(expression || '');
88
+ const navigation = opts.sliceFilter
89
+ ? `${quoteFhirPathPath(fieldPath)}.where(${opts.sliceFilter})`
90
+ : quoteFhirPathPath(fieldPath);
91
+ const scope = walkRecursion ? `repeat(${navigation})` : navigation;
92
+ escaped = `${scope}.all(${escaped})`;
93
+ return escaped;
94
+ }
51
95
  export function isBaseElementInvariant(expression) {
52
96
  return /^hasValue\(\)\s+or\s+\(children\(\)\.count\(\)\s*>\s*id\.count\(\)\)$/.test(expression)
53
97
  || /^extension\.exists\(\)\s*!=\s*value\.exists\(\)$/.test(expression);
@@ -1,4 +1,4 @@
1
- import { choicePropertyName, narrowedChoiceType } from '../../core/utils.js';
1
+ import { choicePropertyName, narrowedChoiceType, stripSliceSuffix } from '../../core/utils.js';
2
2
  /**
3
3
  * The element slots FHIR types as `Resource`, i.e. the ones that hold an inline
4
4
  * resource rather than a reference to one: DomainResource.contained,
@@ -22,7 +22,7 @@ const ABSTRACT_RESOURCE_TYPES = new Set(['Resource', 'DomainResource']);
22
22
  * HL7 uses, so this cannot flag a conformant resource.
23
23
  */
24
24
  function buildNestedObjectTypeValidations(fields, arrayFieldPaths, result) {
25
- const isArrayPath = (p) => arrayFieldPaths.has(p) || arrayFieldPaths.has(p.split(':')[0]);
25
+ const isArrayPath = (p) => arrayFieldPaths.has(p) || arrayFieldPaths.has(stripSliceSuffix(p));
26
26
  const seen = new Set();
27
27
  for (const field of fields) {
28
28
  if (field.sliceName || field.isArray)
@@ -63,6 +63,7 @@ function buildNestedObjectTypeValidations(fields, arrayFieldPaths, result) {
63
63
  const body = `${indent}const _soValue = at(${expr}, '${leaf}');
64
64
  ${indent}if (_soValue !== undefined && _soValue !== null && typeof _soValue !== 'object') {
65
65
  ${indent} errors.push("The property ${leaf} must be an Object, not a Primitive property (at ${rel})");
66
+ ${indent} _structuralFailures.add('${rel}');
66
67
  ${indent}}`;
67
68
  result.push(`
68
69
  // Structural type check: ${rel} must be an Object
@@ -160,8 +161,9 @@ export function buildInlineResourceTypeValidations(fields, profileUrl) {
160
161
  * is one identifier among several, so requiring its children of every identifier
161
162
  * would report a conformant resource.
162
163
  */
164
+ /** Undefined for an empty path, which callers treat as "nothing to check". */
163
165
  function leafFieldName(parts) {
164
- return parts[parts.length - 1];
166
+ return parts.at(-1);
165
167
  }
166
168
  function resolveChoiceSliceSegments(elementIdRel) {
167
169
  const segments = elementIdRel.split('.');
@@ -172,7 +174,7 @@ function resolveChoiceSliceSegments(elementIdRel) {
172
174
  continue;
173
175
  }
174
176
  const [base, sliceName] = segment.split(':');
175
- if (!base.endsWith('[x]') || !sliceName)
177
+ if (!base || !base.endsWith('[x]') || !sliceName)
176
178
  return undefined;
177
179
  const baseProp = base.slice(0, -'[x]'.length);
178
180
  if (!sliceName.startsWith(baseProp) || sliceName === baseProp)
@@ -236,17 +238,20 @@ export function buildNestedRequiredValidations(fields, arrayFieldPaths) {
236
238
  // Only an unambiguous choice yields one property name. A multi-typed choice
237
239
  // may legitimately carry any variant, and picking one would flag a conformant
238
240
  // resource — which is what the previous depth guard was protecting against.
239
- if (uniqueTypes.length !== 1)
241
+ const [onlyType] = uniqueTypes;
242
+ if (uniqueTypes.length !== 1 || !onlyType)
240
243
  return;
241
- const choiceSuffix = uniqueTypes[0].charAt(0).toUpperCase() + uniqueTypes[0].slice(1);
244
+ const choiceSuffix = onlyType.charAt(0).toUpperCase() + onlyType.slice(1);
242
245
  rel = relFull.replace(/^[^.]+\./, '').replace(/\[x\]/, choiceSuffix);
243
246
  }
244
247
  const resolvedParts = rel.split('.');
245
- const isArrayPath = (p) => arrayFieldPaths.has(p) || arrayFieldPaths.has(p.split(':')[0]);
248
+ const isArrayPath = (p) => arrayFieldPaths.has(p) || arrayFieldPaths.has(stripSliceSuffix(p));
246
249
  // Check which intermediate parts are arrays
247
250
  const intermediateParts = resolvedParts.slice(1, -1);
248
251
  const hasIntermediateArray = intermediateParts.some((_part, idx) => isArrayPath(resolvedParts.slice(0, idx + 2).join('.')));
249
252
  const arrayParent = resolvedParts[0];
253
+ if (!arrayParent)
254
+ return;
250
255
  const topIsArray = isArrayPath(arrayParent);
251
256
  // An array is needed somewhere above the leaf, but it need not be the first
252
257
  // segment: the flat branches below index straight into resource.<arrayParent>,
@@ -275,17 +280,22 @@ export function buildNestedRequiredValidations(fields, arrayFieldPaths) {
275
280
  }`);
276
281
  return;
277
282
  }
278
- const leafField = resolvedParts[resolvedParts.length - 1];
283
+ const leafField = resolvedParts.at(-1);
284
+ if (!leafField)
285
+ return;
279
286
  // Check if the leaf field itself is an array — if so, don't flag Array values as absent
280
287
  const fullLeafPath = resolvedParts.join('.');
281
- const leafIsArray = arrayFieldPaths.has(fullLeafPath) || arrayFieldPaths.has(fullLeafPath.split(':')[0]);
288
+ const leafIsArray = arrayFieldPaths.has(fullLeafPath) || arrayFieldPaths.has(stripSliceSuffix(fullLeafPath));
282
289
  if (hasIntermediateArray) {
283
290
  // Build nested for-loops for paths with intermediate arrays
284
291
  const levels = [];
285
292
  for (let i = 0; i < resolvedParts.length - 1; i++) {
293
+ const name = resolvedParts[i];
294
+ if (!name)
295
+ continue;
286
296
  const pathToHere = resolvedParts.slice(0, i + 1).join('.');
287
- const isArr = arrayFieldPaths.has(pathToHere) || arrayFieldPaths.has(pathToHere.split(':')[0]);
288
- levels.push({ name: resolvedParts[i], isArray: isArr });
297
+ const isArr = arrayFieldPaths.has(pathToHere) || arrayFieldPaths.has(stripSliceSuffix(pathToHere));
298
+ levels.push({ name, isArray: isArr });
289
299
  }
290
300
  const lines = [];
291
301
  let indent = ' ';
@@ -295,6 +305,8 @@ export function buildNestedRequiredValidations(fields, arrayFieldPaths) {
295
305
  let isTyped = true;
296
306
  for (let i = 0; i < levels.length; i++) {
297
307
  const level = levels[i];
308
+ if (!level)
309
+ continue;
298
310
  const child = isTyped ? `${currentVar}.${level.name}` : `at(${currentVar}, '${level.name}')`;
299
311
  if (level.isArray) {
300
312
  const loopVar = `_nrEl${i}`;
@@ -393,7 +405,7 @@ function emitNestedFixedValue(result, rel, field, arrayFieldPaths, profileUrl, b
393
405
  .some(candidate => candidate.split('.').some(segment => segment.includes(':')));
394
406
  if (hasSliceQualifier)
395
407
  return;
396
- const isArrayPath = (p) => arrayFieldPaths.has(p) || arrayFieldPaths.has(p.split(':')[0]);
408
+ const isArrayPath = (p) => arrayFieldPaths.has(p) || arrayFieldPaths.has(stripSliceSuffix(p));
397
409
  const segments = rel.split('.');
398
410
  const parents = segments.slice(0, -1);
399
411
  const leaf = segments[segments.length - 1];
@@ -547,9 +559,9 @@ export function buildProhibitedFieldValidations(fields, arrayFieldPaths, fieldPa
547
559
  const resolvedParts = eidRel.split('.').map(p => {
548
560
  if (p.includes(':')) {
549
561
  const [basePart, sliceName] = p.split(':', 2);
550
- if (basePart.includes('[x]') && sliceName)
562
+ if (basePart?.includes('[x]') && sliceName)
551
563
  return sliceName;
552
- return basePart;
564
+ return basePart ?? p;
553
565
  }
554
566
  return p.replace(/\[x\]$/, '');
555
567
  });
@@ -573,9 +585,13 @@ export function buildProhibitedFieldValidations(fields, arrayFieldPaths, fieldPa
573
585
  }
574
586
  else if (pathParts.length >= 2) {
575
587
  const arrayParent = pathParts[0];
576
- if (!arrayFieldPaths.has(arrayParent) && !arrayFieldPaths.has(arrayParent.split(':')[0]))
588
+ if (!arrayParent)
589
+ return;
590
+ if (!arrayFieldPaths.has(arrayParent) && !arrayFieldPaths.has(stripSliceSuffix(arrayParent)))
591
+ return;
592
+ const leafField = pathParts.at(-1);
593
+ if (!leafField)
577
594
  return;
578
- const leafField = pathParts[pathParts.length - 1];
579
595
  const fullPath = `${resourceType}.${rel}`;
580
596
  // `at()` walks the dotted path and yields `unknown`, so the prohibited
581
597
  // field is probed without widening the element to `any`.
@@ -703,7 +719,7 @@ const CONTAINER_TYPES = new Set([
703
719
  * `constraint` looking like an HL7-only finding.
704
720
  */
705
721
  function buildArrayNestedPrimitiveKindValidations(fields, arrayFieldPaths, result) {
706
- const isArrayPath = (p) => arrayFieldPaths.has(p) || arrayFieldPaths.has(p.split(':')[0]);
722
+ const isArrayPath = (p) => arrayFieldPaths.has(p) || arrayFieldPaths.has(stripSliceSuffix(p));
707
723
  const seen = new Set();
708
724
  for (const field of fields) {
709
725
  if (field.sliceName || field.isArray)
@@ -846,6 +862,7 @@ export function buildStructuralTypeValidations(fields, arrayFieldPaths) {
846
862
  if (_stValue !== undefined && _stValue !== null && typeof _stValue !== 'object') {
847
863
  const _stType = at(resource, 'resourceType');
848
864
  errors.push("The property ${rel} must be an Object, not a Primitive property (at " + (typeof _stType === 'string' ? _stType : "Resource") + ".${rel})");
865
+ _structuralFailures.add('${rel}');
849
866
  }
850
867
  }`);
851
868
  }
@@ -8,7 +8,7 @@ import { buildNestedRequiredValidations, buildFixedValueValidations, buildProhib
8
8
  import { buildBindingValidations } from './validatorBindingBuilder.js';
9
9
  import { generateBundleRefValidation, generateContainedRefValidation, generateExtensionStructuralValidation, generateAggregationRefValidation, buildReferenceTargetTypeValidation, buildBundleEntryResolution } from './validatorTemplates.js';
10
10
  import { HELPER_NAMES } from './validatorRuntime.js';
11
- import { quoteFhirPathPath, normalizeCollectionTypeFilters, isBaseElementInvariant } from './validatorExpressions.js';
11
+ import { isBaseElementInvariant, scopeConstraintExpression } from './validatorExpressions.js';
12
12
  const log = logger.withTag('validator');
13
13
  /**
14
14
  * A constraint declared on a slice applies only to that slice's members.
@@ -407,55 +407,40 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
407
407
  const lastSegment = path.split('.').pop() || '';
408
408
  return !!lastSegment && relativeFieldNames.has(`${path}.${lastSegment}`);
409
409
  };
410
+ // Paths a structural type check can actually record, read back from the code
411
+ // just emitted rather than re-deriving which fields qualify — two copies of
412
+ // that predicate would drift, and a guard on a path nothing records is a dead
413
+ // conditional in every generated validator.
414
+ const structuralFailurePaths = new Set([...structuralTypeValidations.join('').matchAll(/_structuralFailures\.add\('([^']+)'\)/g)].map(m => m[1]));
415
+ const hasStructuralChecks = structuralFailurePaths.size > 0;
410
416
  // ── Generate FHIRPath evaluation code ───────────────────────────────────
411
417
  const validationLogic = filteredConstraints
412
418
  .map((item, index) => {
413
419
  const constraint = item.constraint;
414
420
  const fieldPath = item.fieldPath;
415
- let escapedExpression = normalizeCollectionTypeFilters(constraint.expression || '')
416
- .replace(/\s*\n\s*/g, ' ')
417
- .replace(/\\/g, '\\\\')
418
- .replace(/"/g, '\\"');
421
+ // Shared with the Zod emitter so both evaluate the identical expression.
422
+ const escapedExpression = scopeConstraintExpression(constraint.expression || '', fieldPath, {
423
+ isRecursiveElement,
424
+ sliceFilter: item.sliceFilter,
425
+ });
419
426
  const escapedHuman = (constraint.human || '').replace(/"/g, '\\"').replace(/[\r\n]+/g, ' ');
420
- // Scope constraints to their fieldPath if needed
421
- // If fieldPath exists and the expression doesn't already navigate FROM it,
422
- // wrap with .all() so the constraint evaluates relative to each element.
423
- // Only check the START of the expression — built constraints (e.g.,
424
- // "action.exists()") always begin with the fieldPath, while field-level
425
- // constraints are relative to the element and start with child names.
426
- // Checking for fieldPath anywhere in the expression causes false positives
427
- // for recursive elements (e.g., "resource.exists() != action.exists()"
428
- // on fieldPath "action" — the expression contains "action" but only as a
429
- // child property reference, not a root navigation).
430
- //
431
- // NOTE: resource-root constraints (fieldPath === baseResourceType) also
432
- // need .all() wrapping. FHIRPath `implies` returns empty when the
433
- // antecedent is missing, and .all() correctly converts that to false —
434
- // matching the HL7 / Firely validator behavior which fires the constraint.
435
- if (fieldPath) {
436
- const escaped = fieldPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
437
- const fieldPathNavigationPattern = new RegExp(`^${escaped}\\s*\\.`);
438
- const fieldPathStandalonePattern = new RegExp(`^${escaped}(?:[^.\\w]|$)`);
439
- const expressionRefersToField = fieldPathNavigationPattern.test(escapedExpression) || fieldPathStandalonePattern.test(escapedExpression);
440
- if (!expressionRefersToField) {
441
- const resourceTypePattern = /^[A-Z][a-zA-Z]+\./;
442
- if (!resourceTypePattern.test(escapedExpression)) {
443
- const walkRecursion = isRecursiveElement(fieldPath)
444
- && !isBaseElementInvariant(constraint.expression || '');
445
- const navigation = item.sliceFilter
446
- ? `${quoteFhirPathPath(fieldPath)}.where(${item.sliceFilter})`
447
- : quoteFhirPathPath(fieldPath);
448
- const scope = walkRecursion ? `repeat(${navigation})` : navigation;
449
- escapedExpression = `${scope}.all(${escapedExpression})`;
450
- }
451
- }
452
- }
453
427
  const needsTerminology = /\bmemberOf\b/.test(constraint.expression || '');
454
428
  let evaluateBlock = `
455
429
  const result${index} = await fhirpath.evaluate(resource, "${escapedExpression}", { resource, rootResource: resource }, fhirpath_model, fhirpathOptions);
456
430
  if (!result${index}.every(Boolean)) {
457
431
  ${constraint.severity === "error" ? "errors" : "warnings"}.push("Constraint violation: ${escapedHuman}");
458
432
  }`;
433
+ // One defect, one diagnostic. An element that failed its structural type
434
+ // check cannot satisfy an invariant that inspects its children, so
435
+ // evaluating them here adds a second error for the same root cause — the
436
+ // ele-1 ("must have a @value or children") cascade behind
437
+ // ClinicalUseDefinition*, where HL7 reports only the type error. HL7 is
438
+ // the reference implementation and reports the root cause once.
439
+ if (fieldPath && structuralFailurePaths.has(fieldPath)) {
440
+ evaluateBlock = `
441
+ if (!_structuralFailures.has('${fieldPath}')) {${evaluateBlock}
442
+ }`;
443
+ }
459
444
  // Gate memberOf() behind terminologyUrl. For resolve(), use try/catch
460
445
  // instead of a hard gate: FHIRPath `and` short-circuits, so expressions
461
446
  // like `(A and resolve().is(X)) or (B and focus.exists().not())` can still
@@ -545,6 +530,12 @@ ${body}
545
530
  fhirpathOptionsLines.push(' async: true as const,', ' ...(options?.terminologyUrl ? { terminologyUrl: options.terminologyUrl } : {}),', ' ...(options?.fhirServerUrl ? { fhirServerUrl: options.fhirServerUrl } : {}),');
546
531
  }
547
532
  const fhirpathOptionsBlock = `const fhirpathOptions = {\n${fhirpathOptionsLines.join('\n')}\n };`;
533
+ // Paths whose value is the wrong JSON kind. Populated by the structural type
534
+ // checks, which the assembled body now runs before any invariant so the guard
535
+ // has something to read.
536
+ const structuralFailureDecl = hasStructuralChecks
537
+ ? 'const _structuralFailures = new Set<string>();\n '
538
+ : '';
548
539
  let valueSetImportStatements = '';
549
540
  if (valueSetImports.size > 0) {
550
541
  valueSetImportStatements = Array.from(valueSetImports.entries())
@@ -562,8 +553,8 @@ ${body}
562
553
  code: `${fhirpathImport}${optionsImport}${valueSetImportStatements}${validatorImportStatements}export async function validate${interfaceName}(resource: ${interfaceName}, options?: ValidatorOptions): Promise<{ errors: string[]; warnings: string[] }> {
563
554
  const errors: string[] = [];
564
555
  const warnings: string[] = [];
565
- ${fhirpathOptionsBlock}
566
- ${bundleEntryResolution}${referenceTargetTypeValidation}${validationLogic}${fixedPatternValidations.join('')}${nestedRequiredValidations.join('')}${fixedValueValidations.join('')}${structuralTypeValidations.join('')}${prohibitedFieldValidations.join('')}${bindingValidations.join('')}${primitiveFormatValidations.join('')}${choiceNarrowingValidations.join('')}${sliceValidations.join('')}${closedSlicingValidations.join('')}${datatypeProfileDelegations.join('')}${nonDomainTextCheck}${extensionStructuralValidation}${containedRefValidation}${bundledAggregationValidation}${bundleRefValidation}
556
+ ${structuralFailureDecl}${fhirpathOptionsBlock}
557
+ ${bundleEntryResolution}${referenceTargetTypeValidation}${structuralTypeValidations.join('')}${validationLogic}${fixedPatternValidations.join('')}${nestedRequiredValidations.join('')}${fixedValueValidations.join('')}${prohibitedFieldValidations.join('')}${bindingValidations.join('')}${primitiveFormatValidations.join('')}${choiceNarrowingValidations.join('')}${sliceValidations.join('')}${closedSlicingValidations.join('')}${datatypeProfileDelegations.join('')}${nonDomainTextCheck}${extensionStructuralValidation}${containedRefValidation}${bundledAggregationValidation}${bundleRefValidation}
567
558
  return { errors, warnings };
568
559
  }`,
569
560
  valueSetImports
@@ -26,8 +26,19 @@ export interface ValidatorOptions {
26
26
  terminologyUrl?: string;
27
27
  /** FHIR server URL for resolve() evaluation (e.g. "https://hapi.fhir.org/baseR4"). */
28
28
  fhirServerUrl?: string;
29
- /** HTTP headers keyed by server URL — used for auth with terminology or FHIR servers. */
30
- httpHeaders?: Record<string, string>;
29
+ /**
30
+ * HTTP headers for auth with terminology or FHIR servers, keyed by server base
31
+ * URL, each mapping header name to value:
32
+ *
33
+ * { "https://tx.fhir.org/r4": { Authorization: "Bearer …" } }
34
+ *
35
+ * Two levels, not one. The comment always said "keyed by server URL" while the
36
+ * type said \`Record<string, string>\`, so a caller typing against it wrote
37
+ * \`{ Authorization: "Bearer …" }\` — which fhirpath reads as base URL
38
+ * "Authorization" with a string where a header map belongs, and the header is
39
+ * never sent. fhirpath 5.1.0 corrected the same mistake in its own declaration.
40
+ */
41
+ httpHeaders?: Record<string, Record<string, string>>;
31
42
  /** AbortSignal for cancelling long-running async evaluations. */
32
43
  signal?: AbortSignal;
33
44
  /** Debug tracing callback — invoked for every FHIRPath trace() call. */
@@ -2,6 +2,7 @@
2
2
  * ValueSet Generator
3
3
  * Generates TypeScript files for FHIR ValueSets with runtime validation support
4
4
  */
5
+ import { pascalCaseIdentifier } from '../../core/utils.js';
5
6
  /**
6
7
  * Assign a unique identifier to every ValueSet that will be emitted.
7
8
  *
@@ -148,16 +149,11 @@ export function generateValueSetTypeScript(valueSet, options) {
148
149
  * Sanitize ValueSet name for use as TypeScript identifier
149
150
  */
150
151
  export function sanitizeValueSetName(name) {
151
- // Remove common prefixes
152
- let sanitized = name.replace(/^ValueSet[-_]?/i, '');
153
- // Split on non-alphanumeric and capitalize each part
154
- const parts = sanitized.split(/[^a-zA-Z0-9]+/).filter(Boolean);
155
- sanitized = parts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('');
156
- // Ensure it starts with a letter
157
- if (/^[0-9]/.test(sanitized)) {
158
- sanitized = 'VS' + sanitized;
159
- }
160
- return sanitized || 'UnnamedValueSet';
152
+ return pascalCaseIdentifier(name, {
153
+ stripPrefix: /^ValueSet[-_]?/i,
154
+ digitPrefix: 'VS',
155
+ fallback: 'UnnamedValueSet',
156
+ });
161
157
  }
162
158
  /**
163
159
  * Generate a ValueSet registry file that exports all ValueSets