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
|
@@ -78,6 +78,34 @@ export function resolveWellKnownValueSetCodes(valueSetCodesMap) {
|
|
|
78
78
|
log.info(`Resolved ${resolved} core ValueSets from well-known system codes (no tx server needed)`);
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Drop the codes a ValueSet's `compose.exclude` removes, returning what survives.
|
|
83
|
+
*
|
|
84
|
+
* Concept excludes drop the listed system|code pairs. Filter excludes cover `is-a`
|
|
85
|
+
* only, by dropping the named code itself: the full hierarchy is not available
|
|
86
|
+
* here, and that is the case published IGs actually rely on.
|
|
87
|
+
*/
|
|
88
|
+
function applyComposeExcludes(bucket, excludes) {
|
|
89
|
+
if (!Array.isArray(excludes))
|
|
90
|
+
return bucket;
|
|
91
|
+
let kept = bucket;
|
|
92
|
+
for (const excl of excludes) {
|
|
93
|
+
if (Array.isArray(excl.concept)) {
|
|
94
|
+
const dropped = new Set(excl.concept.map(c => `${excl.system || ''}|${c.code}`));
|
|
95
|
+
kept = kept.filter(e => !dropped.has(`${e.system || ''}|${e.code}`));
|
|
96
|
+
}
|
|
97
|
+
if (Array.isArray(excl.filter)) {
|
|
98
|
+
for (const f of excl.filter) {
|
|
99
|
+
if (f.op !== 'is-a' || !f.value)
|
|
100
|
+
continue;
|
|
101
|
+
const code = f.value;
|
|
102
|
+
const system = excl.system || '';
|
|
103
|
+
kept = kept.filter(e => !(e.code === code && (e.system || '') === system));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return kept;
|
|
108
|
+
}
|
|
81
109
|
/**
|
|
82
110
|
* Resolve ValueSets that use compose.include with valueSet references (include-by-valueSet)
|
|
83
111
|
* and/or compose.exclude entries. These can't be resolved during the initial per-package pass
|
|
@@ -136,37 +164,11 @@ function resolveValueSetCompose(visitedDirs, merged) {
|
|
|
136
164
|
}
|
|
137
165
|
}
|
|
138
166
|
}
|
|
139
|
-
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
if (excl.concept && Array.isArray(excl.concept)) {
|
|
143
|
-
const excludeSet = new Set(excl.concept.map((c) => `${excl.system || ''}|${c.code}`));
|
|
144
|
-
for (let i = bucket.length - 1; i >= 0; i--) {
|
|
145
|
-
if (excludeSet.has(`${bucket[i].system || ''}|${bucket[i].code}`)) {
|
|
146
|
-
bucket.splice(i, 1);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
// Filter-based exclude: handle is-a by removing the specified code
|
|
151
|
-
if (excl.filter && Array.isArray(excl.filter)) {
|
|
152
|
-
for (const f of excl.filter) {
|
|
153
|
-
if (f.op === 'is-a' && f.value) {
|
|
154
|
-
const excludeCode = f.value;
|
|
155
|
-
const excludeSystem = excl.system || '';
|
|
156
|
-
for (let i = bucket.length - 1; i >= 0; i--) {
|
|
157
|
-
if (bucket[i].code === excludeCode && (bucket[i].system || '') === excludeSystem) {
|
|
158
|
-
bucket.splice(i, 1);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
if (bucket.length > 0) {
|
|
167
|
-
merged[canonical] = bucket;
|
|
167
|
+
const kept = applyComposeExcludes(bucket, content.compose.exclude);
|
|
168
|
+
if (kept.length > 0) {
|
|
169
|
+
merged[canonical] = kept;
|
|
168
170
|
resolved++;
|
|
169
|
-
log.info(`Resolved ValueSet ${canonical} via compose references (${
|
|
171
|
+
log.info(`Resolved ValueSet ${canonical} via compose references (${kept.length} codes)`);
|
|
170
172
|
}
|
|
171
173
|
}
|
|
172
174
|
catch { /* skip */ }
|
|
@@ -240,10 +242,11 @@ export function detectFhirVersion(extractedRoot) {
|
|
|
240
242
|
const content = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
241
243
|
if (content.resourceType === 'ImplementationGuide') {
|
|
242
244
|
const versions = Array.isArray(content.fhirVersion) ? content.fhirVersion : [];
|
|
243
|
-
|
|
244
|
-
|
|
245
|
+
const [declared] = versions;
|
|
246
|
+
if (declared) {
|
|
247
|
+
const slug = FHIR_VERSION_TO_SLUG[declared];
|
|
245
248
|
if (slug) {
|
|
246
|
-
log.info(`Auto-detected FHIR version ${
|
|
249
|
+
log.info(`Auto-detected FHIR version ${declared} (${slug}) from ImplementationGuide`);
|
|
247
250
|
return slug;
|
|
248
251
|
}
|
|
249
252
|
}
|
|
@@ -432,6 +435,7 @@ export function readValueSetCodesFromDir(extractedRoot, codeSystemMap) {
|
|
|
432
435
|
if (!canonical)
|
|
433
436
|
continue;
|
|
434
437
|
const bucket = [];
|
|
438
|
+
let kept = bucket;
|
|
435
439
|
// Prefer expansion if present (already resolved codes)
|
|
436
440
|
if (content.expansion && Array.isArray(content.expansion.contains)) {
|
|
437
441
|
for (const c of content.expansion.contains) {
|
|
@@ -458,38 +462,10 @@ export function readValueSetCodesFromDir(extractedRoot, codeSystemMap) {
|
|
|
458
462
|
}
|
|
459
463
|
}
|
|
460
464
|
}
|
|
461
|
-
|
|
462
|
-
if (content.compose.exclude && Array.isArray(content.compose.exclude)) {
|
|
463
|
-
for (const excl of content.compose.exclude) {
|
|
464
|
-
// Concept-based exclude: remove explicitly listed codes
|
|
465
|
-
if (excl.concept && Array.isArray(excl.concept)) {
|
|
466
|
-
const excludeSet = new Set(excl.concept.map((c) => `${excl.system || ''}|${c.code}`));
|
|
467
|
-
for (let i = bucket.length - 1; i >= 0; i--) {
|
|
468
|
-
if (excludeSet.has(`${bucket[i].system || ''}|${bucket[i].code}`)) {
|
|
469
|
-
bucket.splice(i, 1);
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
// Filter-based exclude: handle is-a filter by removing the specified code
|
|
474
|
-
// (full hierarchy traversal is not available; this covers the common case)
|
|
475
|
-
if (excl.filter && Array.isArray(excl.filter)) {
|
|
476
|
-
for (const f of excl.filter) {
|
|
477
|
-
if (f.op === 'is-a' && f.value) {
|
|
478
|
-
const excludeCode = f.value;
|
|
479
|
-
const excludeSystem = excl.system || '';
|
|
480
|
-
for (let i = bucket.length - 1; i >= 0; i--) {
|
|
481
|
-
if (bucket[i].code === excludeCode && (bucket[i].system || '') === excludeSystem) {
|
|
482
|
-
bucket.splice(i, 1);
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
}
|
|
465
|
+
kept = applyComposeExcludes(bucket, content.compose.exclude);
|
|
490
466
|
}
|
|
491
|
-
if (
|
|
492
|
-
map[canonical] =
|
|
467
|
+
if (kept.length) {
|
|
468
|
+
map[canonical] = kept;
|
|
493
469
|
}
|
|
494
470
|
}
|
|
495
471
|
catch (err) {
|
|
@@ -498,6 +474,20 @@ export function readValueSetCodesFromDir(extractedRoot, codeSystemMap) {
|
|
|
498
474
|
}
|
|
499
475
|
return map;
|
|
500
476
|
}
|
|
477
|
+
/**
|
|
478
|
+
* Whether a `dependencies` entry names a FHIR package at a concrete version.
|
|
479
|
+
*
|
|
480
|
+
* A FHIR IG manifest lists unscoped names pinned to exact versions. An npm
|
|
481
|
+
* scope or a semver range means the manifest being read is not an IG's — and
|
|
482
|
+
* the cache path built from it is unwritable (`<cache>/@types/fhir->=0.0.44
|
|
483
|
+
* <0.1.0.tgz`), which surfaced as an unhandled rejection from the download
|
|
484
|
+
* stream and took the whole run down instead of skipping one entry.
|
|
485
|
+
*/
|
|
486
|
+
export function isFhirPackageSpec(depName, depVersion) {
|
|
487
|
+
if (depName.startsWith('@'))
|
|
488
|
+
return false;
|
|
489
|
+
return /^[A-Za-z0-9][A-Za-z0-9.\-+]*$/.test(depVersion);
|
|
490
|
+
}
|
|
501
491
|
/**
|
|
502
492
|
* Recursively ensure all dependency packages are downloaded and extracted.
|
|
503
493
|
* Walks the dependency tree from an extracted package's package.json,
|
|
@@ -508,6 +498,10 @@ export async function ensureDependenciesDownloaded(extractedRoot) {
|
|
|
508
498
|
ensureCacheDir(cacheDir);
|
|
509
499
|
const visited = new Set();
|
|
510
500
|
async function downloadAndExtract(depName, depVersion) {
|
|
501
|
+
if (!isFhirPackageSpec(depName, depVersion)) {
|
|
502
|
+
log.warn(`Ignoring dependency that is not a FHIR package spec: ${depName}@${depVersion}`);
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
511
505
|
const depDir = path.join(cacheDir, `${depName}@${depVersion}`);
|
|
512
506
|
// Already cached (@ separator)?
|
|
513
507
|
if (fs.existsSync(depDir) && fs.readdirSync(depDir).length > 0) {
|
|
@@ -824,6 +818,64 @@ export function readValueSetsFromDir(extractedRoot) {
|
|
|
824
818
|
}
|
|
825
819
|
return map;
|
|
826
820
|
}
|
|
821
|
+
/**
|
|
822
|
+
* Read all NamingSystem resources from an extracted package directory.
|
|
823
|
+
*
|
|
824
|
+
* Entries with no `uniqueId` are dropped: there is nothing to emit for them.
|
|
825
|
+
*/
|
|
826
|
+
export function readNamingSystemsFromDir(extractedRoot) {
|
|
827
|
+
const jsonFiles = findJsonFilesByPrefix(extractedRoot, 'NamingSystem-');
|
|
828
|
+
const found = [];
|
|
829
|
+
let skippedRetired = 0;
|
|
830
|
+
let skippedNoId = 0;
|
|
831
|
+
for (const file of jsonFiles) {
|
|
832
|
+
try {
|
|
833
|
+
const content = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
834
|
+
if (content.resourceType !== 'NamingSystem')
|
|
835
|
+
continue;
|
|
836
|
+
if (content.status === 'retired') {
|
|
837
|
+
skippedRetired++;
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
const uniqueIds = (Array.isArray(content.uniqueId) ? content.uniqueId : [])
|
|
841
|
+
.filter((u) => typeof u?.value === 'string' && u.value.length > 0)
|
|
842
|
+
.map((u) => ({
|
|
843
|
+
type: u.type || 'other',
|
|
844
|
+
value: u.value,
|
|
845
|
+
...(u.preferred !== undefined && { preferred: u.preferred }),
|
|
846
|
+
...(u.comment && { comment: u.comment }),
|
|
847
|
+
}));
|
|
848
|
+
// Destructured rather than length-checked: this is what proves the tuple
|
|
849
|
+
// shape to the compiler, so the emitters need no undefined handling.
|
|
850
|
+
const [firstId, ...restIds] = uniqueIds;
|
|
851
|
+
if (!firstId) {
|
|
852
|
+
skippedNoId++;
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
const name = content.name || content.title || content.id;
|
|
856
|
+
if (!name) {
|
|
857
|
+
skippedNoId++;
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
found.push({
|
|
861
|
+
name,
|
|
862
|
+
...(content.title && { title: content.title }),
|
|
863
|
+
...(content.description && { description: content.description }),
|
|
864
|
+
...(content.kind && { kind: content.kind }),
|
|
865
|
+
uniqueIds: [firstId, ...restIds],
|
|
866
|
+
});
|
|
867
|
+
log.debug(`Loaded NamingSystem: ${name} (${uniqueIds.length} uniqueId entries)`);
|
|
868
|
+
}
|
|
869
|
+
catch (err) {
|
|
870
|
+
log.debug(`Skipping invalid JSON while reading NamingSystems ${file}: ${err.message}`);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
// Say what was dropped: a silently shorter list reads as "the IG declares none".
|
|
874
|
+
if (skippedRetired > 0 || skippedNoId > 0) {
|
|
875
|
+
log.info(`NamingSystems: emitting ${found.length}, skipped ${skippedRetired} retired and ${skippedNoId} without a usable uniqueId`);
|
|
876
|
+
}
|
|
877
|
+
return found;
|
|
878
|
+
}
|
|
827
879
|
/**
|
|
828
880
|
* Read all SearchParameter resources from a package directory.
|
|
829
881
|
* Returns a map of base resource type → array of search parameter definitions.
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Handles local registry, disk cache, network fetching (canonical URLs, Simplifier,
|
|
4
4
|
* hl7.org fallbacks, bundled base R4 SDs), and ValueSet binding URL collection.
|
|
5
5
|
*/
|
|
6
|
+
import { stripVersionFromCanonicalUrl } from '../core/utils.js';
|
|
6
7
|
import path from "path";
|
|
7
8
|
import fs from "fs";
|
|
8
9
|
import https from "https";
|
|
@@ -89,7 +90,8 @@ export async function fetchStructureDefinition(url, fallbackUrl) {
|
|
|
89
90
|
// Strip version suffix from canonical URLs (e.g., "http://....|5.3.0-ballot-tc1" -> "http://....")
|
|
90
91
|
// FHIR canonical URLs use | to separate the URL from the version
|
|
91
92
|
if (url.includes('|')) {
|
|
92
|
-
const
|
|
93
|
+
const baseUrl = stripVersionFromCanonicalUrl(url);
|
|
94
|
+
const version = url.substring(url.indexOf('|') + 1);
|
|
93
95
|
logger.debug(`[fetchSD] Stripping version suffix from canonical URL: ${url} -> ${baseUrl} (version: ${version})`);
|
|
94
96
|
url = baseUrl;
|
|
95
97
|
}
|
|
@@ -373,7 +375,7 @@ export function collectValueSetBindingUrls(structureDefinitions) {
|
|
|
373
375
|
for (const element of sd.differential.element) {
|
|
374
376
|
if (element.binding?.valueSet) {
|
|
375
377
|
// Strip version fragment if present (e.g., "...ValueSet|4.0.1")
|
|
376
|
-
const url = element.binding.valueSet
|
|
378
|
+
const url = stripVersionFromCanonicalUrl(element.binding.valueSet);
|
|
377
379
|
bindingUrls.add(url);
|
|
378
380
|
}
|
|
379
381
|
}
|
|
@@ -382,7 +384,7 @@ export function collectValueSetBindingUrls(structureDefinitions) {
|
|
|
382
384
|
if (sd.snapshot?.element) {
|
|
383
385
|
for (const element of sd.snapshot.element) {
|
|
384
386
|
if (element.binding?.valueSet) {
|
|
385
|
-
const url = element.binding.valueSet
|
|
387
|
+
const url = stripVersionFromCanonicalUrl(element.binding.valueSet);
|
|
386
388
|
bindingUrls.add(url);
|
|
387
389
|
}
|
|
388
390
|
}
|
|
@@ -399,7 +399,7 @@ export async function parseStructureDefinition(structureDefinition, fhirServerUr
|
|
|
399
399
|
}
|
|
400
400
|
if (bindingUri) {
|
|
401
401
|
// Strip version suffix for map lookup (e.g. "…/ValueSet/foo|1.0.0" → "…/ValueSet/foo")
|
|
402
|
-
const lookupUri = bindingUri
|
|
402
|
+
const lookupUri = stripVersionFromCanonicalUrl(bindingUri);
|
|
403
403
|
const codes = valueSetCodesMap?.[lookupUri] ?? valueSetCodesMap?.[bindingUri];
|
|
404
404
|
if (codes && codes.length) {
|
|
405
405
|
// Deterministic pick based on simple hash of binding URI
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Composition-specific helpers live in sliceAggregatorComposition.ts.
|
|
7
7
|
*/
|
|
8
8
|
import { logger } from "../../logger.js";
|
|
9
|
+
import { stripVersionFromCanonicalUrl } from '../core/utils.js';
|
|
9
10
|
import { fetchStructureDefinition } from './sdFetcher.js';
|
|
10
11
|
import { resolveValueSetDefault } from '../core/constants.js';
|
|
11
12
|
import { complexExtValueDefault, resolveCompositionOpts } from './sliceAggregatorComposition.js';
|
|
@@ -26,7 +27,7 @@ export function synthesizeSlicePatternsFromChildren(fields) {
|
|
|
26
27
|
if (!idToUse)
|
|
27
28
|
continue;
|
|
28
29
|
const match = idToUse.match(/^(.*?)(?:\.coding|\.text|$)/);
|
|
29
|
-
const basePath = match
|
|
30
|
+
const basePath = match?.[1] ?? idToUse;
|
|
30
31
|
if (!fieldsByBasePath.has(basePath)) {
|
|
31
32
|
fieldsByBasePath.set(basePath, []);
|
|
32
33
|
}
|
|
@@ -256,6 +257,8 @@ export async function aggregateRequiredSlices(fields, baseFields = [], valueSetC
|
|
|
256
257
|
if (pathParts.length !== 2)
|
|
257
258
|
continue;
|
|
258
259
|
const childPath = pathParts[1];
|
|
260
|
+
if (!childPath)
|
|
261
|
+
continue;
|
|
259
262
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
260
263
|
const elemAny = elem;
|
|
261
264
|
const fixedValue = elemAny.fixedUri ??
|
|
@@ -267,10 +270,11 @@ export async function aggregateRequiredSlices(fields, baseFields = [], valueSetC
|
|
|
267
270
|
const bindingUri = elem.binding?.valueSet;
|
|
268
271
|
let resolvedDefault = bindingUri ? resolveValueSetDefault(bindingUri) : undefined;
|
|
269
272
|
if (!resolvedDefault && bindingUri && valueSetCodesMap) {
|
|
270
|
-
const bare = bindingUri
|
|
273
|
+
const bare = stripVersionFromCanonicalUrl(bindingUri);
|
|
271
274
|
const codes = valueSetCodesMap[bare] ?? valueSetCodesMap[bindingUri];
|
|
272
|
-
|
|
273
|
-
|
|
275
|
+
const [firstCode] = codes ?? [];
|
|
276
|
+
if (firstCode) {
|
|
277
|
+
resolvedDefault = { code: firstCode.code, system: firstCode.system };
|
|
274
278
|
}
|
|
275
279
|
}
|
|
276
280
|
const isSimpleExtension = isChoiceElement && elem.type?.length &&
|
|
@@ -315,8 +319,8 @@ export async function aggregateRequiredSlices(fields, baseFields = [], valueSetC
|
|
|
315
319
|
}
|
|
316
320
|
}
|
|
317
321
|
// ── Complex extension handling ─────────────────────────────────────
|
|
318
|
-
|
|
319
|
-
|
|
322
|
+
const [extProfileUrl] = slice.profileUrls ?? [];
|
|
323
|
+
if (extProfileUrl) {
|
|
320
324
|
try {
|
|
321
325
|
const extProfile = await fetchStructureDefinition(extProfileUrl, '');
|
|
322
326
|
if (extProfile?.type === 'Extension') {
|
|
@@ -333,10 +337,9 @@ export async function aggregateRequiredSlices(fields, baseFields = [], valueSetC
|
|
|
333
337
|
const subExt = {
|
|
334
338
|
url: urlElem?.fixedUri || subSliceName,
|
|
335
339
|
};
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
: valueElem.type[0].code;
|
|
340
|
+
const firstType = valueElem?.type?.[0];
|
|
341
|
+
if (firstType) {
|
|
342
|
+
const vt = typeof firstType === 'string' ? firstType : firstType.code;
|
|
340
343
|
const cap = vt.charAt(0).toUpperCase() + vt.slice(1);
|
|
341
344
|
subExt[`value${cap}`] = complexExtValueDefault(vt);
|
|
342
345
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Composition profile constraint resolution for slice aggregation.
|
|
3
3
|
*/
|
|
4
4
|
import { fetchStructureDefinition } from './sdFetcher.js';
|
|
5
|
+
import { stripVersionFromCanonicalUrl } from '../core/utils.js';
|
|
5
6
|
import { DEFAULT_BASE64_HELLO } from '../core/constants.js';
|
|
6
7
|
/** Return a sensible default value for a FHIR type used in complex extension value[x]. */
|
|
7
8
|
export function complexExtValueDefault(fhirType) {
|
|
@@ -21,7 +22,15 @@ export function complexExtValueDefault(fhirType) {
|
|
|
21
22
|
case 'canonical': return 'https://example.org';
|
|
22
23
|
case 'Reference': return { reference: 'Resource/example' };
|
|
23
24
|
case 'Quantity': return { value: 1, unit: 'unit' };
|
|
24
|
-
|
|
25
|
+
// Dates go through the same placeholders the class emitter resolves into
|
|
26
|
+
// randomDate(), so a sub-extension gets a fresh value per random() call.
|
|
27
|
+
// Previously dateTime/instant/date fell through to 'value', which is not a
|
|
28
|
+
// valid FHIR dateTime, and Period froze the generation-time clock into the
|
|
29
|
+
// emitted source.
|
|
30
|
+
case 'dateTime':
|
|
31
|
+
case 'instant': return '__DATE_EXPR__';
|
|
32
|
+
case 'date': return '__DATE10_EXPR__';
|
|
33
|
+
case 'Period': return { start: '__DATE_EXPR__' };
|
|
25
34
|
default: return 'value';
|
|
26
35
|
}
|
|
27
36
|
}
|
|
@@ -130,7 +139,7 @@ export async function resolveCompositionOpts(profileUrl) {
|
|
|
130
139
|
const e = elem;
|
|
131
140
|
if (REFERENCE_FIELDS.includes(e.path) && Array.isArray(e.type)) {
|
|
132
141
|
for (const tp of (e.type[0]?.targetProfile ?? [])) {
|
|
133
|
-
const tpBase = tp
|
|
142
|
+
const tpBase = stripVersionFromCanonicalUrl(tp);
|
|
134
143
|
if (tpBase.startsWith('http://hl7.org/fhir/StructureDefinition/'))
|
|
135
144
|
continue;
|
|
136
145
|
const lastSeg = tpBase.split('/').pop() ?? '';
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Parses FHIR ValueSet resources and extracts codes for TypeScript union type generation
|
|
4
4
|
*/
|
|
5
5
|
import { logger } from '../../logger.js';
|
|
6
|
+
import { stripVersionFromCanonicalUrl } from '../core/utils.js';
|
|
6
7
|
import { ctx } from '../fhir/versionContext.js';
|
|
7
8
|
import fs from 'fs';
|
|
8
9
|
import path from 'path';
|
|
@@ -118,7 +119,9 @@ export function getUniformSystem(valueSet) {
|
|
|
118
119
|
if (valueSet.concepts.length === 0) {
|
|
119
120
|
return null;
|
|
120
121
|
}
|
|
121
|
-
const firstSystem = valueSet.concepts[0]
|
|
122
|
+
const firstSystem = valueSet.concepts[0]?.system;
|
|
123
|
+
if (firstSystem === undefined)
|
|
124
|
+
return null;
|
|
122
125
|
const allSameSystem = valueSet.concepts.every(c => c.system === firstSystem);
|
|
123
126
|
return allSameSystem ? firstSystem : null;
|
|
124
127
|
}
|
|
@@ -222,7 +225,7 @@ export function addPlaceholdersForExternalBindings(valueSets, bindingUrls, fhirC
|
|
|
222
225
|
function findValueSetInCache(canonicalUrl, fhirCacheDir) {
|
|
223
226
|
// Derive the expected filename from the canonical URL
|
|
224
227
|
// e.g. "http://hl7.org/fhir/ValueSet/mimetypes" → "ValueSet-mimetypes.json"
|
|
225
|
-
const urlName = canonicalUrl
|
|
228
|
+
const urlName = stripVersionFromCanonicalUrl(canonicalUrl).split('/').pop();
|
|
226
229
|
if (!urlName)
|
|
227
230
|
return undefined;
|
|
228
231
|
const expectedFilename = `ValueSet-${urlName}.json`;
|
|
@@ -242,7 +245,7 @@ function findValueSetInCache(canonicalUrl, fhirCacheDir) {
|
|
|
242
245
|
if (content.resourceType !== 'ValueSet')
|
|
243
246
|
continue;
|
|
244
247
|
// Verify the canonical URL matches (not just the filename)
|
|
245
|
-
if (content.url && content.url === canonicalUrl
|
|
248
|
+
if (content.url && content.url === stripVersionFromCanonicalUrl(canonicalUrl)) {
|
|
246
249
|
const parsed = parseValueSet(content);
|
|
247
250
|
if (parsed)
|
|
248
251
|
return parsed;
|
|
@@ -10,7 +10,7 @@ import path from 'path';
|
|
|
10
10
|
import { generateClass } from './emitters/class/classGenerator.js';
|
|
11
11
|
import { generateInterfaces } from './emitters/interface/interfaceGenerator.js';
|
|
12
12
|
import { fetchStructureDefinition, parseStructureDefinition } from './parser/sdParser.js';
|
|
13
|
-
import { writeInterfaceAndValidatorToFile, mergeFields, alignFields, writeToFile, getBaseResource, sanitizeIdentifier, toPascalCase, stripVersionFromCanonicalUrl } from './core/utils.js';
|
|
13
|
+
import { writeInterfaceAndValidatorToFile, mergeFields, alignFields, writeToFile, getBaseResource, sanitizeIdentifier, toPascalCase, stripVersionFromCanonicalUrl, stripSliceSuffix } from './core/utils.js';
|
|
14
14
|
import { extensionElements, collectSubExtensionSlices, readSubExtensionMin, readBinding } from './core/extensionSdReader.js';
|
|
15
15
|
import { generateValidateProfileFunction } from './emitters/validator/validatorGenerator.js';
|
|
16
16
|
import { generateZodSchema } from './emitters/zod/zodSchemaGenerator.js';
|
|
@@ -113,7 +113,10 @@ async function fillUnstatedFromBaseResource(fields, resourceType) {
|
|
|
113
113
|
if (!current)
|
|
114
114
|
continue;
|
|
115
115
|
for (let i = next; i < segments.length && current; i++) {
|
|
116
|
-
|
|
116
|
+
const segment = segments[i];
|
|
117
|
+
if (segment === undefined)
|
|
118
|
+
break;
|
|
119
|
+
current = resolveComplexTypeChildType(current, segment);
|
|
117
120
|
}
|
|
118
121
|
if (current) {
|
|
119
122
|
field.type = current;
|
|
@@ -193,11 +196,12 @@ async function fillSliceProfileUrlsFromBaseProfiles(fields, baseDefinition) {
|
|
|
193
196
|
async function resolveExtensionMetadata(fields) {
|
|
194
197
|
const extUrls = new Set();
|
|
195
198
|
for (const f of fields) {
|
|
196
|
-
|
|
199
|
+
const firstProfile = f.sliceName ? f.profileUrls?.[0] : undefined;
|
|
200
|
+
if (!firstProfile)
|
|
197
201
|
continue;
|
|
198
|
-
const basePath = (f.name || '').replace(/^[^.]+\./, '')
|
|
202
|
+
const basePath = stripSliceSuffix((f.name || '').replace(/^[^.]+\./, ''));
|
|
199
203
|
if (basePath.endsWith('extension') || basePath.endsWith('modifierExtension')) {
|
|
200
|
-
extUrls.add(
|
|
204
|
+
extUrls.add(firstProfile);
|
|
201
205
|
}
|
|
202
206
|
}
|
|
203
207
|
if (extUrls.size === 0)
|
|
@@ -242,8 +246,9 @@ async function resolveExtensionMetadata(fields) {
|
|
|
242
246
|
// Check binding on value[x] first, then fall back to typed slices
|
|
243
247
|
// (e.g., Extension.valueCode may carry the binding instead of Extension.value[x])
|
|
244
248
|
let binding = readBinding(valueEl);
|
|
245
|
-
|
|
246
|
-
|
|
249
|
+
const onlyType = types.length === 1 ? types[0] : undefined;
|
|
250
|
+
if (!binding && onlyType) {
|
|
251
|
+
const suffix = onlyType.charAt(0).toUpperCase() + onlyType.slice(1);
|
|
247
252
|
const sliceEl = els.find(el => el.id === `Extension.value[x]:value${suffix}` ||
|
|
248
253
|
el.path === `Extension.value${suffix}` ||
|
|
249
254
|
el.id === `Extension.value${suffix}`);
|
|
@@ -274,12 +279,13 @@ async function resolveExtensionMetadata(fields) {
|
|
|
274
279
|
async function resolveDatatypeProfilePins(fields) {
|
|
275
280
|
const urls = new Set();
|
|
276
281
|
for (const f of fields) {
|
|
277
|
-
|
|
282
|
+
const firstProfile = f.sliceName ? f.profileUrls?.[0] : undefined;
|
|
283
|
+
if (!firstProfile)
|
|
278
284
|
continue;
|
|
279
|
-
const basePath = (f.name || '').replace(/^[^.]+\./, '')
|
|
285
|
+
const basePath = stripSliceSuffix((f.name || '').replace(/^[^.]+\./, ''));
|
|
280
286
|
if (basePath.endsWith('extension') || basePath.endsWith('modifierExtension'))
|
|
281
287
|
continue;
|
|
282
|
-
urls.add(
|
|
288
|
+
urls.add(firstProfile);
|
|
283
289
|
}
|
|
284
290
|
if (urls.size === 0)
|
|
285
291
|
return undefined;
|
|
@@ -514,8 +520,11 @@ export async function processStructureDefinition(sd, ctx) {
|
|
|
514
520
|
continue;
|
|
515
521
|
if (f.isOptional || f.isForbidden)
|
|
516
522
|
continue;
|
|
517
|
-
const
|
|
518
|
-
|
|
523
|
+
const parentSegment = parts[1];
|
|
524
|
+
if (!parentSegment)
|
|
525
|
+
continue;
|
|
526
|
+
const parentFieldName = stripSliceSuffix(parentSegment);
|
|
527
|
+
let relativePath = parts.slice(2).map((p) => stripSliceSuffix(p)).join('.');
|
|
519
528
|
if (relativePath.includes('[x]')) {
|
|
520
529
|
const resolvedPath = resolveChoiceSegments(f, parts, candidateRequiredFields);
|
|
521
530
|
// An intermediate choice we cannot narrow to one type has no property name.
|
|
@@ -566,9 +575,9 @@ export async function processStructureDefinition(sd, ctx) {
|
|
|
566
575
|
const parentPath = parts.slice(0, -1).join('.');
|
|
567
576
|
const parentField = candidateRequiredFields.find(pf => pf?.name === parentPath && pf.type && pf.type !== 'any' && pf.type !== 'unknown')
|
|
568
577
|
|| parsed.oldFields.find(pf => pf?.name === parentPath && pf.type && pf.type !== 'any' && pf.type !== 'unknown');
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
fieldType = resolveComplexTypeChildType(parentField.type,
|
|
578
|
+
const leafSegment = parts.at(-1);
|
|
579
|
+
if (parentField?.type && leafSegment) {
|
|
580
|
+
fieldType = resolveComplexTypeChildType(parentField.type, stripSliceSuffix(leafSegment));
|
|
572
581
|
}
|
|
573
582
|
}
|
|
574
583
|
}
|
|
@@ -640,8 +649,8 @@ export async function processStructureDefinition(sd, ctx) {
|
|
|
640
649
|
if (originalField?.typeOptions?.[0]?.targetProfileUrls?.[0]) {
|
|
641
650
|
const targetUrl = stripVersionFromCanonicalUrl(originalField.typeOptions[0].targetProfileUrls[0]);
|
|
642
651
|
const match = targetUrl.match(/\/([^/]+)$/);
|
|
643
|
-
|
|
644
|
-
|
|
652
|
+
const candidate = match?.[1];
|
|
653
|
+
if (candidate) {
|
|
645
654
|
const knownResourceNames = versionCtx().resourceNames;
|
|
646
655
|
if (knownResourceNames.includes(candidate)) {
|
|
647
656
|
rf.targetResourceType = candidate;
|
|
@@ -651,9 +660,9 @@ export async function processStructureDefinition(sd, ctx) {
|
|
|
651
660
|
let resolved = false;
|
|
652
661
|
for (const bf of baseFields) {
|
|
653
662
|
for (const tp of bf.typeOptions?.[0]?.targetProfileUrls ?? []) {
|
|
654
|
-
const
|
|
655
|
-
if (
|
|
656
|
-
rf.targetResourceType =
|
|
663
|
+
const bmCandidate = tp.match(/\/([^/]+)$/)?.[1];
|
|
664
|
+
if (bmCandidate && knownResourceNames.includes(bmCandidate)) {
|
|
665
|
+
rf.targetResourceType = bmCandidate;
|
|
657
666
|
resolved = true;
|
|
658
667
|
break;
|
|
659
668
|
}
|
|
@@ -732,7 +741,7 @@ export async function processStructureDefinition(sd, ctx) {
|
|
|
732
741
|
writeToFile(path.join(outputDir, `${interfaceName}Class.ts`), classContent);
|
|
733
742
|
}
|
|
734
743
|
if (flags?.schema === 'zod') {
|
|
735
|
-
const zodResult = generateZodSchema(interfaceName, aligned.alignedNewFields, baseResource, parsed.oldFields, valueSets);
|
|
744
|
+
const zodResult = generateZodSchema(interfaceName, aligned.alignedNewFields, baseResource, parsed.oldFields, valueSets, !!flags?.zodInvariants);
|
|
736
745
|
writeToFile(path.join(outputDir, `${interfaceName}.zod.ts`), zodResult.code);
|
|
737
746
|
}
|
|
738
747
|
if (flags?.prefab) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Required-field extraction and deduplication for sdProcessor.
|
|
3
3
|
*/
|
|
4
|
+
import { stripSliceSuffix } from './core/utils.js';
|
|
4
5
|
/**
|
|
5
6
|
* Build and deduplicate the list of required fields from candidate fields.
|
|
6
7
|
* Filters to direct children of the root, then deduplicates by name with
|
|
@@ -22,7 +23,7 @@ export function extractAndDeduplicateRequiredFields(candidateRequiredFields, sdI
|
|
|
22
23
|
return root === baseResource || root === sdRoot || (root === type && profileDiffFieldNames.has(f.name));
|
|
23
24
|
})
|
|
24
25
|
.map(f => ({
|
|
25
|
-
name: (f.name.split('.').pop() || f.name)
|
|
26
|
+
name: stripSliceSuffix(f.name.split('.').pop() || f.name),
|
|
26
27
|
type: f.type || 'string',
|
|
27
28
|
baseType: f.baseTypeCode,
|
|
28
29
|
isArray: f.isArray,
|
|
@@ -229,7 +230,7 @@ export function resolveChoiceSegments(field, parts, candidateFields) {
|
|
|
229
230
|
const segments = parts.slice(2);
|
|
230
231
|
const resolved = [];
|
|
231
232
|
for (let i = 0; i < segments.length; i++) {
|
|
232
|
-
const segment = segments[i]
|
|
233
|
+
const segment = stripSliceSuffix(segments[i] ?? '');
|
|
233
234
|
const choice = /^(\w+)\[x\]$/.exec(segment);
|
|
234
235
|
if (!choice) {
|
|
235
236
|
resolved.push(segment);
|
package/out/src/main.js
CHANGED
|
@@ -57,6 +57,7 @@ function printUsage() {
|
|
|
57
57
|
console.log(" --no-classes Only generate interfaces and types (skip class generation)");
|
|
58
58
|
console.log(" --no-client Skip FHIR client generation (client generated by default)");
|
|
59
59
|
console.log(" --schema <format> Generate schema files alongside outputs (supported: zod)");
|
|
60
|
+
console.log(" --zod-invariants Emit FHIRPath invariants into Zod schemas (fuller checking, slower parse)");
|
|
60
61
|
console.log(" --dicomweb Generate DICOMweb helpers typed to ImagingStudy profiles in the IG");
|
|
61
62
|
console.log(" --prefab Generate Prefab UI render functions per profile (@maxhealth.tech/prefab)");
|
|
62
63
|
console.log(" --prefab-styles <path> Path to styles module (.ts/.js) copied into the generated prefab/ folder");
|
|
@@ -366,6 +367,10 @@ export function parseCliArgs(argv) {
|
|
|
366
367
|
i++;
|
|
367
368
|
continue;
|
|
368
369
|
}
|
|
370
|
+
if (arg === '--zod-invariants') {
|
|
371
|
+
out.flags.zodInvariants = true;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
369
374
|
if (arg === '--fhir-version') {
|
|
370
375
|
const val = argv[i + 1];
|
|
371
376
|
if (!val || val.startsWith('-'))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "babelfhir-ts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.1",
|
|
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,24 +86,24 @@
|
|
|
86
86
|
},
|
|
87
87
|
"devDependencies": {
|
|
88
88
|
"@eslint/js": "^10.0.1",
|
|
89
|
-
"@max-health/config": "
|
|
90
|
-
"@maxhealth.tech/prefab": "^0.3.
|
|
89
|
+
"@max-health-inc/config": "^3.1.0",
|
|
90
|
+
"@maxhealth.tech/prefab": "^0.3.7",
|
|
91
91
|
"@types/node": "^25.9.5",
|
|
92
|
-
"@types/semver": "^7.
|
|
92
|
+
"@types/semver": "^7.8.0",
|
|
93
93
|
"@types/unzipper": "^0.10.11",
|
|
94
94
|
"@vitest/coverage-v8": "^4.1.10",
|
|
95
|
-
"@vitest/eslint-plugin": "^1.6.
|
|
95
|
+
"@vitest/eslint-plugin": "^1.6.26",
|
|
96
96
|
"@vitest/ui": "^4.1.10",
|
|
97
97
|
"cross-env": "^10.1.0",
|
|
98
98
|
"eslint": "^10.8.0",
|
|
99
|
-
"fhirpath": "^5.
|
|
100
|
-
"globals": "^17.
|
|
99
|
+
"fhirpath": "^5.1.0",
|
|
100
|
+
"globals": "^17.9.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.66.0",
|
|
105
105
|
"vitepress": "^1.6.4",
|
|
106
|
-
"vitepress-plugin-llms": "^1.13.
|
|
106
|
+
"vitepress-plugin-llms": "^1.13.4",
|
|
107
107
|
"vitest": "^4.1.10",
|
|
108
108
|
"zod": "4.3.6"
|
|
109
109
|
},
|
|
@@ -111,7 +111,10 @@
|
|
|
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.7",
|
|
115
115
|
"unzipper": "^0.12.5"
|
|
116
|
+
},
|
|
117
|
+
"overrides": {
|
|
118
|
+
"brace-expansion": "^5.0.9"
|
|
116
119
|
}
|
|
117
120
|
}
|