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.
- package/README.md +6 -3
- package/out/src/generator/core/extensionSdReader.js +2 -1
- package/out/src/generator/core/utils.js +69 -0
- package/out/src/generator/emitters/class/classGeneratorHelpers.js +10 -2
- package/out/src/generator/emitters/class/sliceElementDefaults.js +10 -1
- package/out/src/generator/emitters/interface/interfaceFieldProcessor.js +15 -12
- package/out/src/generator/emitters/interface/interfaceFieldUtils.js +6 -3
- package/out/src/generator/emitters/interface/postProcessExtensions.js +8 -2
- package/out/src/generator/emitters/interface/processNestedField.js +4 -6
- package/out/src/generator/emitters/namingsystem/namingSystemGenerator.js +136 -0
- package/out/src/generator/emitters/prefab/prefabEmitter.js +2 -15
- package/out/src/generator/emitters/prefab/prefabRenderer.js +2 -19
- package/out/src/generator/emitters/validator/closedSlicingValidation.js +5 -2
- package/out/src/generator/emitters/validator/fhirpathStubInstaller.js +7 -1
- package/out/src/generator/emitters/validator/sliceBackboneValidation.js +27 -12
- package/out/src/generator/emitters/validator/sliceValidatorGenerator.js +15 -27
- package/out/src/generator/emitters/validator/sliceValidatorUtils.js +5 -1
- package/out/src/generator/emitters/validator/subExtensionScope.js +10 -2
- package/out/src/generator/emitters/validator/validatorExpressions.js +44 -0
- package/out/src/generator/emitters/validator/validatorFieldBuilders.js +34 -17
- package/out/src/generator/emitters/validator/validatorGenerator.js +31 -40
- package/out/src/generator/emitters/validator/validatorRuntime.js +13 -2
- package/out/src/generator/emitters/valueset/valueSetGenerator.js +6 -10
- package/out/src/generator/emitters/zod/zodRefinementBuilder.js +39 -3
- package/out/src/generator/emitters/zod/zodSchemaGenerator.js +36 -8
- package/out/src/generator/generationHelpers.js +59 -7
- package/out/src/generator/index.js +90 -47
- package/out/src/generator/parser/packageParser.js +76 -0
- package/out/src/generator/parser/vsParser.js +4 -2
- package/out/src/generator/sdProcessor.js +30 -21
- package/out/src/main.js +5 -0
- package/package.json +12 -9
- 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 =
|
|
61
|
+
const parentProp = parentSegment.replace(/\[x\]$/, '');
|
|
56
62
|
if (parentProp === discriminatorProp)
|
|
57
63
|
continue;
|
|
58
|
-
const isChoiceType =
|
|
59
|
-
const prop =
|
|
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}.${
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
241
|
+
const [onlyType] = uniqueTypes;
|
|
242
|
+
if (uniqueTypes.length !== 1 || !onlyType)
|
|
240
243
|
return;
|
|
241
|
-
const choiceSuffix =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
288
|
-
levels.push({ name
|
|
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
|
|
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
|
|
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 (!
|
|
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
|
|
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 {
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
.
|
|
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}${
|
|
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
|
-
/**
|
|
30
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|