babelfhir-ts 1.5.19 → 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.
Files changed (38) hide show
  1. package/out/src/cli/updateCommand.js +19 -12
  2. package/out/src/generator/core/constants.js +19 -0
  3. package/out/src/generator/core/extensionSdReader.js +99 -0
  4. package/out/src/generator/core/utils.js +97 -3
  5. package/out/src/generator/emitters/class/classTemplate.js +8 -2
  6. package/out/src/generator/emitters/class/sliceElementDefaults.js +5 -3
  7. package/out/src/generator/emitters/client/clientGenerator.js +22 -6
  8. package/out/src/generator/emitters/client/smartAuthGenerator.js +4 -1
  9. package/out/src/generator/emitters/interface/backboneSliceTyping.js +15 -4
  10. package/out/src/generator/emitters/interface/importManager.js +28 -2
  11. package/out/src/generator/emitters/interface/interfaceFieldProcessor.js +21 -16
  12. package/out/src/generator/emitters/interface/interfaceFieldUtils.js +16 -0
  13. package/out/src/generator/emitters/interface/postProcessExtensions.js +1 -0
  14. package/out/src/generator/emitters/validator/closedSlicingValidation.js +146 -0
  15. package/out/src/generator/emitters/validator/sliceDelegation.js +450 -0
  16. package/out/src/generator/emitters/validator/sliceExtensionValidation.js +200 -35
  17. package/out/src/generator/emitters/validator/sliceValidatorGenerator.js +130 -299
  18. package/out/src/generator/emitters/validator/sliceValidatorUtils.js +89 -16
  19. package/out/src/generator/emitters/validator/subExtensionScope.js +100 -0
  20. package/out/src/generator/emitters/validator/validatorBindingBuilder.js +113 -237
  21. package/out/src/generator/emitters/validator/validatorBindingLeafEmitters.js +13 -4
  22. package/out/src/generator/emitters/validator/validatorConstraintBuilders.js +104 -63
  23. package/out/src/generator/emitters/validator/validatorExpressions.js +54 -0
  24. package/out/src/generator/emitters/validator/validatorFieldBuilders.js +492 -46
  25. package/out/src/generator/emitters/validator/validatorGenerator.js +170 -169
  26. package/out/src/generator/emitters/validator/validatorRuntime.js +279 -0
  27. package/out/src/generator/emitters/validator/validatorTemplates.js +186 -54
  28. package/out/src/generator/emitters/zod/zodRefinementBuilder.js +29 -19
  29. package/out/src/generator/emitters/zod/zodSchemaGenerator.js +16 -1
  30. package/out/src/generator/index.js +21 -11
  31. package/out/src/generator/parser/packageParser.js +1 -8
  32. package/out/src/generator/parser/sdParser.js +1 -0
  33. package/out/src/generator/sdProcessor.js +303 -30
  34. package/out/src/generator/sdProcessorHelpers.js +1 -1
  35. package/out/src/generator/sdProcessorRequiredFields.js +120 -0
  36. package/out/src/main.js +37 -11
  37. package/package.json +3 -1
  38. package/parity-matrix.json +18 -18
@@ -1,7 +1,186 @@
1
+ import { choicePropertyName, narrowedChoiceType } from '../../core/utils.js';
2
+ /**
3
+ * The element slots FHIR types as `Resource`, i.e. the ones that hold an inline
4
+ * resource rather than a reference to one: DomainResource.contained,
5
+ * Bundle.entry.resource and Parameters.parameter.resource. A profile narrows them
6
+ * by naming concrete resource types — QuestionnaireResponse.contained restricted
7
+ * to Questionnaire, for instance.
8
+ */
9
+ export const INLINE_RESOURCE_SLOTS = new Set(['contained', 'resource']);
10
+ /** Type codes that place no restriction on which resource may appear. */
11
+ const ABSTRACT_RESOURCE_TYPES = new Set(['Resource', 'DomainResource']);
12
+ /**
13
+ * A complex-typed element below the root must still be a JSON object.
14
+ *
15
+ * The depth-1 pass covers only top-level fields, and it also skips container types,
16
+ * so Bundle.entry.resource was never checked at any depth — HL7 reports
17
+ * "The property resource must be an Object, not a Primitive property
18
+ * (at Bundle.entry[0].resource)" and we reported nothing. Containers are included
19
+ * here: a Resource-valued element holding a primitive is malformed regardless.
20
+ *
21
+ * Only fires on a value that is present and primitive, which is the same condition
22
+ * HL7 uses, so this cannot flag a conformant resource.
23
+ */
24
+ function buildNestedObjectTypeValidations(fields, arrayFieldPaths, result) {
25
+ const isArrayPath = (p) => arrayFieldPaths.has(p) || arrayFieldPaths.has(p.split(':')[0]);
26
+ const seen = new Set();
27
+ for (const field of fields) {
28
+ if (field.sliceName || field.isArray)
29
+ continue;
30
+ const fullPath = field.name || '';
31
+ if (fullPath.includes('[x]'))
32
+ continue;
33
+ const rel = fullPath.replace(/^[^.]+\./, '');
34
+ if (!rel.includes('.') || seen.has(rel))
35
+ continue;
36
+ const fieldType = field.type || field.baseTypeCode;
37
+ if (!fieldType || isFhirPrimitive(fieldType))
38
+ continue;
39
+ // A primitive-valued element is checked by the format validations instead.
40
+ if (fieldType.startsWith('http://hl7.org/fhirpath/'))
41
+ continue;
42
+ const segments = rel.split('.');
43
+ const parents = segments.slice(0, -1);
44
+ const leaf = segments[segments.length - 1];
45
+ // Walk the parents, iterating any array level so each member is checked.
46
+ let expr = 'resource';
47
+ const open = [];
48
+ let indent = ' ';
49
+ parents.forEach((segment, i) => {
50
+ const pathToHere = parents.slice(0, i + 1).join('.');
51
+ if (isArrayPath(pathToHere)) {
52
+ const loopVar = `_soEl${i}`;
53
+ open.push(`${indent}for (const ${loopVar} of arr(at(${expr}, '${segment}'))) {`);
54
+ indent += ' ';
55
+ expr = loopVar;
56
+ }
57
+ else {
58
+ expr = `at(${expr}, '${segment}')`;
59
+ }
60
+ });
61
+ seen.add(rel);
62
+ const closes = open.map((_l, i) => ' '.repeat(open.length - i + 1) + '}').join('\n');
63
+ const body = `${indent}const _soValue = at(${expr}, '${leaf}');
64
+ ${indent}if (_soValue !== undefined && _soValue !== null && typeof _soValue !== 'object') {
65
+ ${indent} errors.push("The property ${leaf} must be an Object, not a Primitive property (at ${rel})");
66
+ ${indent}}`;
67
+ result.push(`
68
+ // Structural type check: ${rel} must be an Object
69
+ {
70
+ ${[...open, body, closes].filter(Boolean).join('\n')}
71
+ }`);
72
+ }
73
+ }
74
+ /**
75
+ * Build inline-resource type validations.
76
+ *
77
+ * An inline resource must carry a `resourceType`, and where the profile narrows
78
+ * the slot that type must be one it allows. We checked neither, so a resource
79
+ * generated without a resourceType — or of a type the profile forbids — passed
80
+ * while HL7 reported "Unable to find resourceType property" and "The type 'X' is
81
+ * not valid - no resources allowed here".
82
+ *
83
+ * Slots left as Resource/DomainResource are unrestricted and only get the
84
+ * resourceType presence check, which is what HL7 does too.
85
+ */
86
+ export function buildInlineResourceTypeValidations(fields, profileUrl) {
87
+ const result = [];
88
+ // A slot can appear twice once base and profile fields are merged; emitting both
89
+ // would report the same violation twice.
90
+ const seen = new Set();
91
+ for (const field of fields) {
92
+ if (field.sliceName)
93
+ continue;
94
+ const rel = (field.name || '').replace(/^[^.]+\./, '');
95
+ if (seen.has(rel))
96
+ continue;
97
+ // Only the top-level slot for now; Bundle.entry.resource sits under an array
98
+ // and is covered by the Bundle-specific entry validation.
99
+ if (!rel || rel.includes('.') || !INLINE_RESOURCE_SLOTS.has(rel))
100
+ continue;
101
+ const declaredCodes = (field.typeOptions?.length
102
+ ? field.typeOptions.map(option => option.code)
103
+ : [field.type || field.baseTypeCode || '']).filter(code => /^[A-Z][A-Za-z]+$/.test(code));
104
+ if (declaredCodes.length === 0)
105
+ continue;
106
+ seen.add(rel);
107
+ const allowed = [...new Set(declaredCodes)].filter(code => !ABSTRACT_RESOURCE_TYPES.has(code));
108
+ const member = field.isArray ? '_irItem' : `at(resource, '${rel}')`;
109
+ const typeCheck = allowed.length > 0
110
+ ? `
111
+ } else if (typeof _irType === 'string' && !${JSON.stringify(allowed)}.includes(_irType)) {
112
+ errors.push("The type '" + _irType + "' is not valid - no resources allowed here (allowed = ${allowed.join(', ')})");`
113
+ : '';
114
+ // A contained resource is only reachable through a '#id' reference, so FHIR
115
+ // requires the id — HL7 reports it right alongside the missing resourceType.
116
+ const idCheck = rel === 'contained'
117
+ ? `
118
+ if (at(${member}, 'id') === undefined) {
119
+ errors.push("Resource requires an id, but none is present");
120
+ }`
121
+ : '';
122
+ // An untyped inline resource reads as a bare `Resource`, so a narrowed slot is
123
+ // also violated on type. HL7 reports all three together — the missing property,
124
+ // the profile's declared type, and the slot's allowed set — and reporting only
125
+ // the first left the other two attributed to HL7 alone.
126
+ const untypedIsWrongType = allowed.length > 0
127
+ ? `
128
+ errors.push("The Profile '${(profileUrl || '').replace(/'/g, "\\'")}' definition allows for the type ${allowed.join(', ')} but found type Resource");
129
+ errors.push("The type 'Resource' is not valid - no resources allowed here (allowed = ${allowed.join(', ')})");`
130
+ : '';
131
+ const body = `
132
+ const _irType = at(${member}, 'resourceType');
133
+ if (_irType === undefined) {
134
+ errors.push("Unable to find resourceType property");${untypedIsWrongType}${typeCheck}
135
+ }${idCheck}`;
136
+ result.push(field.isArray
137
+ ? `
138
+ // Inline resource check for ${rel}${allowed.length > 0 ? ` (restricted to ${allowed.join(', ')})` : ''}
139
+ for (const _irItem of arr(at(resource, '${rel}'))) {${body}
140
+ }`
141
+ : `
142
+ // Inline resource check for ${rel}${allowed.length > 0 ? ` (restricted to ${allowed.join(', ')})` : ''}
143
+ if (at(resource, '${rel}') !== undefined) {${body}
144
+ }`);
145
+ }
146
+ return result;
147
+ }
1
148
  /**
2
149
  * Build deeply nested required field validations.
3
150
  * For fields like component.value[x].data (min=1) where the parent is an array.
4
151
  */
152
+ /**
153
+ * Rewrite an element id whose only slice qualifiers name choice-type variants into
154
+ * the concrete JSON path, or undefined when any qualifier discriminates a subset.
155
+ *
156
+ * `ingredient.item[x]:itemCodeableConcept.text` → `ingredient.itemCodeableConcept.text`.
157
+ * A qualifier is only accepted when its base ends in `[x]` and the slice name starts
158
+ * with the base property, which is the convention that makes the slice name the
159
+ * property name. `identifier:NPI.value` fails both tests and is still skipped: NPI
160
+ * is one identifier among several, so requiring its children of every identifier
161
+ * would report a conformant resource.
162
+ */
163
+ function leafFieldName(parts) {
164
+ return parts[parts.length - 1];
165
+ }
166
+ function resolveChoiceSliceSegments(elementIdRel) {
167
+ const segments = elementIdRel.split('.');
168
+ const out = [];
169
+ for (const segment of segments) {
170
+ if (!segment.includes(':')) {
171
+ out.push(segment.replace(/\[x\]$/, ''));
172
+ continue;
173
+ }
174
+ const [base, sliceName] = segment.split(':');
175
+ if (!base.endsWith('[x]') || !sliceName)
176
+ return undefined;
177
+ const baseProp = base.slice(0, -'[x]'.length);
178
+ if (!sliceName.startsWith(baseProp) || sliceName === baseProp)
179
+ return undefined;
180
+ out.push(sliceName);
181
+ }
182
+ return out.join('.');
183
+ }
5
184
  export function buildNestedRequiredValidations(fields, arrayFieldPaths) {
6
185
  const result = [];
7
186
  fields
@@ -15,44 +194,87 @@ export function buildNestedRequiredValidations(fields, arrayFieldPaths) {
15
194
  let rel = relFull.replace(/^[^.]+\./, '').replace(/\[x\]/g, '');
16
195
  if (!rel)
17
196
  return;
18
- // Skip slice-specific fields
197
+ // Skip slice-specific fields: a slice's requirements bind only its own
198
+ // members, and this builder emits an unconditional check.
199
+ //
200
+ // One exception, and it is not really a subset. A choice-type slice names a
201
+ // JSON property rather than discriminating within one: FHIR writes
202
+ // `item[x]:itemCodeableConcept` for the CodeableConcept variant of `item[x]`,
203
+ // and an instance carrying that variant writes `itemCodeableConcept`. So the
204
+ // slice *is* the property, and its required children apply whenever the
205
+ // property is present — which is what HL7 reports as
206
+ // "Medication.ingredient.item[x]:itemCodeableConcept.text: minimum required = 1"
207
+ // where we said nothing, because `field.name` flattens `item[x]` to `item`
208
+ // and loses which variant was meant.
19
209
  const elementId = field.elementId || relFull;
20
- if (elementId.includes(':'))
21
- return;
210
+ let choiceSliceResolved = false;
211
+ if (elementId.includes(':')) {
212
+ const resolved = resolveChoiceSliceSegments(elementId.replace(/^[^.]+\./, ''));
213
+ if (!resolved)
214
+ return;
215
+ rel = resolved;
216
+ choiceSliceResolved = true;
217
+ }
22
218
  const pathParts = rel.split('.');
23
219
  if (pathParts.length < 2)
24
220
  return;
25
- // Resolve choice type in path
26
- if (relFull.includes('[x]')) {
27
- const relFullWithoutLeaf = relFull.substring(0, relFull.lastIndexOf('.'));
28
- const parentField = fields.find(f => f.name === relFullWithoutLeaf);
29
- const parentType = parentField?.baseTypeCode || parentField?.type;
30
- // BackboneElement/Element are container types, never valid choice[x] suffixes.
31
- // e.g. CodeSystem.concept.property is BackboneElement, but property.value[x]
32
- // resolves to code/Coding/string/etc — not valueBackboneElement.
33
- if (parentType && parentType !== 'any'
34
- && parentType !== 'BackboneElement' && parentType !== 'Element') {
35
- const choiceSuffix = parentType.charAt(0).toUpperCase() + parentType.slice(1);
36
- rel = relFull.replace(/^[^.]+\./, '').replace(/\[x\]/, choiceSuffix);
37
- }
38
- else {
221
+ // Resolve choice type in path. Skipped when the element id already named the
222
+ // variant: this block reads the *leaf's* own type, which for a nested leaf
223
+ // (…item[x]:itemCodeableConcept.text) is the text's type, not the choice's.
224
+ if (relFull.includes('[x]') && !choiceSliceResolved) {
225
+ // The JSON property name comes from the element's *own* type, not its parent's.
226
+ // Reading the parent discarded the check whenever that parent was a container:
227
+ // Claim.item.location[x] is typed CodeableConcept, so the property is
228
+ // locationCodeableConcept, but item is a BackboneElement and the whole check
229
+ // was dropped — HL7 reported "Claim.item.location[x]: minimum required = 1"
230
+ // alone.
231
+ const ownTypes = (field.typeOptions?.length
232
+ ? field.typeOptions.map(option => option.code)
233
+ : [field.type || field.baseTypeCode || '']).filter(code => /^[A-Za-z][A-Za-z0-9]*$/.test(code)
234
+ && code !== 'BackboneElement' && code !== 'Element' && code !== 'any');
235
+ const uniqueTypes = [...new Set(ownTypes)];
236
+ // Only an unambiguous choice yields one property name. A multi-typed choice
237
+ // may legitimately carry any variant, and picking one would flag a conformant
238
+ // resource — which is what the previous depth guard was protecting against.
239
+ if (uniqueTypes.length !== 1)
39
240
  return;
40
- }
241
+ const choiceSuffix = uniqueTypes[0].charAt(0).toUpperCase() + uniqueTypes[0].slice(1);
242
+ rel = relFull.replace(/^[^.]+\./, '').replace(/\[x\]/, choiceSuffix);
41
243
  }
42
244
  const resolvedParts = rel.split('.');
43
- // Skip shallow choice-type paths — choice types at depth 2 are direct children
44
- // of array parents where the substitution may not apply to all instances
45
- if (relFull.includes('[x]') && resolvedParts.length < 3)
46
- return;
47
- const arrayParent = resolvedParts[0];
48
- if (!arrayFieldPaths.has(arrayParent) && !arrayFieldPaths.has(arrayParent.split(':')[0]))
49
- return;
245
+ const isArrayPath = (p) => arrayFieldPaths.has(p) || arrayFieldPaths.has(p.split(':')[0]);
50
246
  // Check which intermediate parts are arrays
51
247
  const intermediateParts = resolvedParts.slice(1, -1);
52
- const hasIntermediateArray = intermediateParts.some((_part, idx) => {
53
- const intermediatePath = resolvedParts.slice(0, idx + 2).join('.');
54
- return arrayFieldPaths.has(intermediatePath) || arrayFieldPaths.has(intermediatePath.split(':')[0]);
55
- });
248
+ const hasIntermediateArray = intermediateParts.some((_part, idx) => isArrayPath(resolvedParts.slice(0, idx + 2).join('.')));
249
+ const arrayParent = resolvedParts[0];
250
+ const topIsArray = isArrayPath(arrayParent);
251
+ // An array is needed somewhere above the leaf, but it need not be the first
252
+ // segment: the flat branches below index straight into resource.<arrayParent>,
253
+ // whereas the nested-loop branch walks any shape. Requiring the *first*
254
+ // segment to be an array meant a path whose only array sits deeper
255
+ // (Observation.code.coding.code — code is 0..1, coding is the array) bailed
256
+ // out and emitted nothing, leaving HL7 to report the missing element alone.
257
+ if (!topIsArray && !hasIntermediateArray) {
258
+ // A choice-slice path is emitted even when no level is a known array.
259
+ // `Medication.ingredient` is 0..* in the base but the KBV differential
260
+ // constrains only its `min`, so the merged field carries no max and
261
+ // `isArray` reads false — the check bailed and HL7 reported
262
+ // "…itemCodeableConcept.text: minimum required = 1" alone. `elementsAt`
263
+ // flattens an array level and passes a single object through unchanged,
264
+ // so it is right either way and does not depend on that flag.
265
+ if (!choiceSliceResolved)
266
+ return;
267
+ const walk = resolvedParts.slice(0, -1).map(p => `'${p}'`).join(', ');
268
+ result.push(`
269
+ // Nested required field: ${rel} (min=1)
270
+ for (const _nrEl of elementsAt(resource, [${walk}])) {
271
+ const _nrLeaf = at(_nrEl, '${leafFieldName(resolvedParts)}');
272
+ if (_nrLeaf === undefined || _nrLeaf === null) {
273
+ errors.push("Missing required member: '${leafFieldName(resolvedParts)}'");
274
+ }
275
+ }`);
276
+ return;
277
+ }
56
278
  const leafField = resolvedParts[resolvedParts.length - 1];
57
279
  // Check if the leaf field itself is an array — if so, don't flag Array values as absent
58
280
  const fullLeafPath = resolvedParts.join('.');
@@ -148,17 +370,88 @@ export function buildNestedRequiredValidations(fields, arrayFieldPaths) {
148
370
  /**
149
371
  * Build fixed value validations (fixedValue on fields).
150
372
  */
151
- export function buildFixedValueValidations(fields, _arrayFieldPaths) {
373
+ /**
374
+ * Emit a fixed-value check for an element below the resource root.
375
+ *
376
+ * Uses HL7's wording, whose `#Type.path` fragment is what attributes the finding
377
+ * to the pinned leaf: a bare message would land on `constraint` instead of
378
+ * `reference`. Array parents are walked so each member is checked.
379
+ */
380
+ function emitNestedFixedValue(result, rel, field, arrayFieldPaths, profileUrl, baseResourceType) {
381
+ const fixedValue = field.fixedValue;
382
+ if (typeof fixedValue !== 'string' && typeof fixedValue !== 'number' && typeof fixedValue !== 'boolean')
383
+ return;
384
+ // A pin declared on a slice applies only to that slice's members. field.name
385
+ // carries no slice identity, so emitting it against the base element applied one
386
+ // slice's pin to every member: AuditEvent.agent:user.requestor is fixed to true,
387
+ // and checking it on all agents failed agent:client and agent:server, which
388
+ // legitimately carry false. Same rule the forbidden-field builder follows.
389
+ // Slice qualifiers appear in elementId for most fields and in name for some, so
390
+ // both are checked — relying on elementId alone let a Parameters part slice
391
+ // through.
392
+ const hasSliceQualifier = [field.elementId || '', field.name || '', rel]
393
+ .some(candidate => candidate.split('.').some(segment => segment.includes(':')));
394
+ if (hasSliceQualifier)
395
+ return;
396
+ const isArrayPath = (p) => arrayFieldPaths.has(p) || arrayFieldPaths.has(p.split(':')[0]);
397
+ const segments = rel.split('.');
398
+ const parents = segments.slice(0, -1);
399
+ const leaf = segments[segments.length - 1];
400
+ let expr = 'resource';
401
+ const open = [];
402
+ let indent = ' ';
403
+ parents.forEach((segment, i) => {
404
+ const pathToHere = parents.slice(0, i + 1).join('.');
405
+ if (isArrayPath(pathToHere)) {
406
+ const loopVar = `_fvParent${i}`;
407
+ open.push(`${indent}for (const ${loopVar} of arr(at(${expr}, '${segment}'))) {`);
408
+ indent += ' ';
409
+ expr = loopVar;
410
+ }
411
+ else {
412
+ expr = `at(${expr}, '${segment}')`;
413
+ }
414
+ });
415
+ const members = field.isArray ? `arr(at(${expr}, '${leaf}'))` : `[at(${expr}, '${leaf}')]`;
416
+ const literal = JSON.stringify(fixedValue);
417
+ const anchor = `${(profileUrl || '').replace(/'/g, "\\'")}#${baseResourceType || 'Resource'}.${rel}`;
418
+ const body = `${indent}for (const _fvValue of ${members}) {
419
+ ${indent} if (_fvValue !== undefined && _fvValue !== null && _fvValue !== ${literal}) {
420
+ ${indent} errors.push("Value is '" + _fvValue + "' but is fixed to '${String(fixedValue).replace(/"/g, '\\"')}' in the profile ${anchor}");
421
+ ${indent} }
422
+ ${indent}}`;
423
+ const closes = open.map((_l, i) => ' '.repeat(open.length - i + 1) + '}').join('\n');
424
+ result.push(`
425
+ // Fixed value constraint for ${rel}
426
+ {
427
+ ${[...open, body, closes].filter(Boolean).join('\n')}
428
+ }`);
429
+ }
430
+ export function buildFixedValueValidations(fields, arrayFieldPaths, profileUrl, baseResourceType) {
152
431
  const result = [];
153
432
  fields
154
433
  .filter((field) => field.fixedValue !== undefined)
155
434
  .forEach((field) => {
156
435
  const relFull = field.name || '';
157
- const rel = relFull.replace(/^[^.]+\./, '').replace(/\[x\]/g, '');
436
+ let rel = relFull.replace(/^[^.]+\./, '');
158
437
  if (!rel)
159
438
  return;
439
+ // A choice element is read through the property named for its type. Stripping
440
+ // `[x]` produced `resource.value`, which no resource carries and which the
441
+ // emitted interface — now naming the concrete property — rejects outright.
442
+ if (rel.endsWith('[x]')) {
443
+ const narrowedTo = narrowedChoiceType(field);
444
+ if (!narrowedTo)
445
+ return;
446
+ rel = choicePropertyName(rel.slice(0, -'[x]'.length), narrowedTo);
447
+ }
448
+ rel = rel.replace(/\[x\]/g, '');
160
449
  const pathParts = rel.split('.');
161
450
  if (pathParts.length > 1 && pathParts[0] !== 'meta') {
451
+ // A fixed value below the root was dropped entirely. cdex pins
452
+ // Task.owner.reference to '#practitionerrole', and HL7 reported the
453
+ // mismatch while we reported nothing.
454
+ emitNestedFixedValue(result, rel, field, arrayFieldPaths, profileUrl, baseResourceType);
162
455
  return;
163
456
  }
164
457
  const humanRel = rel || relFull;
@@ -227,6 +520,20 @@ export function buildProhibitedFieldValidations(fields, arrayFieldPaths, fieldPa
227
520
  // Skip if ANY segment has a slice qualifier — the constraint is
228
521
  // slice-scoped (e.g. name:Geburtsname.given) and must not be emitted
229
522
  // as a global prohibition.
523
+ //
524
+ // TODO(parity): this correctly avoids a global prohibition but emits nothing
525
+ // at all, so slice-scoped max=0 goes unchecked. A complex extension sets its
526
+ // own value[x] to max=0 — sub-extensions or a value, never both — and we miss
527
+ // it: on DTRStdQuestionnaire, HL7 reports "Extension.value[x]: max allowed =
528
+ // 0, but found 1" and "Extension.extension: minimum required = 2, but only
529
+ // found 0" for item.extension:contextExpression while we report neither.
530
+ //
531
+ // Fixing it needs a slice-scoped emission, not a relaxed guard: iterate the
532
+ // extensions matching the slice's url and assert the absence of any value*
533
+ // property. It cannot name one property, because a forbidden choice narrows
534
+ // to no type — which is why the choice resolution elsewhere in this file
535
+ // skips it too. Removing this guard instead would reintroduce the regression
536
+ // where AuditEvent.agent:user.requestor was demanded of every agent.
230
537
  const segments = field.elementId.split('.');
231
538
  if (segments.some(seg => seg.includes(':')))
232
539
  return false;
@@ -260,7 +567,7 @@ export function buildProhibitedFieldValidations(fields, arrayFieldPaths, fieldPa
260
567
  if (pathParts.length === 1) {
261
568
  result.push(`
262
569
  // Prohibited field: ${resourceType}.${rel} (max=0)
263
- if (_bRes.${rel} !== undefined) {
570
+ if (at(resource, '${rel}') !== undefined) {
264
571
  errors.push("${resourceType}.${rel}: max allowed = 0, but found 1");
265
572
  }`);
266
573
  }
@@ -277,7 +584,7 @@ export function buildProhibitedFieldValidations(fields, arrayFieldPaths, fieldPa
277
584
  : `'${leafField}'`;
278
585
  result.push(`
279
586
  // Prohibited field: ${fullPath} (max=0)
280
- for (const _el of arr(_bRes.${arrayParent})) {
587
+ for (const _el of arr(at(resource, '${arrayParent}'))) {
281
588
  if (at(_el, ${atPath}) !== undefined) {
282
589
  errors.push("${fullPath}: max allowed = 0, but found 1");
283
590
  }
@@ -315,8 +622,11 @@ export function buildPrimitiveFormatValidations(fields) {
315
622
  const propName = `${rel}${suffix}`;
316
623
  result.push(`
317
624
  // ${fhirType} format validation for ${propName} (choice type)
318
- if (typeof _bRes.${propName} === 'string' && !${info.regex}.test(_bRes.${propName})) {
319
- errors.push("Not a valid ${info.label} format: '" + _bRes.${propName} + "'");
625
+ {
626
+ const _fmtValue = at(resource, '${propName}');
627
+ if (typeof _fmtValue === 'string' && !${info.regex}.test(_fmtValue)) {
628
+ errors.push("Not a valid ${info.label} format: '" + _fmtValue + "'");
629
+ }
320
630
  }`);
321
631
  }
322
632
  }
@@ -327,8 +637,11 @@ export function buildPrimitiveFormatValidations(fields) {
327
637
  const { regex, label } = PRIMITIVE_FORMAT_REGEX[fhirType];
328
638
  result.push(`
329
639
  // ${fhirType} format validation for ${rel}
330
- if (typeof _bRes.${rel} === 'string' && !${regex}.test(_bRes.${rel})) {
331
- errors.push("Not a valid ${label} format: '" + _bRes.${rel} + "'");
640
+ {
641
+ const _fmtValue = at(resource, '${rel}');
642
+ if (typeof _fmtValue === 'string' && !${regex}.test(_fmtValue)) {
643
+ errors.push("Not a valid ${label} format: '" + _fmtValue + "'");
644
+ }
332
645
  }`);
333
646
  }
334
647
  });
@@ -341,6 +654,16 @@ const FHIR_PRIMITIVE_TYPES = new Set([
341
654
  'time', 'instant', 'code', 'id', 'oid', 'markdown', 'base64Binary',
342
655
  'uuid', 'positiveInt', 'unsignedInt', 'integer64', 'xhtml',
343
656
  ]);
657
+ /**
658
+ * FHIR primitives that serialize as a JSON number.
659
+ *
660
+ * integer64 is deliberately absent: R5 serializes it as a *string* to survive
661
+ * JavaScript's 53-bit safe integer range, so requiring a number would reject
662
+ * conformant data.
663
+ */
664
+ const NUMERIC_PRIMITIVES = new Set([
665
+ 'decimal', 'integer', 'positiveInt', 'unsignedInt',
666
+ ]);
344
667
  /** Check whether a FHIR type serializes as a JSON primitive (string/number/bool). */
345
668
  function isFhirPrimitive(fieldType) {
346
669
  if (FHIR_PRIMITIVE_TYPES.has(fieldType))
@@ -366,8 +689,72 @@ const CONTAINER_TYPES = new Set([
366
689
  *
367
690
  * Only top-level (depth-1) non-sliced fields are checked.
368
691
  */
369
- export function buildStructuralTypeValidations(fields, _arrayFieldPaths) {
692
+ /**
693
+ * A numeric or boolean primitive below a repeating element must still hold the right
694
+ * JSON kind.
695
+ *
696
+ * The flat pass deliberately skips any path with an array ancestor, because a path
697
+ * walk cannot descend one — so `ingredient.strength.numerator.value` went unchecked
698
+ * and HL7 reported "The value 'Example value' is not a valid decimal" alone. This
699
+ * emits the loop that pass declined to guess at.
700
+ *
701
+ * Both of HL7's headings are pushed: the value wording classifies as `unclassified`
702
+ * and the parse wording as `constraint`, so emitting only the first leaves
703
+ * `constraint` looking like an HL7-only finding.
704
+ */
705
+ function buildArrayNestedPrimitiveKindValidations(fields, arrayFieldPaths, result) {
706
+ const isArrayPath = (p) => arrayFieldPaths.has(p) || arrayFieldPaths.has(p.split(':')[0]);
707
+ const seen = new Set();
708
+ for (const field of fields) {
709
+ if (field.sliceName || field.isArray)
710
+ continue;
711
+ const rel = (field.name || '').replace(/^[^.]+\./, '');
712
+ if (!rel || !rel.includes('.') || rel.includes('[x]') || seen.has(rel))
713
+ continue;
714
+ const fieldType = field.type || field.baseTypeCode;
715
+ const jsonKind = NUMERIC_PRIMITIVES.has(fieldType || '') ? 'number'
716
+ : fieldType === 'boolean' ? 'boolean'
717
+ : undefined;
718
+ if (!jsonKind)
719
+ continue;
720
+ const segments = rel.split('.');
721
+ const parents = segments.slice(0, -1);
722
+ const leaf = segments[segments.length - 1];
723
+ // The flat pass already covers a path with no array on the way down.
724
+ if (!parents.some((_p, i) => isArrayPath(parents.slice(0, i + 1).join('.'))))
725
+ continue;
726
+ seen.add(rel);
727
+ let expr = 'resource';
728
+ const open = [];
729
+ let indent = ' ';
730
+ parents.forEach((segment, i) => {
731
+ if (isArrayPath(parents.slice(0, i + 1).join('.'))) {
732
+ const loopVar = `_pkEl${i}`;
733
+ open.push(`${indent}for (const ${loopVar} of arr(at(${expr}, '${segment}'))) {`);
734
+ indent += ' ';
735
+ expr = loopVar;
736
+ }
737
+ else {
738
+ expr = `at(${expr}, '${segment}')`;
739
+ }
740
+ });
741
+ const closes = open.map((_l, i) => ' '.repeat(open.length - i + 1) + '}').join('\n');
742
+ const body = `${indent}const _pkValue = at(${expr}, '${leaf}');
743
+ ${indent}if (_pkValue !== undefined && _pkValue !== null && typeof _pkValue !== '${jsonKind}') {
744
+ ${indent} errors.push("The value '" + String(_pkValue) + "' is not a valid ${fieldType}");
745
+ ${indent} errors.push("Error parsing JSON: the primitive value must be a ${jsonKind}");
746
+ ${indent}}`;
747
+ result.push(`
748
+ // Structural type check: ${rel} must be a JSON ${jsonKind} (${fieldType})
749
+ {
750
+ ${[...open, body, closes].filter(Boolean).join('\n')}
751
+ }`);
752
+ }
753
+ }
754
+ export function buildStructuralTypeValidations(fields, arrayFieldPaths) {
370
755
  const result = [];
756
+ buildNestedObjectTypeValidations(fields, arrayFieldPaths, result);
757
+ buildArrayNestedPrimitiveKindValidations(fields, arrayFieldPaths, result);
371
758
  for (const field of fields) {
372
759
  // Skip sliced fields — handled by slice validator
373
760
  if (field.sliceName)
@@ -380,27 +767,86 @@ export function buildStructuralTypeValidations(fields, _arrayFieldPaths) {
380
767
  if (fullPath.includes('[x]'))
381
768
  continue;
382
769
  const rel = fullPath.replace(/^[^.]+\./, '');
383
- if (!rel || rel.includes('.'))
384
- continue; // only top-level fields
770
+ if (!rel)
771
+ continue;
772
+ // A nested primitive is still worth checking, and only the primitive kind check
773
+ // below handles one: HL7 reports "The value 'Example value' is not a valid
774
+ // decimal" on Medication.amount.numerator.value, three levels down, where we
775
+ // reported nothing. The array and object checks stay top-level only, since both
776
+ // would need iteration to descend correctly.
777
+ const isNested = rel.includes('.');
778
+ if (isNested) {
779
+ // Any array on the way down needs a loop, not a path walk. Skipped rather than
780
+ // guessed at, so a repeating ancestor never yields a check against the array
781
+ // object itself.
782
+ // arrayFieldPaths is keyed on the *relative* path. Building the key from the
783
+ // full path never matched, so Bundle.entry.search.score was emitted as
784
+ // at(resource, 'entry', 'search', 'score') — a walk into an array, which
785
+ // yields undefined and checks nothing.
786
+ const segments = rel.split('.');
787
+ const hasArrayAncestor = segments.some((_seg, i) => arrayFieldPaths.has(segments.slice(0, i + 1).join('.')));
788
+ if (hasArrayAncestor)
789
+ continue;
790
+ if (!NUMERIC_PRIMITIVES.has(field.type || '') && field.type !== 'boolean')
791
+ continue;
792
+ }
385
793
  const fieldType = field.type || field.baseTypeCode;
386
794
  if (!fieldType)
387
795
  continue;
388
796
  // ── Array structural check ─────────────────────────────────────────
389
- if (field.isArray) {
797
+ if (field.isArray && !isNested) {
390
798
  result.push(`
391
799
  // Structural type check: ${rel} must be a JSON Array
392
- if (_bRes.${rel} !== undefined && _bRes.${rel} !== null && !Array.isArray(_bRes.${rel})) {
393
- errors.push("The property ${rel} must be a JSON Array, not an Object (at " + (_bRes.resourceType || "Resource") + ")");
800
+ {
801
+ const _stValue = at(resource, '${rel}');
802
+ if (_stValue !== undefined && _stValue !== null && !Array.isArray(_stValue)) {
803
+ const _stType = at(resource, 'resourceType');
804
+ errors.push("The property ${rel} must be a JSON Array, not an Object (at " + (typeof _stType === 'string' ? _stType : "Resource") + ")");
805
+ }
806
+ }`);
807
+ }
808
+ // ── Primitive JSON kind check ──────────────────────────────────────
809
+ // A numeric or boolean FHIR primitive must not arrive as a string. We checked
810
+ // that complex types are objects but never that a decimal holds a number, so
811
+ // HL7 reported "The value 'Example value' is not a valid decimal" and "the
812
+ // primitive value must be a number" where we said nothing.
813
+ //
814
+ // Both headings, as HL7 reports them: the value wording classifies as
815
+ // `unclassified` and the parse wording as `constraint`, so emitting only the
816
+ // first left `constraint` attributed to HL7 alone for a defect we had detected.
817
+ //
818
+ // Only the kinds JSON distinguishes are checked. Date, code and the rest all
819
+ // serialize as strings, and their formats already have dedicated checks
820
+ // elsewhere; asserting `typeof === 'string'` on them would add nothing.
821
+ const jsonKind = NUMERIC_PRIMITIVES.has(fieldType) ? 'number'
822
+ : fieldType === 'boolean' ? 'boolean'
823
+ : undefined;
824
+ if (jsonKind && !field.isArray) {
825
+ // Segments, not a dotted key: at() walks its varargs, so at(resource, 'a.b')
826
+ // would look for a property literally named "a.b" and always find nothing.
827
+ const accessorArgs = rel.split('.').map(seg => `'${seg}'`).join(', ');
828
+ result.push(`
829
+ // Structural type check: ${rel} must be a JSON ${jsonKind} (${fieldType})
830
+ {
831
+ const _stValue = at(resource, ${accessorArgs});
832
+ if (_stValue !== undefined && _stValue !== null && typeof _stValue !== '${jsonKind}') {
833
+ errors.push("The value '" + String(_stValue) + "' is not a valid ${fieldType}");
834
+ errors.push("Error parsing JSON: the primitive value must be a ${jsonKind}");
835
+ }
394
836
  }`);
395
837
  }
396
838
  // ── Complex type as Object check ───────────────────────────────────
397
- if (!isFhirPrimitive(fieldType) && !CONTAINER_TYPES.has(fieldType)) {
839
+ if (!isNested && !isFhirPrimitive(fieldType) && !CONTAINER_TYPES.has(fieldType)) {
398
840
  // Complex types must be objects when present — not primitive strings/numbers
399
841
  if (!field.isArray) {
400
842
  result.push(`
401
843
  // Structural type check: ${rel} must be an Object, not a Primitive
402
- if (_bRes.${rel} !== undefined && _bRes.${rel} !== null && typeof _bRes.${rel} !== 'object') {
403
- errors.push("The property ${rel} must be an Object, not a Primitive property (at " + (_bRes.resourceType || "Resource") + ".${rel})");
844
+ {
845
+ const _stValue = at(resource, '${rel}');
846
+ if (_stValue !== undefined && _stValue !== null && typeof _stValue !== 'object') {
847
+ const _stType = at(resource, 'resourceType');
848
+ errors.push("The property ${rel} must be an Object, not a Primitive property (at " + (typeof _stType === 'string' ? _stType : "Resource") + ".${rel})");
849
+ }
404
850
  }`);
405
851
  }
406
852
  }