babelfhir-ts 1.5.21 → 1.6.1
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/constants.js +2 -1
- package/out/src/generator/core/extensionSdReader.js +4 -1
- package/out/src/generator/core/utils.js +91 -9
- package/out/src/generator/emitters/class/classGenerator.js +14 -8
- package/out/src/generator/emitters/class/classGeneratorHelpers.js +22 -9
- package/out/src/generator/emitters/class/classHeuristics.js +4 -2
- package/out/src/generator/emitters/class/fieldPatternExtractor.js +17 -13
- package/out/src/generator/emitters/class/sliceElementDefaults.js +21 -6
- package/out/src/generator/emitters/class/sliceElementGenerator.js +3 -3
- package/out/src/generator/emitters/interface/backboneSliceTyping.js +21 -13
- package/out/src/generator/emitters/interface/importManager.js +1 -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/interfaceGenerator.js +14 -9
- package/out/src/generator/emitters/interface/postProcessExtensions.js +32 -16
- package/out/src/generator/emitters/interface/processFlatField.js +9 -7
- package/out/src/generator/emitters/interface/processNestedField.js +9 -10
- 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/prefab/prefabTypeMapper.js +2 -0
- 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/sliceDelegation.js +8 -6
- package/out/src/generator/emitters/validator/sliceExtensionValidation.js +3 -3
- package/out/src/generator/emitters/validator/sliceValidatorGenerator.js +41 -41
- package/out/src/generator/emitters/validator/sliceValidatorUtils.js +8 -3
- package/out/src/generator/emitters/validator/subExtensionScope.js +10 -2
- package/out/src/generator/emitters/validator/validatorBindingBuilder.js +4 -3
- package/out/src/generator/emitters/validator/validatorBindingLeafEmitters.js +5 -5
- package/out/src/generator/emitters/validator/validatorConstraintBuilders.js +8 -6
- 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 +43 -47
- package/out/src/generator/emitters/validator/validatorRuntime.js +13 -2
- package/out/src/generator/emitters/validator/validatorTemplates.js +4 -1
- package/out/src/generator/emitters/valueset/valueSetGenerator.js +6 -10
- package/out/src/generator/emitters/zod/zodRefinementBuilder.js +48 -8
- package/out/src/generator/emitters/zod/zodSchemaGenerator.js +51 -24
- package/out/src/generator/fhir/corePackageResolver.js +2 -3
- package/out/src/generator/fhir/corePackageResolver.ts +2 -3
- package/out/src/generator/generationHelpers.js +59 -7
- package/out/src/generator/index.js +94 -49
- package/out/src/generator/parser/packageManager.js +4 -4
- package/out/src/generator/parser/packageParser.js +116 -64
- package/out/src/generator/parser/sdFetcher.js +5 -3
- package/out/src/generator/parser/sdParser.js +1 -1
- package/out/src/generator/parser/sliceAggregator.js +13 -10
- package/out/src/generator/parser/sliceAggregatorComposition.js +11 -2
- package/out/src/generator/parser/vsParser.js +6 -3
- package/out/src/generator/sdProcessor.js +30 -21
- package/out/src/generator/sdProcessorRequiredFields.js +3 -2
- package/out/src/main.js +5 -0
- package/package.json +13 -10
- package/parity-matrix.json +6 -0
|
@@ -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
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stripSliceSuffix, stripVersionFromCanonicalUrl } from '../../core/utils.js';
|
|
1
2
|
import { logger } from '../../../logger.js';
|
|
2
3
|
import { sanitizeValueSetName } from '../valueset/valueSetGenerator.js';
|
|
3
4
|
import { generateSliceValidations } from './sliceValidatorGenerator.js';
|
|
@@ -8,7 +9,7 @@ import { buildNestedRequiredValidations, buildFixedValueValidations, buildProhib
|
|
|
8
9
|
import { buildBindingValidations } from './validatorBindingBuilder.js';
|
|
9
10
|
import { generateBundleRefValidation, generateContainedRefValidation, generateExtensionStructuralValidation, generateAggregationRefValidation, buildReferenceTargetTypeValidation, buildBundleEntryResolution } from './validatorTemplates.js';
|
|
10
11
|
import { HELPER_NAMES } from './validatorRuntime.js';
|
|
11
|
-
import {
|
|
12
|
+
import { isBaseElementInvariant, scopeConstraintExpression } from './validatorExpressions.js';
|
|
12
13
|
const log = logger.withTag('validator');
|
|
13
14
|
/**
|
|
14
15
|
* A constraint declared on a slice applies only to that slice's members.
|
|
@@ -52,6 +53,8 @@ function sliceScopedBindingPath(rel, field, fields) {
|
|
|
52
53
|
if (!sliceMatch)
|
|
53
54
|
return rel;
|
|
54
55
|
const sliceId = sliceMatch[1];
|
|
56
|
+
if (!sliceId)
|
|
57
|
+
return rel;
|
|
55
58
|
const pinned = fields.find(f => {
|
|
56
59
|
if (!f.elementId || !f.elementId.startsWith(sliceId + '.'))
|
|
57
60
|
return false;
|
|
@@ -63,7 +66,9 @@ function sliceScopedBindingPath(rel, field, fields) {
|
|
|
63
66
|
const discriminatorProp = pinned.elementId.substring(sliceId.length + 1);
|
|
64
67
|
if (!discriminatorProp || discriminatorProp.includes('.'))
|
|
65
68
|
return rel;
|
|
66
|
-
const coding = pinned.patternConstraint.coding
|
|
69
|
+
const [coding] = pinned.patternConstraint.coding;
|
|
70
|
+
if (!coding)
|
|
71
|
+
return rel;
|
|
67
72
|
const system = typeof coding.system === 'string' ? coding.system : undefined;
|
|
68
73
|
const code = typeof coding.code === 'string' ? coding.code : undefined;
|
|
69
74
|
if (!code)
|
|
@@ -71,9 +76,9 @@ function sliceScopedBindingPath(rel, field, fields) {
|
|
|
71
76
|
const terms = [system ? `system='${system}'` : '', `code='${code}'`].filter(Boolean).join(' and ');
|
|
72
77
|
const segments = rel.split('.');
|
|
73
78
|
// rel is <base>.<leaf>; the where() goes on the repeating base element.
|
|
74
|
-
|
|
79
|
+
const leaf = segments.length >= 2 ? segments.pop() : undefined;
|
|
80
|
+
if (!leaf)
|
|
75
81
|
return rel;
|
|
76
|
-
const leaf = segments.pop();
|
|
77
82
|
return `${segments.join('.')}.where(${discriminatorProp}.coding.where(${terms}).exists()).${leaf}`;
|
|
78
83
|
}
|
|
79
84
|
export function generateValidateProfileFunction(interfaceName, fields, valueSets, profileUrl, baseResourceType, profileUrlToName, extensionMetadata, baseDefinition, profileUrlToType, datatypeProfilePins) {
|
|
@@ -110,7 +115,7 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
|
|
|
110
115
|
const relPath = fullPath.replace(/^[^.]+\./, "");
|
|
111
116
|
arrayFieldPaths.add(relPath);
|
|
112
117
|
if (relPath.includes(':')) {
|
|
113
|
-
arrayFieldPaths.add(relPath
|
|
118
|
+
arrayFieldPaths.add(stripSliceSuffix(relPath));
|
|
114
119
|
}
|
|
115
120
|
}
|
|
116
121
|
});
|
|
@@ -126,7 +131,7 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
|
|
|
126
131
|
let isNestedInArray = false;
|
|
127
132
|
for (let j = 0; j < i; j++) {
|
|
128
133
|
const parentPath = parts.slice(0, j + 1).join('.');
|
|
129
|
-
const basePath = parentPath
|
|
134
|
+
const basePath = stripSliceSuffix(parentPath);
|
|
130
135
|
if (arrayFieldPaths.has(parentPath) || arrayFieldPaths.has(basePath)) {
|
|
131
136
|
isNestedInArray = true;
|
|
132
137
|
break;
|
|
@@ -169,7 +174,7 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
|
|
|
169
174
|
let vs = valueSets.get(bindingUri);
|
|
170
175
|
let resolvedUri = bindingUri;
|
|
171
176
|
if (!vs && bindingUri.includes('|')) {
|
|
172
|
-
resolvedUri = bindingUri
|
|
177
|
+
resolvedUri = stripVersionFromCanonicalUrl(bindingUri);
|
|
173
178
|
vs = valueSets.get(resolvedUri);
|
|
174
179
|
}
|
|
175
180
|
if (!vs)
|
|
@@ -225,7 +230,7 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
|
|
|
225
230
|
if (!fieldPath)
|
|
226
231
|
return;
|
|
227
232
|
fieldPath = sliceScopedBindingPath(fieldPath, f, fields);
|
|
228
|
-
const vsUri = (f.binding.uri || '')
|
|
233
|
+
const vsUri = stripVersionFromCanonicalUrl(f.binding.uri || '');
|
|
229
234
|
const vsDisplayName = vsUri.split('/').pop() || vsUri;
|
|
230
235
|
constraintsWithContext.push({
|
|
231
236
|
constraint: {
|
|
@@ -407,55 +412,40 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
|
|
|
407
412
|
const lastSegment = path.split('.').pop() || '';
|
|
408
413
|
return !!lastSegment && relativeFieldNames.has(`${path}.${lastSegment}`);
|
|
409
414
|
};
|
|
415
|
+
// Paths a structural type check can actually record, read back from the code
|
|
416
|
+
// just emitted rather than re-deriving which fields qualify — two copies of
|
|
417
|
+
// that predicate would drift, and a guard on a path nothing records is a dead
|
|
418
|
+
// conditional in every generated validator.
|
|
419
|
+
const structuralFailurePaths = new Set([...structuralTypeValidations.join('').matchAll(/_structuralFailures\.add\('([^']+)'\)/g)].map(m => m[1]));
|
|
420
|
+
const hasStructuralChecks = structuralFailurePaths.size > 0;
|
|
410
421
|
// ── Generate FHIRPath evaluation code ───────────────────────────────────
|
|
411
422
|
const validationLogic = filteredConstraints
|
|
412
423
|
.map((item, index) => {
|
|
413
424
|
const constraint = item.constraint;
|
|
414
425
|
const fieldPath = item.fieldPath;
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
.
|
|
426
|
+
// Shared with the Zod emitter so both evaluate the identical expression.
|
|
427
|
+
const escapedExpression = scopeConstraintExpression(constraint.expression || '', fieldPath, {
|
|
428
|
+
isRecursiveElement,
|
|
429
|
+
sliceFilter: item.sliceFilter,
|
|
430
|
+
});
|
|
419
431
|
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
432
|
const needsTerminology = /\bmemberOf\b/.test(constraint.expression || '');
|
|
454
433
|
let evaluateBlock = `
|
|
455
434
|
const result${index} = await fhirpath.evaluate(resource, "${escapedExpression}", { resource, rootResource: resource }, fhirpath_model, fhirpathOptions);
|
|
456
435
|
if (!result${index}.every(Boolean)) {
|
|
457
436
|
${constraint.severity === "error" ? "errors" : "warnings"}.push("Constraint violation: ${escapedHuman}");
|
|
458
437
|
}`;
|
|
438
|
+
// One defect, one diagnostic. An element that failed its structural type
|
|
439
|
+
// check cannot satisfy an invariant that inspects its children, so
|
|
440
|
+
// evaluating them here adds a second error for the same root cause — the
|
|
441
|
+
// ele-1 ("must have a @value or children") cascade behind
|
|
442
|
+
// ClinicalUseDefinition*, where HL7 reports only the type error. HL7 is
|
|
443
|
+
// the reference implementation and reports the root cause once.
|
|
444
|
+
if (fieldPath && structuralFailurePaths.has(fieldPath)) {
|
|
445
|
+
evaluateBlock = `
|
|
446
|
+
if (!_structuralFailures.has('${fieldPath}')) {${evaluateBlock}
|
|
447
|
+
}`;
|
|
448
|
+
}
|
|
459
449
|
// Gate memberOf() behind terminologyUrl. For resolve(), use try/catch
|
|
460
450
|
// instead of a hard gate: FHIRPath `and` short-circuits, so expressions
|
|
461
451
|
// like `(A and resolve().is(X)) or (B and focus.exists().not())` can still
|
|
@@ -545,6 +535,12 @@ ${body}
|
|
|
545
535
|
fhirpathOptionsLines.push(' async: true as const,', ' ...(options?.terminologyUrl ? { terminologyUrl: options.terminologyUrl } : {}),', ' ...(options?.fhirServerUrl ? { fhirServerUrl: options.fhirServerUrl } : {}),');
|
|
546
536
|
}
|
|
547
537
|
const fhirpathOptionsBlock = `const fhirpathOptions = {\n${fhirpathOptionsLines.join('\n')}\n };`;
|
|
538
|
+
// Paths whose value is the wrong JSON kind. Populated by the structural type
|
|
539
|
+
// checks, which the assembled body now runs before any invariant so the guard
|
|
540
|
+
// has something to read.
|
|
541
|
+
const structuralFailureDecl = hasStructuralChecks
|
|
542
|
+
? 'const _structuralFailures = new Set<string>();\n '
|
|
543
|
+
: '';
|
|
548
544
|
let valueSetImportStatements = '';
|
|
549
545
|
if (valueSetImports.size > 0) {
|
|
550
546
|
valueSetImportStatements = Array.from(valueSetImports.entries())
|
|
@@ -562,8 +558,8 @@ ${body}
|
|
|
562
558
|
code: `${fhirpathImport}${optionsImport}${valueSetImportStatements}${validatorImportStatements}export async function validate${interfaceName}(resource: ${interfaceName}, options?: ValidatorOptions): Promise<{ errors: string[]; warnings: string[] }> {
|
|
563
559
|
const errors: string[] = [];
|
|
564
560
|
const warnings: string[] = [];
|
|
565
|
-
${fhirpathOptionsBlock}
|
|
566
|
-
${bundleEntryResolution}${referenceTargetTypeValidation}${
|
|
561
|
+
${structuralFailureDecl}${fhirpathOptionsBlock}
|
|
562
|
+
${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
563
|
return { errors, warnings };
|
|
568
564
|
}`,
|
|
569
565
|
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. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stripVersionFromCanonicalUrl } from '../../core/utils.js';
|
|
1
2
|
/**
|
|
2
3
|
* Emit the reference target-type check for one profile.
|
|
3
4
|
*
|
|
@@ -13,7 +14,7 @@
|
|
|
13
14
|
*/
|
|
14
15
|
export function buildReferenceTargetTypeValidation(fields, profileUrlToType) {
|
|
15
16
|
const resolveTargetType = (url) => {
|
|
16
|
-
const bare = url
|
|
17
|
+
const bare = stripVersionFromCanonicalUrl(url);
|
|
17
18
|
const lastSegment = bare.split('/').pop() || '';
|
|
18
19
|
if (/^[A-Z][A-Za-z]+$/.test(lastSegment))
|
|
19
20
|
return lastSegment;
|
|
@@ -328,6 +329,8 @@ ${indent} errors.push('${message}' + ${refVar});
|
|
|
328
329
|
${indent}}`;
|
|
329
330
|
}
|
|
330
331
|
const segment = segments[index];
|
|
332
|
+
if (segment === undefined)
|
|
333
|
+
return '';
|
|
331
334
|
if (isArray[index]) {
|
|
332
335
|
const itemVar = `_aggItem${id}${index}`;
|
|
333
336
|
return `
|
|
@@ -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
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* and required ValueSet bindings.
|
|
4
4
|
*/
|
|
5
5
|
import { sanitizeValueSetName } from '../valueset/valueSetGenerator.js';
|
|
6
|
+
import { scopeConstraintExpression } from '../validator/validatorExpressions.js';
|
|
7
|
+
import { stripSliceSuffix, stripVersionFromCanonicalUrl } from '../../core/utils.js';
|
|
6
8
|
/** Escape double-quote characters in refinement messages */
|
|
7
9
|
function escStr(s) {
|
|
8
10
|
return s.replace(/"/g, '\\"');
|
|
@@ -11,15 +13,19 @@ function escStr(s) {
|
|
|
11
13
|
* Build refinement chains for pattern constraints, required slice cardinalities,
|
|
12
14
|
* and FHIRPath invariants. Returns refinement code strings plus tracked constraint metadata.
|
|
13
15
|
*/
|
|
14
|
-
export function buildRefinements(allFields, rootPrefix, valueSets
|
|
16
|
+
export function buildRefinements(allFields, rootPrefix, valueSets,
|
|
17
|
+
/** See generateZodSchema's `emitInvariants`: opt-in, for the per-parse cost. */
|
|
18
|
+
emitInvariants = false) {
|
|
15
19
|
const refinements = [];
|
|
20
|
+
const invariantEvaluators = [];
|
|
16
21
|
const bindingVsImports = new Map();
|
|
17
22
|
// Build set of direct (root-level) field names for safety checks
|
|
18
23
|
const directFieldNames = new Set();
|
|
19
24
|
for (const field of allFields) {
|
|
20
25
|
const parts = field.name.split('.');
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
const [root, direct] = parts;
|
|
27
|
+
if (parts.length === 2 && root === rootPrefix && direct) {
|
|
28
|
+
directFieldNames.add(stripSliceSuffix(direct));
|
|
23
29
|
}
|
|
24
30
|
}
|
|
25
31
|
// 1. Pattern constraints (patternCodeableConcept, patternCoding)
|
|
@@ -31,7 +37,7 @@ export function buildRefinements(allFields, rootPrefix, valueSets) {
|
|
|
31
37
|
continue;
|
|
32
38
|
if (rel.includes('.') || rel.endsWith('.coding'))
|
|
33
39
|
continue;
|
|
34
|
-
if (!directFieldNames.has(rel
|
|
40
|
+
if (!directFieldNames.has(stripSliceSuffix(rel)))
|
|
35
41
|
continue;
|
|
36
42
|
const pattern = field.patternConstraint;
|
|
37
43
|
// patternCodeableConcept
|
|
@@ -84,7 +90,7 @@ export function buildRefinements(allFields, rootPrefix, valueSets) {
|
|
|
84
90
|
continue;
|
|
85
91
|
if (relPath.includes('[x]'))
|
|
86
92
|
continue;
|
|
87
|
-
if (!directFieldNames.has(relPath
|
|
93
|
+
if (!directFieldNames.has(stripSliceSuffix(relPath)))
|
|
88
94
|
continue;
|
|
89
95
|
for (const slice of slices) {
|
|
90
96
|
const min = typeof slice.min === 'number' ? slice.min : (slice.isOptional ? 0 : 1);
|
|
@@ -163,10 +169,42 @@ export function buildRefinements(allFields, rootPrefix, valueSets) {
|
|
|
163
169
|
unsupportedConstraints.push(meta);
|
|
164
170
|
}
|
|
165
171
|
else {
|
|
166
|
-
// Supported but not yet emitted — future FHIRPath→JS translation point
|
|
167
172
|
supportedConstraints.push(meta);
|
|
168
173
|
}
|
|
169
174
|
}
|
|
175
|
+
// Emit the supported invariants.
|
|
176
|
+
//
|
|
177
|
+
// These were tracked and never emitted, on the premise that FHIRPath
|
|
178
|
+
// evaluation is async while Zod refinements are synchronous. Neither half
|
|
179
|
+
// holds: fhirpath.js returns an array and only returns a Promise when async
|
|
180
|
+
// mode is opted into, which it needs solely for memberOf()/resolve() — and
|
|
181
|
+
// those are already in `unsupportedConstraints` above. So every constraint
|
|
182
|
+
// here evaluates synchronously inside a plain refinement, and `safeParse`
|
|
183
|
+
// keeps working for consumers.
|
|
184
|
+
//
|
|
185
|
+
// The expression goes through the same scoping the validator uses, and the
|
|
186
|
+
// message repeats its wording, so the two agree per profile rather than
|
|
187
|
+
// reporting the same defect under different text.
|
|
188
|
+
supportedConstraints.forEach((meta, i) => {
|
|
189
|
+
if (!emitInvariants)
|
|
190
|
+
return;
|
|
191
|
+
const scoped = scopeConstraintExpression(meta.expression, meta.fieldPath);
|
|
192
|
+
if (!scoped)
|
|
193
|
+
return;
|
|
194
|
+
const human = escStr(meta.human).replace(/[\r\n]+/g, ' ');
|
|
195
|
+
const fn = `_inv${i}`;
|
|
196
|
+
// Compiled once at module load, not per parse. `evaluate` re-parses the
|
|
197
|
+
// expression on every call, which cost ~0.3ms each: 69 invariants made a
|
|
198
|
+
// C4BBOrganization parse 242x slower on an empty resource and 964x on a
|
|
199
|
+
// populated one. `compile` parses once and returns a reusable evaluator.
|
|
200
|
+
//
|
|
201
|
+
// traceFn is silenced deliberately: fhirpath's trace() defaults to writing
|
|
202
|
+
// to the console, so a schema parse would print "TRACE:[…]" out of a
|
|
203
|
+
// published package.
|
|
204
|
+
invariantEvaluators.push(`const ${fn} = fhirpath.compile("${scoped}", fhirpath_model, { preciseMath: true, traceFn: () => {} });`);
|
|
205
|
+
refinements.push(`.refine(d => { try { const _r = ${fn}(d, { resource: d, rootResource: d }); return Array.isArray(_r) ? _r.every(Boolean) : true; } catch { return true; } },`
|
|
206
|
+
+ ` { message: "Constraint violation: ${human}", when: () => true })`);
|
|
207
|
+
});
|
|
170
208
|
// 4. Meta sub-field constraints
|
|
171
209
|
// When a profile constrains meta.lastUpdated, meta.profile, etc. with min>=1,
|
|
172
210
|
// emit refinements that check for their presence. Uses `when: () => true` so
|
|
@@ -201,6 +239,8 @@ export function buildRefinements(allFields, rootPrefix, valueSets) {
|
|
|
201
239
|
if (field.binding?.strength !== 'required')
|
|
202
240
|
continue;
|
|
203
241
|
const rel = parts[1];
|
|
242
|
+
if (!rel)
|
|
243
|
+
continue;
|
|
204
244
|
// Skip choice-type placeholders (code[x], value[x], etc.) — the binding applies
|
|
205
245
|
// to the resolved variant (e.g. codeCodeableConcept) which we cannot reliably
|
|
206
246
|
// identify here. Emitting d.code[x] would produce invalid TypeScript.
|
|
@@ -216,7 +256,7 @@ export function buildRefinements(allFields, rootPrefix, valueSets) {
|
|
|
216
256
|
vsDisplayName = field.binding.uri?.split('/').pop() || 'ValueSet';
|
|
217
257
|
}
|
|
218
258
|
else if (field.binding.uri && valueSets) {
|
|
219
|
-
const vsUri = field.binding.uri
|
|
259
|
+
const vsUri = stripVersionFromCanonicalUrl(field.binding.uri);
|
|
220
260
|
const vs = valueSets.get(vsUri) || valueSets.get(field.binding.uri);
|
|
221
261
|
if (vs && vs.concepts.length > 0 && vs.concepts.length <= 200) {
|
|
222
262
|
codes = vs.concepts.map(c => c.code);
|
|
@@ -253,5 +293,5 @@ export function buildRefinements(allFields, rootPrefix, valueSets) {
|
|
|
253
293
|
}
|
|
254
294
|
}
|
|
255
295
|
}
|
|
256
|
-
return { refinements, supportedConstraints, unsupportedConstraints, valueSetImports: bindingVsImports };
|
|
296
|
+
return { refinements, supportedConstraints, unsupportedConstraints, valueSetImports: bindingVsImports, invariantEvaluators };
|
|
257
297
|
}
|