babelfhir-ts 1.6.10 → 1.6.12
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/out/src/generator/core/canonicalUrl.js +24 -0
- package/out/src/generator/core/constants.js +1 -1
- package/out/src/generator/core/utils.js +5 -12
- package/out/src/generator/emitters/class/enrichResourceHandlers.js +11 -6
- package/out/src/generator/emitters/class/sliceElementGenerator.js +8 -2
- package/out/src/generator/emitters/prefab/prefabRuntimeAssets.js +57 -16
- package/out/src/generator/emitters/validator/extensionInvariants.js +45 -0
- package/out/src/generator/emitters/validator/sliceExtensionValidation.js +4 -2
- package/out/src/generator/emitters/validator/validatorGenerator.js +6 -1
- package/out/src/generator/emitters/zod/zodSchemaGenerator.js +11 -6
- package/out/src/generator/fhir/createRules.js +0 -3
- package/out/src/generator/fhir/createRules.ts +0 -4
- package/out/src/generator/sdProcessor.js +53 -1
- package/package.json +13 -13
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical-URL string handling, with no dependencies of its own.
|
|
3
|
+
*
|
|
4
|
+
* A leaf on purpose. This lived in `utils.ts`, which imports the FHIR version
|
|
5
|
+
* context, so `constants.ts` calling it pulled the whole generator graph in
|
|
6
|
+
* behind a config module: constants → utils → versionContext → types →
|
|
7
|
+
* createRules / fetchUtils → constants. Node evaluates that cycle in an order
|
|
8
|
+
* that leaves `TS` and `USER_AGENT` uninitialized whenever `constants.ts` is the
|
|
9
|
+
* entry point, which is exactly what a script importing it directly does — so
|
|
10
|
+
* `scripts/check-ig-versions.ts` failed on import with `Cannot access 'TS'
|
|
11
|
+
* before initialization` while the CLI, entered through `main.ts`, was fine.
|
|
12
|
+
*
|
|
13
|
+
* Keep this module free of imports so both layers can depend on it.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* A canonical URL with any version suffix removed:
|
|
17
|
+
* `http://…/ValueSet/foo|4.0.1` -> `http://…/ValueSet/foo`.
|
|
18
|
+
*/
|
|
19
|
+
export function stripVersionFromCanonicalUrl(url) {
|
|
20
|
+
if (!url)
|
|
21
|
+
return url;
|
|
22
|
+
const pipeIndex = url.indexOf('|');
|
|
23
|
+
return pipeIndex >= 0 ? url.substring(0, pipeIndex) : url;
|
|
24
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* maintenance trivial.
|
|
7
7
|
*/
|
|
8
8
|
import { createRequire } from 'module';
|
|
9
|
-
import { stripVersionFromCanonicalUrl } from './
|
|
9
|
+
import { stripVersionFromCanonicalUrl } from './canonicalUrl.js';
|
|
10
10
|
import { fileURLToPath } from 'url';
|
|
11
11
|
import path from 'path';
|
|
12
12
|
const require = createRequire(import.meta.url);
|
|
@@ -2,18 +2,11 @@ import fs from "fs";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { logger } from "../../logger.js";
|
|
4
4
|
import { ctx } from '../fhir/versionContext.js';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
*/
|
|
11
|
-
export function stripVersionFromCanonicalUrl(url) {
|
|
12
|
-
if (!url)
|
|
13
|
-
return url;
|
|
14
|
-
const pipeIndex = url.indexOf('|');
|
|
15
|
-
return pipeIndex >= 0 ? url.substring(0, pipeIndex) : url;
|
|
16
|
-
}
|
|
5
|
+
// Defined in a leaf module so `constants.ts` can call it without importing this
|
|
6
|
+
// one — see canonicalUrl.ts. Re-exported here because 20+ call sites import it
|
|
7
|
+
// from utils, and used below in this file too.
|
|
8
|
+
import { stripVersionFromCanonicalUrl } from "./canonicalUrl.js";
|
|
9
|
+
export { stripVersionFromCanonicalUrl };
|
|
17
10
|
/**
|
|
18
11
|
* An element path with any slice suffix removed: `identifier:NPI` -> `identifier`.
|
|
19
12
|
*
|
|
@@ -317,11 +317,16 @@ export function generateEnrichResourceEnd() {
|
|
|
317
317
|
}
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
// No display on the RxNorm fallback. 'Amoxicillin 250mg' was paired with
|
|
321
|
+
// 1049502, which RxNorm defines as an oxycodone product, and the HL7 validator
|
|
322
|
+
// answered "Wrong Display Name ... Valid display is one of 5 choices". A Coding
|
|
323
|
+
// without a display is conformant, and an unverifiable display is a liability:
|
|
324
|
+
// when the binding does resolve, the display comes from the expansion instead.
|
|
320
325
|
// Medication — isik-med-1: code.exists() or ingredient.exists()
|
|
321
326
|
if (/^Medication$/i.test(baseResource)) {
|
|
322
327
|
if (!('code' in base) && !('ingredient' in base)) {
|
|
323
|
-
const resolved = resolveBindingCode('code', '${TS.RXNORM}', '1049502'
|
|
324
|
-
base.code = resolved || { coding: [{ system: '${TS.RXNORM}', code: '1049502'
|
|
328
|
+
const resolved = resolveBindingCode('code', '${TS.RXNORM}', '1049502');
|
|
329
|
+
base.code = resolved || { coding: [{ system: '${TS.RXNORM}', code: '1049502' }] };
|
|
325
330
|
}
|
|
326
331
|
}
|
|
327
332
|
|
|
@@ -330,8 +335,8 @@ export function generateEnrichResourceEnd() {
|
|
|
330
335
|
if (!('status' in base)) base.status = 'active';
|
|
331
336
|
if (!('intent' in base)) base.intent = 'order';
|
|
332
337
|
if (!('medicationCodeableConcept' in base) && !('medicationReference' in base)) {
|
|
333
|
-
const resolved = resolveBindingCode('medicationCodeableConcept', '${TS.RXNORM}', '1049502'
|
|
334
|
-
base.medicationCodeableConcept = resolved || { coding: [{ system: '${TS.RXNORM}', code: '1049502'
|
|
338
|
+
const resolved = resolveBindingCode('medicationCodeableConcept', '${TS.RXNORM}', '1049502');
|
|
339
|
+
base.medicationCodeableConcept = resolved || { coding: [{ system: '${TS.RXNORM}', code: '1049502' }] };
|
|
335
340
|
}
|
|
336
341
|
if (!('subject' in base)) base.subject = { reference: 'Patient/' + randomId() };
|
|
337
342
|
// requester required when intent='order' (us-core-21 constraint)
|
|
@@ -342,8 +347,8 @@ export function generateEnrichResourceEnd() {
|
|
|
342
347
|
if (/MedicationDispense/i.test(baseResource)) {
|
|
343
348
|
if (!('status' in base)) base.status = 'completed';
|
|
344
349
|
if (!('medicationCodeableConcept' in base) && !('medicationReference' in base)) {
|
|
345
|
-
const resolved = resolveBindingCode('medicationCodeableConcept', '${TS.RXNORM}', '1049502'
|
|
346
|
-
base.medicationCodeableConcept = resolved || { coding: [{ system: '${TS.RXNORM}', code: '1049502'
|
|
350
|
+
const resolved = resolveBindingCode('medicationCodeableConcept', '${TS.RXNORM}', '1049502');
|
|
351
|
+
base.medicationCodeableConcept = resolved || { coding: [{ system: '${TS.RXNORM}', code: '1049502' }] };
|
|
347
352
|
}
|
|
348
353
|
if (!('subject' in base)) base.subject = { reference: 'Patient/' + randomId() };
|
|
349
354
|
if (!('performer' in base)) base.performer = [{ actor: { reference: 'Practitioner/' + randomId() } }];
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Extracted helpers live in sliceElementDefaults.ts.
|
|
5
5
|
*/
|
|
6
6
|
import { logger } from '../../../logger.js';
|
|
7
|
+
import { ctx } from '../../fhir/versionContext.js';
|
|
7
8
|
import { capitalise, deriveIdentifierConfig } from './classHeuristics.js';
|
|
8
9
|
import { TS, EXAMPLE_ID_SYSTEM, RESOURCE_SKELETONS, compositionSkeleton, bundleEntry, matchSkeletonBySliceName, } from '../../core/constants.js';
|
|
9
10
|
import { getDefaultValueForType, getTypePlaceholder, objectToCodeExpression, } from './sliceElementDefaults.js';
|
|
@@ -260,9 +261,14 @@ export function generateSliceElement(slice, elementType) {
|
|
|
260
261
|
}
|
|
261
262
|
const STRUCTURAL_BACKBONE_FIELDS = new Set(['extension', 'modifierExtension', 'part', 'contains']);
|
|
262
263
|
const rawType = child.type;
|
|
263
|
-
|
|
264
|
+
// A child whose own type the differential does not restate still has one in
|
|
265
|
+
// the base datatype. Falling back to 'string' gave Identifier.system, a uri,
|
|
266
|
+
// the literal "placeholder", and the HL7 validator rightly answered
|
|
267
|
+
// "Identifier.system must be an absolute reference, not a local reference".
|
|
268
|
+
const declaredType = rawType || ctx().dataTypes[elementType]?.[childPath]?.type;
|
|
269
|
+
const effectiveType = (!declaredType || declaredType === 'any') && STRUCTURAL_BACKBONE_FIELDS.has(childPath)
|
|
264
270
|
? 'BackboneElement'
|
|
265
|
-
: (
|
|
271
|
+
: (declaredType || 'string');
|
|
266
272
|
const placeholder = getTypePlaceholder(effectiveType);
|
|
267
273
|
if (placeholder !== undefined) {
|
|
268
274
|
if (!child.isRequired && (placeholder === '{ }' || placeholder === '{}')) {
|
|
@@ -20,7 +20,7 @@ export interface CodeableConceptLike { text?: string; coding?: CodingLike[] }
|
|
|
20
20
|
export interface ReferenceLike { reference?: string; display?: string; type?: string; identifier?: IdentifierLike }
|
|
21
21
|
export interface QuantityLike { value?: number; unit?: string; code?: string; comparator?: string }
|
|
22
22
|
export interface PeriodLike { start?: string; end?: string }
|
|
23
|
-
export interface IdentifierLike { use?: string; system?: string; value?: string }
|
|
23
|
+
export interface IdentifierLike { use?: string; system?: string; value?: string; type?: CodeableConceptLike }
|
|
24
24
|
export interface AddressLike { line?: string[]; city?: string; state?: string; postalCode?: string; country?: string; text?: string; use?: string }
|
|
25
25
|
export interface ContactPointLike { system?: string; value?: string; use?: string }
|
|
26
26
|
export interface AttachmentLike { contentType?: string; url?: string; data?: string; title?: string }
|
|
@@ -30,8 +30,14 @@ export interface RangeLike { low?: QuantityLike; high?: QuantityLike }
|
|
|
30
30
|
export function formatHumanName(n?: NameLike): string | undefined {
|
|
31
31
|
if (!n) return undefined;
|
|
32
32
|
if (n.text) return n.text;
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// prefix and suffix are part of the name, not decoration: dropping them
|
|
34
|
+
// renders "Jane Doe" for a record that says "Dr. Jane Doe MD".
|
|
35
|
+
const full = [
|
|
36
|
+
(n.prefix ?? []).join(' '),
|
|
37
|
+
(n.given ?? []).join(' '),
|
|
38
|
+
n.family,
|
|
39
|
+
(n.suffix ?? []).join(' '),
|
|
40
|
+
].filter(Boolean).join(' ').trim();
|
|
35
41
|
return full || undefined;
|
|
36
42
|
}
|
|
37
43
|
|
|
@@ -83,12 +89,17 @@ export function formatQuantity(q?: QuantityLike): string | undefined {
|
|
|
83
89
|
export function formatPeriod(p?: PeriodLike): string | undefined {
|
|
84
90
|
if (!p) return undefined;
|
|
85
91
|
if (!p.start && !p.end) return undefined;
|
|
86
|
-
|
|
92
|
+
// An absent end means the period has not closed, which is not the same as
|
|
93
|
+
// an end nobody recorded.
|
|
94
|
+
return \`\${p.start ?? '?'} – \${p.end ?? 'ongoing'}\`;
|
|
87
95
|
}
|
|
88
96
|
|
|
89
97
|
export function formatIdentifier(i?: IdentifierLike): string | undefined {
|
|
90
|
-
if (!i) return undefined;
|
|
91
|
-
|
|
98
|
+
if (!i?.value) return undefined;
|
|
99
|
+
// A bare "12345" says nothing next to a second identifier; the type is what
|
|
100
|
+
// tells the reader which number they are looking at.
|
|
101
|
+
const label = formatCodeableConcept(i.type);
|
|
102
|
+
return label ? \`\${label}: \${i.value}\` : i.value;
|
|
92
103
|
}
|
|
93
104
|
|
|
94
105
|
export function formatAddress(a?: AddressLike): string | undefined {
|
|
@@ -237,10 +248,11 @@ import { registerPipe, type PipeFn } from '@maxhealth.tech/prefab';
|
|
|
237
248
|
const humanName: PipeFn = (value) => {
|
|
238
249
|
const format = (n: unknown): string => {
|
|
239
250
|
if (!n || typeof n !== 'object') return '';
|
|
240
|
-
const name = n as { text?: string; given?: string[]; family?: string };
|
|
251
|
+
const name = n as { text?: string; prefix?: string[]; given?: string[]; family?: string; suffix?: string[] };
|
|
241
252
|
if (name.text) return name.text;
|
|
242
|
-
const
|
|
243
|
-
return [given, name.family
|
|
253
|
+
const joined = (v: unknown): string => (Array.isArray(v) ? v.join(' ') : '');
|
|
254
|
+
return [joined(name.prefix), joined(name.given), name.family, joined(name.suffix)]
|
|
255
|
+
.filter(Boolean).join(' ').trim();
|
|
244
256
|
};
|
|
245
257
|
if (Array.isArray(value)) {
|
|
246
258
|
return value.map(format).filter(Boolean).join(', ');
|
|
@@ -273,13 +285,25 @@ const address: PipeFn = (value) => {
|
|
|
273
285
|
return format(value);
|
|
274
286
|
};
|
|
275
287
|
|
|
276
|
-
/**
|
|
288
|
+
/**
|
|
289
|
+
* Format a ContactPoint (telecom).
|
|
290
|
+
*
|
|
291
|
+
* One contact renders bare, because whatever labels it already says which
|
|
292
|
+
* system it is. A list mixes systems, so each entry names its own — without it
|
|
293
|
+
* a phone number and a fax number are two indistinguishable strings.
|
|
294
|
+
*/
|
|
277
295
|
const contactPoint: PipeFn = (value) => {
|
|
278
296
|
const format = (cp: unknown): string => {
|
|
279
297
|
if (!cp || typeof cp !== 'object') return '';
|
|
280
298
|
return String((cp as { value?: unknown }).value ?? '');
|
|
281
299
|
};
|
|
282
|
-
|
|
300
|
+
const labelled = (cp: unknown): string => {
|
|
301
|
+
const val = format(cp);
|
|
302
|
+
if (!val) return '';
|
|
303
|
+
const sys = (cp as { system?: unknown }).system;
|
|
304
|
+
return typeof sys === 'string' && sys ? sys + ': ' + val : val;
|
|
305
|
+
};
|
|
306
|
+
if (Array.isArray(value)) return value.map(labelled).filter(Boolean).join(', ');
|
|
283
307
|
return format(value);
|
|
284
308
|
};
|
|
285
309
|
|
|
@@ -294,7 +318,12 @@ const contactPointBySystem: PipeFn = (value, system) => {
|
|
|
294
318
|
.join(', ');
|
|
295
319
|
};
|
|
296
320
|
|
|
297
|
-
/**
|
|
321
|
+
/**
|
|
322
|
+
* Format ContactPoint[] entries whose \`system\` is neither 'phone' nor 'email'.
|
|
323
|
+
*
|
|
324
|
+
* This is the leftover bucket — fax, pager, url, sms — so each entry names its
|
|
325
|
+
* system, exactly as a mixed list does above.
|
|
326
|
+
*/
|
|
298
327
|
const contactPointOther: PipeFn = (value) => {
|
|
299
328
|
if (!Array.isArray(value)) return '';
|
|
300
329
|
return value
|
|
@@ -302,16 +331,28 @@ const contactPointOther: PipeFn = (value) => {
|
|
|
302
331
|
const s = cp && (cp as { system?: unknown }).system;
|
|
303
332
|
return typeof s === 'string' && s !== 'phone' && s !== 'email';
|
|
304
333
|
})
|
|
305
|
-
.map((cp) =>
|
|
334
|
+
.map((cp) => {
|
|
335
|
+
const val = String((cp as { value?: unknown }).value ?? '');
|
|
336
|
+
return val ? String((cp as { system?: unknown }).system) + ': ' + val : '';
|
|
337
|
+
})
|
|
306
338
|
.filter(Boolean)
|
|
307
339
|
.join(', ');
|
|
308
340
|
};
|
|
309
341
|
|
|
310
|
-
/** Format an Identifier
|
|
342
|
+
/** Format an Identifier as \`type: value\`, or the bare value when untyped. */
|
|
311
343
|
const identifier: PipeFn = (value) => {
|
|
312
344
|
const format = (i: unknown): string => {
|
|
313
345
|
if (!i || typeof i !== 'object') return '';
|
|
314
|
-
|
|
346
|
+
const ident = i as {
|
|
347
|
+
value?: unknown;
|
|
348
|
+
type?: { text?: string; coding?: Array<{ display?: string; code?: string }> };
|
|
349
|
+
};
|
|
350
|
+
const val = String(ident.value ?? '');
|
|
351
|
+
if (!val) return '';
|
|
352
|
+
const type = ident.type;
|
|
353
|
+
const first = type && Array.isArray(type.coding) ? type.coding[0] : undefined;
|
|
354
|
+
const label = (type && type.text) || (first && (first.display ?? first.code)) || '';
|
|
355
|
+
return label ? label + ': ' + val : val;
|
|
315
356
|
};
|
|
316
357
|
if (Array.isArray(value)) return value.map(format).filter(Boolean).join(', ');
|
|
317
358
|
return format(value);
|
|
@@ -417,7 +458,7 @@ const period: PipeFn = (value) => {
|
|
|
417
458
|
if (!p || typeof p !== 'object') return '';
|
|
418
459
|
const per = p as { start?: string; end?: string };
|
|
419
460
|
if (!per.start && !per.end) return '';
|
|
420
|
-
return \`\${per.start ?? '?'} – \${per.end ?? '
|
|
461
|
+
return \`\${per.start ?? '?'} – \${per.end ?? 'ongoing'}\`;
|
|
421
462
|
};
|
|
422
463
|
if (Array.isArray(value)) return value.map(format).filter(Boolean).join(', ');
|
|
423
464
|
return format(value);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invariants declared on an Extension's own StructureDefinition, evaluated against
|
|
3
|
+
* each matched extension in the resource that carries it.
|
|
4
|
+
*
|
|
5
|
+
* The extension profile's own validator already emits these, but that one runs only
|
|
6
|
+
* when an Extension is validated standalone. A resource embedding the extension
|
|
7
|
+
* checked that the slice was present and that its value[x] was permitted, and never
|
|
8
|
+
* evaluated the extension's constraints. KBV_EX_ERP_Multiple_Prescription is the
|
|
9
|
+
* clear case: "if the flag is true a period must be given" is an invariant on the
|
|
10
|
+
* extension SD, and the HL7 validator reports it on the containing MedicationRequest
|
|
11
|
+
* where we reported nothing.
|
|
12
|
+
*
|
|
13
|
+
* Split out of sliceExtensionValidation.ts to keep that file under the 650 LOC gate.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Emit the invariant checks for one matched-extension list.
|
|
17
|
+
*
|
|
18
|
+
* Returns '' when the extension declares nothing worth checking, so callers can
|
|
19
|
+
* concatenate unconditionally.
|
|
20
|
+
*/
|
|
21
|
+
export function generateExtensionInvariantChecks(matchedArrayVar, extensionUrl, extMeta, ctx, indent) {
|
|
22
|
+
const constraints = extMeta?.constraints;
|
|
23
|
+
if (!constraints || constraints.length === 0)
|
|
24
|
+
return '';
|
|
25
|
+
// The fhirpath import is gated on the profile's own constraint list, so a profile
|
|
26
|
+
// that declares none would reference an undeclared `fhirpath` binding here.
|
|
27
|
+
if (ctx?.fhirpathNeeded)
|
|
28
|
+
ctx.fhirpathNeeded.value = true;
|
|
29
|
+
const lines = [];
|
|
30
|
+
lines.push(`${indent}// Invariants declared on extension ${extensionUrl}`);
|
|
31
|
+
lines.push(`${indent}for (const _invExt of ${matchedArrayVar}) {`);
|
|
32
|
+
for (const c of constraints) {
|
|
33
|
+
// Evaluated against the extension element rather than the resource: the
|
|
34
|
+
// expressions are written against Extension, exactly as the extension's own
|
|
35
|
+
// validator evaluates them.
|
|
36
|
+
lines.push(`${indent} const _inv${c.key.replace(/[^A-Za-z0-9]/g, '_')} = await fhirpath.evaluate(`
|
|
37
|
+
+ `node(_invExt), ${JSON.stringify(c.expression)}, `
|
|
38
|
+
+ `{ resource, rootResource: resource }, fhirpath_model, fhirpathOptions);`);
|
|
39
|
+
lines.push(`${indent} if (!_inv${c.key.replace(/[^A-Za-z0-9]/g, '_')}.every(Boolean)) {`);
|
|
40
|
+
lines.push(`${indent} errors.push(${JSON.stringify(`Constraint violation: ${c.human}`)});`);
|
|
41
|
+
lines.push(`${indent} }`);
|
|
42
|
+
}
|
|
43
|
+
lines.push(`${indent}}`);
|
|
44
|
+
return '\n' + lines.join('\n');
|
|
45
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Extension and ValueSet slice validation strategies.
|
|
3
3
|
*/
|
|
4
|
+
import { generateExtensionInvariantChecks } from './extensionInvariants.js';
|
|
4
5
|
import { safeAccessor, safeList, sliceElseBranch, detectNestedArray, findArrayAncestor, isArrayPath, FHIR_PRIMITIVE_STRING_TYPES } from './sliceValidatorUtils.js';
|
|
5
6
|
import { stripVersionFromCanonicalUrl, stripSliceSuffix } from '../../core/utils.js';
|
|
6
7
|
import { buildSubExtensionScope, renderScopeHops } from './subExtensionScope.js';
|
|
@@ -517,7 +518,7 @@ ${indent} if (_forbidden) {
|
|
|
517
518
|
${indent} errors.push("Extension.value[x]: max allowed = 0, but found 1 (${forbidUrl})");
|
|
518
519
|
${indent} errors.push("The Extension '${extensionUrl}' definition allows for the types [] but found type " + _forbidden.slice(5));
|
|
519
520
|
${indent} }${generateSubExtensionChecks(extMeta, ctx, indent + ' ')}
|
|
520
|
-
${indent}}`;
|
|
521
|
+
${indent}}${generateExtensionInvariantChecks(matchedArrayVar, extensionUrl, extMeta, ctx, indent)}`;
|
|
521
522
|
}
|
|
522
523
|
const lines = [];
|
|
523
524
|
const allowedProps = extMeta.valueTypes.map(t => `"value${valuePropertySuffix(t)}"`);
|
|
@@ -575,5 +576,6 @@ ${indent}}`;
|
|
|
575
576
|
}
|
|
576
577
|
}
|
|
577
578
|
lines.push(`${indent}}`);
|
|
578
|
-
return '\n' + lines.join('\n')
|
|
579
|
+
return '\n' + lines.join('\n')
|
|
580
|
+
+ generateExtensionInvariantChecks(matchedArrayVar, extensionUrl, extMeta, ctx, indent);
|
|
579
581
|
}
|
|
@@ -216,10 +216,15 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
|
|
|
216
216
|
isChoiceType: false,
|
|
217
217
|
});
|
|
218
218
|
});
|
|
219
|
+
// Slice code can need fhirpath on its own: an extension's invariants are evaluated
|
|
220
|
+
// against each matched extension, and the profile carrying it need not declare a
|
|
221
|
+
// constraint of its own.
|
|
222
|
+
const fhirpathNeeded = { value: false };
|
|
219
223
|
const sliceValidations = generateSliceValidations({
|
|
220
224
|
fields, fieldPathMap, arrayFieldPaths, getValueSetForBinding,
|
|
221
225
|
valueSetImports, profileUrl, profileName: interfaceName, baseResourceType,
|
|
222
226
|
profileUrlToName, validatorImports, extensionMetadata, profileUrlToType, datatypeProfilePins,
|
|
227
|
+
fhirpathNeeded,
|
|
223
228
|
});
|
|
224
229
|
const closedSlicingValidations = generateClosedSlicingValidations(fields, profileUrl, baseResourceType, profileUrlToType);
|
|
225
230
|
const primitiveFormatValidations = buildPrimitiveFormatValidations(fields);
|
|
@@ -491,7 +496,7 @@ ${body}
|
|
|
491
496
|
const expr = c.constraint.expression || '';
|
|
492
497
|
return /\bmemberOf\b/.test(expr) || /\bresolve\b/.test(expr);
|
|
493
498
|
});
|
|
494
|
-
const fhirpathImport = filteredConstraints.length > 0
|
|
499
|
+
const fhirpathImport = filteredConstraints.length > 0 || fhirpathNeeded.value
|
|
495
500
|
? `import fhirpath from "fhirpath";\nimport fhirpath_model from "fhirpath/fhir-context/${fhirpathModelSlug()}/index.js";\n`
|
|
496
501
|
: '';
|
|
497
502
|
// `at`/`arr` are value imports: they replace the `as any` element probing that
|
|
@@ -263,12 +263,17 @@ emitInvariants = false) {
|
|
|
263
263
|
}
|
|
264
264
|
}
|
|
265
265
|
// Collect refinements (pattern constraints, slice cardinality, FHIRPath invariants, required bindings)
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
266
|
+
//
|
|
267
|
+
// Emitted for DataType profiles too, Extension among them. They used to be skipped
|
|
268
|
+
// wholesale because "DataType random() methods don't produce pattern-aware data" —
|
|
269
|
+
// a property of the test fixtures, deciding what conformance rules a shipped
|
|
270
|
+
// schema enforces. The cost was that an Extension profile's zod schema checked its
|
|
271
|
+
// fixed url and nothing else: sdc's AssembleExpectation carries a required binding
|
|
272
|
+
// to the assemble-expectation ValueSet, validate() enforced it, and the schema
|
|
273
|
+
// accepted any code at all. Over a hundred profiles disagreed that way, and the
|
|
274
|
+
// disagreement was recorded as a parity failure rather than as the missing check
|
|
275
|
+
// it was.
|
|
276
|
+
const refinementResult = buildRefinements(allFields, rootPrefix, valueSets, emitInvariants);
|
|
272
277
|
// Merge ValueSet imports from binding refinements
|
|
273
278
|
for (const [name, importPath] of refinementResult.valueSetImports) {
|
|
274
279
|
if (!valueSetImports.has(name)) {
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
* are genuinely identical across R4 / R4B / R5 so they live here as shared
|
|
11
11
|
* constants.
|
|
12
12
|
*/
|
|
13
|
-
import { TS } from '../core/constants.js';
|
|
14
13
|
// ── Shared constants (version-independent) ──────────────────────────────────
|
|
15
14
|
/** FHIR primitive type → TypeScript type mapping (same for R4/R4B/R5). */
|
|
16
15
|
const PRIMITIVE_MAP = {
|
|
@@ -46,8 +45,6 @@ export const VALUESET_THRESHOLDS = {
|
|
|
46
45
|
UNION_TYPE: 20,
|
|
47
46
|
STORE_CODES: 50,
|
|
48
47
|
};
|
|
49
|
-
/** Re-export centralized terminology systems for backward compatibility. */
|
|
50
|
-
export const FHIR_TERMINOLOGY_SYSTEMS = TS;
|
|
51
48
|
// ── Cardinality / child-type helpers ────────────────────────────────────────
|
|
52
49
|
/**
|
|
53
50
|
* Fields whose `use` / `type` properties are enum codes that should *not*
|
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
|
|
14
14
|
import type { FhirRules } from './types.js';
|
|
15
15
|
import type { DerivedVersionData } from './corePackageResolver.js';
|
|
16
|
-
import { TS } from '../core/constants.js';
|
|
17
16
|
|
|
18
17
|
// ── Shared constants (version-independent) ──────────────────────────────────
|
|
19
18
|
|
|
@@ -54,9 +53,6 @@ export const VALUESET_THRESHOLDS = {
|
|
|
54
53
|
STORE_CODES: 50,
|
|
55
54
|
} as const;
|
|
56
55
|
|
|
57
|
-
/** Re-export centralized terminology systems for backward compatibility. */
|
|
58
|
-
export const FHIR_TERMINOLOGY_SYSTEMS = TS;
|
|
59
|
-
|
|
60
56
|
// ── Cardinality / child-type helpers ────────────────────────────────────────
|
|
61
57
|
|
|
62
58
|
/**
|
|
@@ -21,6 +21,7 @@ import { ctx as versionCtx, fhirModuleSpecifier } from './fhir/versionContext.js
|
|
|
21
21
|
import { registerProfile, addDiagnostic } from './core/sdDiagnostics.js';
|
|
22
22
|
import { resetFetchFailureTracking, getFetchFailureCount, getFetchFailureWarning, recordFetchFailure, resolveComplexTypeChildType, ensureRandomSupportFile, ensureValidatorOptionsFile, } from './sdProcessorHelpers.js';
|
|
23
23
|
import { extractAndDeduplicateRequiredFields, resolveChoiceSegments } from './sdProcessorRequiredFields.js';
|
|
24
|
+
import { isBaseElementInvariant } from './emitters/validator/validatorExpressions.js';
|
|
24
25
|
// Re-export public API consumed by index.ts / generationHelpers.ts
|
|
25
26
|
export { resetFetchFailureTracking, getFetchFailureCount, getFetchFailureWarning };
|
|
26
27
|
const log = logger.withTag('generator');
|
|
@@ -194,6 +195,52 @@ async function fillSliceProfileUrlsFromBaseProfiles(fields, baseDefinition) {
|
|
|
194
195
|
* DTRStdQuestionnaire's contextExpression emitted no validation at all.
|
|
195
196
|
* Callers must strip before reading; use `lookupExtensionMetadata`.
|
|
196
197
|
*/
|
|
198
|
+
/**
|
|
199
|
+
* Invariants on an Extension SD, minus the ones the emitted validator handles
|
|
200
|
+
* elsewhere or evaluates differently from HL7.
|
|
201
|
+
*
|
|
202
|
+
* ext-1 is already checked structurally by the extension slice code, and ele-1
|
|
203
|
+
* diverges between fhirpath.js and HL7, so both are dropped here for the same
|
|
204
|
+
* reasons validatorGenerator drops them from a profile's own constraint list.
|
|
205
|
+
*/
|
|
206
|
+
function readExtensionConstraints(extSd) {
|
|
207
|
+
const els = extensionElements(extSd);
|
|
208
|
+
if (!els)
|
|
209
|
+
return undefined;
|
|
210
|
+
const out = [];
|
|
211
|
+
const seen = new Set();
|
|
212
|
+
for (const el of els) {
|
|
213
|
+
// Only invariants on the Extension root apply to the extension as a whole.
|
|
214
|
+
const path = el.path;
|
|
215
|
+
if (path !== 'Extension')
|
|
216
|
+
continue;
|
|
217
|
+
for (const c of (el.constraint ?? [])) {
|
|
218
|
+
const { key, human, expression, severity } = c;
|
|
219
|
+
if (!key || !human || !expression)
|
|
220
|
+
continue;
|
|
221
|
+
if (severity && severity !== 'error')
|
|
222
|
+
continue;
|
|
223
|
+
// By key first, because these are restated with local variations that defeat
|
|
224
|
+
// matching on the expression: the subscriptions-backport extensions write ele-1
|
|
225
|
+
// as `hasValue() or (children().count() > id.count()) or $this is Parameters`,
|
|
226
|
+
// and isBaseElementInvariant anchors its pattern, so the suffix slipped through
|
|
227
|
+
// and every backport extension reported ele-1 on its Subscription.
|
|
228
|
+
if (key === 'ele-1' || key === 'ext-1')
|
|
229
|
+
continue;
|
|
230
|
+
if (isBaseElementInvariant(expression))
|
|
231
|
+
continue;
|
|
232
|
+
if (/text\.div/.test(expression))
|
|
233
|
+
continue;
|
|
234
|
+
if (/\$[A-Z]/.test(expression))
|
|
235
|
+
continue;
|
|
236
|
+
if (seen.has(key))
|
|
237
|
+
continue;
|
|
238
|
+
seen.add(key);
|
|
239
|
+
out.push({ key, human, expression });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return out.length > 0 ? out : undefined;
|
|
243
|
+
}
|
|
197
244
|
async function resolveExtensionMetadata(fields) {
|
|
198
245
|
const extUrls = new Set();
|
|
199
246
|
for (const f of fields) {
|
|
@@ -220,6 +267,10 @@ async function resolveExtensionMetadata(fields) {
|
|
|
220
267
|
const valueEl = els.find(el => el.path === 'Extension.value[x]' || el.id === 'Extension.value[x]');
|
|
221
268
|
const subExtensions = collectSubExtensionSlices(els);
|
|
222
269
|
const subExtensionMin = readSubExtensionMin(els);
|
|
270
|
+
// Read before the branching below: a complex extension returns early, and its
|
|
271
|
+
// invariants are exactly the ones worth carrying (Mehrfachverordnung's
|
|
272
|
+
// "if the flag is true a period is required" lives on such an SD).
|
|
273
|
+
const constraints = readExtensionConstraints(extSd);
|
|
223
274
|
// A complex extension carries sub-extensions instead of a value, and forbids a
|
|
224
275
|
// value outright (ext-1: extension.exists() != value.exists()). Recorded as an
|
|
225
276
|
// empty type list — different from no entry at all, which would mean the SD
|
|
@@ -235,6 +286,7 @@ async function resolveExtensionMetadata(fields) {
|
|
|
235
286
|
valueTypes: [],
|
|
236
287
|
subExtensionMin,
|
|
237
288
|
subExtensions: subExtensions.length ? subExtensions : undefined,
|
|
289
|
+
constraints,
|
|
238
290
|
});
|
|
239
291
|
return;
|
|
240
292
|
}
|
|
@@ -243,7 +295,7 @@ async function resolveExtensionMetadata(fields) {
|
|
|
243
295
|
const types = (valueEl.type || []).map((t) => t.code).filter(Boolean);
|
|
244
296
|
if (types.length === 0)
|
|
245
297
|
return;
|
|
246
|
-
const entry = { valueTypes: types };
|
|
298
|
+
const entry = { valueTypes: types, constraints };
|
|
247
299
|
// Check binding on value[x] first, then fall back to typed slices
|
|
248
300
|
// (e.g., Extension.valueCode may carry the binding instead of Extension.value[x])
|
|
249
301
|
let binding = readBinding(valueEl);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "babelfhir-ts",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.12",
|
|
4
4
|
"description": "BabelFHIR-TS: generate TypeScript interfaces, validators, and helper classes from FHIR R4/R4B/R5 StructureDefinitions (profiles) directly inside package archives.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "out/src/main.js",
|
|
@@ -86,32 +86,32 @@
|
|
|
86
86
|
},
|
|
87
87
|
"devDependencies": {
|
|
88
88
|
"@eslint/js": "^10.0.1",
|
|
89
|
-
"@max-health-inc/config": "^3.
|
|
89
|
+
"@max-health-inc/config": "^3.3.0",
|
|
90
90
|
"@maxhealth.tech/prefab": "^0.3.12",
|
|
91
|
-
"@types/node": "^
|
|
91
|
+
"@types/node": "^26.4.0",
|
|
92
92
|
"@types/semver": "^7.8.0",
|
|
93
93
|
"@types/unzipper": "^0.10.11",
|
|
94
|
-
"@vitest/coverage-v8": "^4.1.
|
|
95
|
-
"@vitest/eslint-plugin": "^1.6.
|
|
96
|
-
"@vitest/ui": "^4.1.
|
|
94
|
+
"@vitest/coverage-v8": "^4.1.11",
|
|
95
|
+
"@vitest/eslint-plugin": "^1.6.27",
|
|
96
|
+
"@vitest/ui": "^4.1.11",
|
|
97
97
|
"cross-env": "^10.1.0",
|
|
98
|
-
"eslint": "^10.
|
|
98
|
+
"eslint": "^10.9.1",
|
|
99
99
|
"fhirpath": "^5.2.0",
|
|
100
|
-
"globals": "^17.
|
|
100
|
+
"globals": "^17.11.0",
|
|
101
101
|
"rimraf": "^6.1.3",
|
|
102
102
|
"semver": "^7.8.5",
|
|
103
103
|
"typescript": "^6.0.3",
|
|
104
|
-
"typescript-eslint": "^8.
|
|
104
|
+
"typescript-eslint": "^8.68.0",
|
|
105
105
|
"vitepress": "^1.6.4",
|
|
106
|
-
"vitepress-plugin-llms": "^1.13.
|
|
107
|
-
"vitest": "^4.1.
|
|
108
|
-
"zod": "4.3
|
|
106
|
+
"vitepress-plugin-llms": "^1.13.5",
|
|
107
|
+
"vitest": "^4.1.11",
|
|
108
|
+
"zod": "4.4.3"
|
|
109
109
|
},
|
|
110
110
|
"dependencies": {
|
|
111
111
|
"@types/fhir": "^0.0.44",
|
|
112
112
|
"consola": "^3.4.2",
|
|
113
113
|
"tar": "^7.5.22",
|
|
114
|
-
"tsx": "^4.23.
|
|
114
|
+
"tsx": "^4.23.12",
|
|
115
115
|
"unzipper": "^0.12.5"
|
|
116
116
|
},
|
|
117
117
|
"overrides": {
|