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.
Files changed (57) hide show
  1. package/README.md +6 -3
  2. package/out/src/generator/core/constants.js +2 -1
  3. package/out/src/generator/core/extensionSdReader.js +4 -1
  4. package/out/src/generator/core/utils.js +91 -9
  5. package/out/src/generator/emitters/class/classGenerator.js +14 -8
  6. package/out/src/generator/emitters/class/classGeneratorHelpers.js +22 -9
  7. package/out/src/generator/emitters/class/classHeuristics.js +4 -2
  8. package/out/src/generator/emitters/class/fieldPatternExtractor.js +17 -13
  9. package/out/src/generator/emitters/class/sliceElementDefaults.js +21 -6
  10. package/out/src/generator/emitters/class/sliceElementGenerator.js +3 -3
  11. package/out/src/generator/emitters/interface/backboneSliceTyping.js +21 -13
  12. package/out/src/generator/emitters/interface/importManager.js +1 -1
  13. package/out/src/generator/emitters/interface/interfaceFieldProcessor.js +15 -12
  14. package/out/src/generator/emitters/interface/interfaceFieldUtils.js +6 -3
  15. package/out/src/generator/emitters/interface/interfaceGenerator.js +14 -9
  16. package/out/src/generator/emitters/interface/postProcessExtensions.js +32 -16
  17. package/out/src/generator/emitters/interface/processFlatField.js +9 -7
  18. package/out/src/generator/emitters/interface/processNestedField.js +9 -10
  19. package/out/src/generator/emitters/namingsystem/namingSystemGenerator.js +136 -0
  20. package/out/src/generator/emitters/prefab/prefabEmitter.js +2 -15
  21. package/out/src/generator/emitters/prefab/prefabRenderer.js +2 -19
  22. package/out/src/generator/emitters/prefab/prefabTypeMapper.js +2 -0
  23. package/out/src/generator/emitters/validator/closedSlicingValidation.js +5 -2
  24. package/out/src/generator/emitters/validator/fhirpathStubInstaller.js +7 -1
  25. package/out/src/generator/emitters/validator/sliceBackboneValidation.js +27 -12
  26. package/out/src/generator/emitters/validator/sliceDelegation.js +8 -6
  27. package/out/src/generator/emitters/validator/sliceExtensionValidation.js +3 -3
  28. package/out/src/generator/emitters/validator/sliceValidatorGenerator.js +41 -41
  29. package/out/src/generator/emitters/validator/sliceValidatorUtils.js +8 -3
  30. package/out/src/generator/emitters/validator/subExtensionScope.js +10 -2
  31. package/out/src/generator/emitters/validator/validatorBindingBuilder.js +4 -3
  32. package/out/src/generator/emitters/validator/validatorBindingLeafEmitters.js +5 -5
  33. package/out/src/generator/emitters/validator/validatorConstraintBuilders.js +8 -6
  34. package/out/src/generator/emitters/validator/validatorExpressions.js +44 -0
  35. package/out/src/generator/emitters/validator/validatorFieldBuilders.js +34 -17
  36. package/out/src/generator/emitters/validator/validatorGenerator.js +43 -47
  37. package/out/src/generator/emitters/validator/validatorRuntime.js +13 -2
  38. package/out/src/generator/emitters/validator/validatorTemplates.js +4 -1
  39. package/out/src/generator/emitters/valueset/valueSetGenerator.js +6 -10
  40. package/out/src/generator/emitters/zod/zodRefinementBuilder.js +48 -8
  41. package/out/src/generator/emitters/zod/zodSchemaGenerator.js +51 -24
  42. package/out/src/generator/fhir/corePackageResolver.js +2 -3
  43. package/out/src/generator/fhir/corePackageResolver.ts +2 -3
  44. package/out/src/generator/generationHelpers.js +59 -7
  45. package/out/src/generator/index.js +94 -49
  46. package/out/src/generator/parser/packageManager.js +4 -4
  47. package/out/src/generator/parser/packageParser.js +116 -64
  48. package/out/src/generator/parser/sdFetcher.js +5 -3
  49. package/out/src/generator/parser/sdParser.js +1 -1
  50. package/out/src/generator/parser/sliceAggregator.js +13 -10
  51. package/out/src/generator/parser/sliceAggregatorComposition.js +11 -2
  52. package/out/src/generator/parser/vsParser.js +6 -3
  53. package/out/src/generator/sdProcessor.js +30 -21
  54. package/out/src/generator/sdProcessorRequiredFields.js +3 -2
  55. package/out/src/main.js +5 -0
  56. package/package.json +13 -10
  57. 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
- // Apply compose.exclude — remove specific codes
140
- if (content.compose.exclude && Array.isArray(content.compose.exclude)) {
141
- for (const excl of content.compose.exclude) {
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 (${bucket.length} codes)`);
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
- if (versions.length > 0) {
244
- const slug = FHIR_VERSION_TO_SLUG[versions[0]];
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 ${versions[0]} (${slug}) from ImplementationGuide`);
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
- // Apply compose.exclude — remove codes that should be excluded
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 (bucket.length) {
492
- map[canonical] = bucket;
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 [baseUrl, version] = url.split('|');
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.split('|')[0];
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.split('|')[0];
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.split('|')[0];
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 ? match[1] : idToUse;
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.split('|')[0];
273
+ const bare = stripVersionFromCanonicalUrl(bindingUri);
271
274
  const codes = valueSetCodesMap[bare] ?? valueSetCodesMap[bindingUri];
272
- if (codes?.length) {
273
- resolvedDefault = { code: codes[0].code, system: codes[0].system };
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
- if (slice.profileUrls && slice.profileUrls.length > 0) {
319
- const extProfileUrl = slice.profileUrls[0];
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
- if (valueElem?.type?.length) {
337
- const vt = typeof valueElem.type[0] === 'string'
338
- ? valueElem.type[0]
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
- case 'Period': return { start: new Date().toISOString() };
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.split('|')[0];
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].system;
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.split('|')[0].split('/').pop();
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.split('|')[0]) {
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
- current = resolveComplexTypeChildType(current, segments[i]);
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
- if (!f.sliceName || !f.profileUrls?.length)
199
+ const firstProfile = f.sliceName ? f.profileUrls?.[0] : undefined;
200
+ if (!firstProfile)
197
201
  continue;
198
- const basePath = (f.name || '').replace(/^[^.]+\./, '').split(':')[0];
202
+ const basePath = stripSliceSuffix((f.name || '').replace(/^[^.]+\./, ''));
199
203
  if (basePath.endsWith('extension') || basePath.endsWith('modifierExtension')) {
200
- extUrls.add(f.profileUrls[0]);
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
- if (!binding && types.length === 1) {
246
- const suffix = types[0].charAt(0).toUpperCase() + types[0].slice(1);
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
- if (!f.sliceName || !f.profileUrls?.length)
282
+ const firstProfile = f.sliceName ? f.profileUrls?.[0] : undefined;
283
+ if (!firstProfile)
278
284
  continue;
279
- const basePath = (f.name || '').replace(/^[^.]+\./, '').split(':')[0];
285
+ const basePath = stripSliceSuffix((f.name || '').replace(/^[^.]+\./, ''));
280
286
  if (basePath.endsWith('extension') || basePath.endsWith('modifierExtension'))
281
287
  continue;
282
- urls.add(f.profileUrls[0]);
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 parentFieldName = parts[1].split(':')[0];
518
- let relativePath = parts.slice(2).map((p) => p.split(':')[0]).join('.');
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
- if (parentField?.type) {
570
- const childName = parts[parts.length - 1].split(':')[0];
571
- fieldType = resolveComplexTypeChildType(parentField.type, childName);
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
- if (match) {
644
- const candidate = match[1];
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 bm = tp.match(/\/([^/]+)$/);
655
- if (bm && knownResourceNames.includes(bm[1])) {
656
- rf.targetResourceType = bm[1];
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).split(':')[0],
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].split(':')[0];
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.5.21",
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": "github:max-health-inc/config",
90
- "@maxhealth.tech/prefab": "^0.3.5",
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.7.1",
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.24",
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.0.0",
100
- "globals": "^17.7.0",
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.65.0",
104
+ "typescript-eslint": "^8.66.0",
105
105
  "vitepress": "^1.6.4",
106
- "vitepress-plugin-llms": "^1.13.3",
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.1",
114
+ "tsx": "^4.23.7",
115
115
  "unzipper": "^0.12.5"
116
+ },
117
+ "overrides": {
118
+ "brace-expansion": "^5.0.9"
116
119
  }
117
120
  }