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
package/README.md CHANGED
@@ -61,9 +61,9 @@ Every push to `develop` and `main` runs the parity pipeline, which validates gen
61
61
  4. Generates `empty()` and `random()` test resources for every profile
62
62
  5. Validates those resources against two independent external FHIR validators
63
63
 
64
- ### Validated Implementation Guides (30)
64
+ ### Validated Implementation Guides (31)
65
65
 
66
- Every parity run validates **30 real-world IGs** (28 R4, 2 R5). The list is not duplicated here — it is generated from the parity suite's own source of truth into [`parity-matrix.json`](./parity-matrix.json).
66
+ Every parity run validates **31 real-world IGs** (29 R4, 2 R5). The list is not duplicated here — it is generated from the parity suite's own source of truth into [`parity-matrix.json`](./parity-matrix.json).
67
67
 
68
68
  Each validated IG is also published as a ready-to-install package — `@max-health-inc/fhir-<name>` — so you do not have to generate it yourself:
69
69
 
@@ -111,6 +111,7 @@ The `firely` job validates generated resources using the [Firely SDK Validator](
111
111
  ![AU Core (Australia)](https://img.shields.io/endpoint?url=https://max-health-inc.github.io/BabelFHIR-TS/badges/badge-au-core.json)
112
112
  ![PIXm](https://img.shields.io/endpoint?url=https://max-health-inc.github.io/BabelFHIR-TS/badges/badge-pixm.json)
113
113
  ![MHD](https://img.shields.io/endpoint?url=https://max-health-inc.github.io/BabelFHIR-TS/badges/badge-mhd.json)
114
+ ![vitals](https://img.shields.io/endpoint?url=https://max-health-inc.github.io/BabelFHIR-TS/badges/badge-vitals.json)
114
115
 
115
116
  </details>
116
117
 
@@ -148,10 +149,11 @@ The `hl7` job validates the same artifacts using the [official HL7 FHIR Validato
148
149
  ![AU Core (Australia)](https://img.shields.io/endpoint?url=https://max-health-inc.github.io/BabelFHIR-TS/badges/badge-hl7-au-core.json)
149
150
  ![PIXm](https://img.shields.io/endpoint?url=https://max-health-inc.github.io/BabelFHIR-TS/badges/badge-hl7-pixm.json)
150
151
  ![MHD](https://img.shields.io/endpoint?url=https://max-health-inc.github.io/BabelFHIR-TS/badges/badge-hl7-mhd.json)
152
+ ![vitals](https://img.shields.io/endpoint?url=https://max-health-inc.github.io/BabelFHIR-TS/badges/badge-hl7-vitals.json)
151
153
 
152
154
  </details>
153
155
 
154
- > External-validator badges cover the 28 R4/R4B IGs. R5 packages are generated and typechecked, but neither external validator publishes R5 badges yet.
156
+ > External-validator badges cover the 29 R4/R4B IGs. R5 packages are generated and typechecked, but neither external validator publishes R5 badges yet.
155
157
 
156
158
  > Terminology validation requires a tx server. The pipeline uses `--tx-server https://tx.fhir.org/{r4|r5}` (matching the package's FHIR version) during generation to expand ValueSets and produce valid codes.
157
159
 
@@ -223,6 +225,7 @@ Options:
223
225
  --no-classes Only generate interfaces and types (skip class generation)
224
226
  --no-client Skip FHIR client generation (client generated by default)
225
227
  --schema <format> Generate schema files alongside outputs (supported: zod)
228
+ --zod-invariants Emit FHIRPath invariants into Zod schemas (fuller checking, slower parse)
226
229
  --dicomweb Generate DICOMweb helpers typed to ImagingStudy profiles in the IG
227
230
  --prefab Generate Prefab UI render functions per profile (@maxhealth.tech/prefab)
228
231
  --prefab-styles <path> Path to styles module (.ts/.js) copied into the generated prefab/ folder
@@ -6,6 +6,7 @@
6
6
  * maintenance trivial.
7
7
  */
8
8
  import { createRequire } from 'module';
9
+ import { stripVersionFromCanonicalUrl } from './utils.js';
9
10
  import { fileURLToPath } from 'url';
10
11
  import path from 'path';
11
12
  const require = createRequire(import.meta.url);
@@ -244,7 +245,7 @@ export const VALUESET_DEFAULTS = {
244
245
  */
245
246
  export function resolveValueSetDefault(bindingUri) {
246
247
  // Strip version suffix: "http://...ValueSet/foo|4.0.1" → "http://...ValueSet/foo"
247
- const bare = bindingUri.split('|')[0];
248
+ const bare = stripVersionFromCanonicalUrl(bindingUri);
248
249
  return VALUESET_DEFAULTS[bare];
249
250
  }
250
251
  export const FALLBACK_CODES = {
@@ -9,6 +9,7 @@
9
9
  * binding, while KBV_EX_ERP_PracticeSupply_Payor binds it required to
10
10
  * KBV_VS_FOR_Payor_type.
11
11
  */
12
+ import { stripVersionFromCanonicalUrl } from './utils.js';
12
13
  /** `Extension.extension:<name>` with no further path below it. */
13
14
  const SUB_SLICE_ID = /^Extension\.extension:([^.:]+)$/;
14
15
  /**
@@ -32,7 +33,7 @@ export function readBinding(el) {
32
33
  : binding.valueSet?.reference;
33
34
  if (!raw)
34
35
  return undefined;
35
- return { strength: binding.strength, uri: raw.split('|')[0] };
36
+ return { strength: binding.strength, uri: stripVersionFromCanonicalUrl(raw) };
36
37
  }
37
38
  /** Type codes declared on an element. */
38
39
  function typeCodes(el) {
@@ -69,6 +70,8 @@ export function collectSubExtensionSlices(els) {
69
70
  if (!match)
70
71
  continue;
71
72
  const sliceName = el.sliceName ?? match[1];
73
+ if (!sliceName)
74
+ continue;
72
75
  const urlEl = byId.get(`Extension.extension:${sliceName}.url`);
73
76
  const url = typeof urlEl?.fixedUri === 'string' ? urlEl.fixedUri : undefined;
74
77
  if (!url)
@@ -14,6 +14,43 @@ export function stripVersionFromCanonicalUrl(url) {
14
14
  const pipeIndex = url.indexOf('|');
15
15
  return pipeIndex >= 0 ? url.substring(0, pipeIndex) : url;
16
16
  }
17
+ /**
18
+ * An element path with any slice suffix removed: `identifier:NPI` -> `identifier`.
19
+ *
20
+ * FHIR element ids name the slice after a colon, and most path comparisons want
21
+ * the unsliced form. Written with indexOf rather than `split(':')[0]` so it
22
+ * returns a string: the split form is an indexing expression, which is only
23
+ * `string | undefined` to the compiler even though it can never be empty.
24
+ */
25
+ export function stripSliceSuffix(path) {
26
+ if (!path)
27
+ return path;
28
+ const colonIndex = path.indexOf(':');
29
+ return colonIndex >= 0 ? path.substring(0, colonIndex) : path;
30
+ }
31
+ /**
32
+ * The slice name after the colon: `extension:Stadtteil` -> `Stadtteil`.
33
+ *
34
+ * Undefined when the segment carries no slice, which is the distinction callers
35
+ * act on — the counterpart to {@link stripSliceSuffix}, which returns the base.
36
+ */
37
+ export function sliceSuffix(segment) {
38
+ const colonIndex = segment.indexOf(':');
39
+ return colonIndex >= 0 ? segment.substring(colonIndex + 1) : undefined;
40
+ }
41
+ /**
42
+ * The first dot-separated segment of a path: `code.coding.system` -> `code`.
43
+ *
44
+ * indexOf rather than `split('.')[0]`, for the same reason as
45
+ * {@link stripSliceSuffix}: the split form is an indexing expression and reads as
46
+ * possibly-undefined even when it cannot be.
47
+ */
48
+ export function firstSegment(path) {
49
+ if (!path)
50
+ return path;
51
+ const dotIndex = path.indexOf('.');
52
+ return dotIndex >= 0 ? path.substring(0, dotIndex) : path;
53
+ }
17
54
  export function writeInterfaceAndValidatorToFile(outputDir, interfaceName, interfaceContent, validateFunctionContent) {
18
55
  const filePath = path.join(outputDir, `${interfaceName}.ts`);
19
56
  const content = `${interfaceContent}\n\n${validateFunctionContent}`;
@@ -96,7 +133,9 @@ export function getResourceTypes(options) {
96
133
  const exportRegex = /export\s+(?:\*|{[^}]+})\s+from\s+['"]\.\/([\w-]+)\.js['"]/g;
97
134
  let match;
98
135
  while ((match = exportRegex.exec(indexContent)) !== null) {
99
- exportedTypes.add(match[1]);
136
+ const moduleName = match[1];
137
+ if (moduleName)
138
+ exportedTypes.add(moduleName);
100
139
  }
101
140
  }
102
141
  for (const file of files) {
@@ -109,9 +148,9 @@ export function getResourceTypes(options) {
109
148
  if (actualFile) {
110
149
  const content = fs.readFileSync(actualFile, "utf-8");
111
150
  // Check if it has resourceType property (base resource)
112
- const resourceTypeMatch = content.match(/resourceType:\s*["'](\w+)["']/);
113
- if (resourceTypeMatch) {
114
- resourceTypes.set(profileName, resourceTypeMatch[1]);
151
+ const declaredResourceType = content.match(/resourceType:\s*["'](\w+)["']/)?.[1];
152
+ if (declaredResourceType) {
153
+ resourceTypes.set(profileName, declaredResourceType);
115
154
  continue;
116
155
  }
117
156
  // Check if it extends a FHIR resource (handles both `extends Foo` and `extends Omit<Foo, ...>`)
@@ -136,15 +175,16 @@ export function getBaseResourceType(profileName, outputDir) {
136
175
  }
137
176
  const content = fs.readFileSync(actualFile, "utf-8");
138
177
  // Check for explicit resourceType property
139
- const resourceTypeMatch = content.match(/resourceType:\s*["'](\w+)["']/);
140
- if (resourceTypeMatch) {
141
- return resourceTypeMatch[1];
178
+ const declaredResourceType = content.match(/resourceType:\s*["'](\w+)["']/)?.[1];
179
+ if (declaredResourceType) {
180
+ return declaredResourceType;
142
181
  }
143
182
  // Check for extends clause (handles both `extends Foo` and `extends Omit<Foo, ...>`)
144
183
  const baseTypes = extractBaseTypes(content);
145
- if (baseTypes.length > 0) {
184
+ const [firstBase] = baseTypes;
185
+ if (firstBase) {
146
186
  // Prefer a known resource type, fall back to the first candidate
147
- return baseTypes.find(bt => ctx().resourceNames.includes(bt)) ?? baseTypes[0];
187
+ return baseTypes.find(bt => ctx().resourceNames.includes(bt)) ?? firstBase;
148
188
  }
149
189
  return profileName; // fallback
150
190
  }
@@ -331,6 +371,48 @@ export function sanitizeIdentifier(name) {
331
371
  cleaned = `_${cleaned}`;
332
372
  return cleaned;
333
373
  }
374
+ /**
375
+ * Turn a FHIR resource name into a PascalCase TypeScript identifier.
376
+ *
377
+ * Shared by the ValueSet and NamingSystem emitters, which previously carried
378
+ * the same split-and-capitalize loop with only the digit prefix differing.
379
+ * {@link toPascalCase} alone is not enough: it drops the underscore that
380
+ * {@link sanitizeIdentifier} adds, so a name starting with a digit comes back
381
+ * as an invalid identifier.
382
+ */
383
+ export function pascalCaseIdentifier(name, opts) {
384
+ const stripped = opts.stripPrefix ? name.replace(opts.stripPrefix, '') : name;
385
+ const pascal = toPascalCase(stripped);
386
+ if (!pascal)
387
+ return opts.fallback;
388
+ return /^[0-9]/.test(pascal) ? `${opts.digitPrefix}${pascal}` : pascal;
389
+ }
390
+ /**
391
+ * Lower-case a name's first character, keeping a leading acronym run intact.
392
+ *
393
+ * "CHCorePatient" → "chCorePatient" (the acronym lowers wholesale, but the
394
+ * last capital starts the next word)
395
+ * "Patient" → "patient"
396
+ * "HumanName" → "humanName"
397
+ *
398
+ * Shared by the prefab renderer and the MCP view registry, which each carried a
399
+ * byte-identical copy — the second one commented "Mirrors the same logic in
400
+ * prefabRenderer.ts", which is the drift warning rather than a defence.
401
+ *
402
+ * charAt() is deliberate over indexing: it yields '' past the end, so the
403
+ * acronym run terminates at the boundary without an index assertion.
404
+ */
405
+ export function lowerFirstPreservingAcronym(name) {
406
+ if (!name)
407
+ return name;
408
+ let i = 0;
409
+ while (i < name.length && name.charAt(i) >= 'A' && name.charAt(i) <= 'Z')
410
+ i++;
411
+ if (i <= 1)
412
+ return name.charAt(0).toLowerCase() + name.slice(1);
413
+ const acronymEnd = i === name.length ? i : i - 1;
414
+ return name.slice(0, acronymEnd).toLowerCase() + name.slice(acronymEnd);
415
+ }
334
416
  export function toPascalCase(name) {
335
417
  if (!name)
336
418
  return name;
@@ -1,4 +1,5 @@
1
1
  import { ctx } from '../../fhir/versionContext.js';
2
+ import { stripVersionFromCanonicalUrl } from '../../core/utils.js';
2
3
  import { TS, } from '../../core/constants.js';
3
4
  import { primitivePlaceholder, generateCodeDefault, makeCodeableConcept, chooseChoiceVariant, applyRequiredFieldHeuristic, deriveIdentifierConfig, } from './classHeuristics.js';
4
5
  import { generateSliceElement, buildNestedRequirementsObject } from './sliceElementGenerator.js';
@@ -187,7 +188,11 @@ export function generateClass(className, interfaceName, baseResource, requiredFi
187
188
  .filter((s) => typeof s === 'string' && s.length > 0);
188
189
  const uniqueSystems = Array.from(new Set(systems));
189
190
  const singleSystem = uniqueSystems.length === 1 ? JSON.stringify(uniqueSystems[0]) : undefined;
191
+ // bindingCodes is non-empty per the guard above, but indexing does not say
192
+ // so; without a code there is no skeleton value to emit.
190
193
  const primary = rf.bindingCodes[0];
194
+ if (!primary)
195
+ continue;
191
196
  if (rf.type === 'CodeableConcept') {
192
197
  const expr = singleSystem
193
198
  ? `skeletonCodeableConcept(${singleSystem}, ${codesArray})`
@@ -349,8 +354,8 @@ export function generateClass(className, interfaceName, baseResource, requiredFi
349
354
  }
350
355
  }
351
356
  // Check profile URLs for known coding profiles
352
- if (slice.profileUrls && slice.profileUrls.length > 0) {
353
- const profileUrl = slice.profileUrls[0];
357
+ const profileUrl = slice.profileUrls?.[0];
358
+ if (profileUrl) {
354
359
  const profileUrlLower = profileUrl.toLowerCase();
355
360
  // ISiK SNOMED-CT coding profile
356
361
  if (profileUrlLower.includes('snomedctcoding') || profileUrlLower.includes('snomed')) {
@@ -376,7 +381,7 @@ export function generateClass(className, interfaceName, baseResource, requiredFi
376
381
  // code from a different context and should be deferred to runtime resolution.
377
382
  const sliceHasOwnBinding = !!(slice.binding?.uri && slice.binding.uri.length > 0);
378
383
  const sliceBindingMatchesParent = !rf.bindingUri ||
379
- (sliceHasOwnBinding && slice.binding.uri.split('|')[0] === rf.bindingUri.split('|')[0]);
384
+ (sliceHasOwnBinding && stripVersionFromCanonicalUrl(slice.binding.uri) === stripVersionFromCanonicalUrl(rf.bindingUri));
380
385
  if (sliceBindingMatchesParent && slice.binding?.sampleCode) {
381
386
  if (slice.binding.sampleCode.system) {
382
387
  system = slice.binding.sampleCode.system;
@@ -384,7 +389,7 @@ export function generateClass(className, interfaceName, baseResource, requiredFi
384
389
  code = JSON.stringify(slice.binding.sampleCode.code);
385
390
  codeResolved = true;
386
391
  }
387
- else if (sliceBindingMatchesParent && slice.binding?.codes && slice.binding.codes.length > 0) {
392
+ else if (sliceBindingMatchesParent && slice.binding?.codes?.[0]) {
388
393
  // Fallback to first code from the binding's codes array
389
394
  const firstCode = slice.binding.codes[0];
390
395
  if (firstCode.system) {
@@ -458,8 +463,8 @@ export function generateClass(className, interfaceName, baseResource, requiredFi
458
463
  // Handle CodeableConcept with pattern constraint - use the pattern instead of skeleton
459
464
  if (rf.type === 'CodeableConcept' && rf.patternConstraint && typeof rf.patternConstraint === 'object') {
460
465
  const pc = rf.patternConstraint;
461
- if (pc.coding && Array.isArray(pc.coding) && pc.coding.length > 0) {
462
- const firstCoding = pc.coding[0];
466
+ const firstCoding = Array.isArray(pc.coding) ? pc.coding[0] : undefined;
467
+ if (firstCoding) {
463
468
  const systemStr = JSON.stringify(firstCoding.system || TS.NULL_FLAVOR);
464
469
  const codeStr = JSON.stringify(firstCoding.code || 'UNK');
465
470
  const displayStr = firstCoding.display ? `, ${JSON.stringify(firstCoding.display)}` : '';
@@ -522,8 +527,9 @@ export function generateClass(className, interfaceName, baseResource, requiredFi
522
527
  }
523
528
  // Complex type skeletons (also check baseType for profiled types like CodeableConcept-uv-ips)
524
529
  const skeletonType = COMPLEX_SKELETON_MAP[rf.type] ? rf.type : (rf.baseType && COMPLEX_SKELETON_MAP[rf.baseType] ? rf.baseType : undefined);
525
- if (skeletonType) {
526
- const expr = COMPLEX_SKELETON_MAP[skeletonType];
530
+ const skeletonExpr = skeletonType ? COMPLEX_SKELETON_MAP[skeletonType] : undefined;
531
+ if (skeletonType && skeletonExpr) {
532
+ const expr = skeletonExpr;
527
533
  const initExpr = buildValueExpression(rf, expr);
528
534
  requiredInits.push(`${propKey(rf.name)}: ${initExpr}`);
529
535
  continue;
@@ -61,15 +61,14 @@ export function buildValueExpression(rf, expr, opts) {
61
61
  export function buildCrossReferencedEntryArray(sliceElements) {
62
62
  const compositionIdx = [];
63
63
  const resourceEntries = [];
64
- for (let i = 0; i < sliceElements.length; i++) {
65
- const expr = sliceElements[i];
64
+ for (const [i, expr] of sliceElements.entries()) {
66
65
  if (/resourceType:\s*'Composition'/.test(expr)) {
67
66
  compositionIdx.push(i);
68
67
  }
69
68
  else {
70
- const m = expr.match(/resourceType:\s*'(\w+)'/);
71
- if (m)
72
- resourceEntries.push({ idx: i, type: m[1] });
69
+ const type = expr.match(/resourceType:\s*'(\w+)'/)?.[1];
70
+ if (type)
71
+ resourceEntries.push({ idx: i, type });
73
72
  }
74
73
  }
75
74
  if (compositionIdx.length === 0 || resourceEntries.length === 0)
@@ -79,10 +78,16 @@ export function buildCrossReferencedEntryArray(sliceElements) {
79
78
  for (const { idx, type } of resourceEntries) {
80
79
  const varName = `_${type.charAt(0).toLowerCase() + type.slice(1)}Url`;
81
80
  decls.push(`const ${varName} = 'urn:uuid:' + crypto.randomUUID();`);
82
- rewritten[idx] = rewritten[idx].replace(/fullUrl:\s*'urn:uuid:'\s*\+\s*crypto\.randomUUID\(\)/, `fullUrl: ${varName}`);
81
+ const entry = rewritten[idx];
82
+ if (entry !== undefined) {
83
+ rewritten[idx] = entry.replace(/fullUrl:\s*'urn:uuid:'\s*\+\s*crypto\.randomUUID\(\)/, `fullUrl: ${varName}`);
84
+ }
83
85
  for (const ci of compositionIdx) {
84
86
  const refPattern = new RegExp(`reference:\\s*'${type}/\\s*'\\s*\\+\\s*randomId\\(\\)`);
85
- rewritten[ci] = rewritten[ci].replace(refPattern, `reference: ${varName}`);
87
+ const composition = rewritten[ci];
88
+ if (composition !== undefined) {
89
+ rewritten[ci] = composition.replace(refPattern, `reference: ${varName}`);
90
+ }
86
91
  }
87
92
  }
88
93
  return `(() => { ${decls.join(' ')} return [${rewritten.join(', ')}]; })()`;
@@ -95,8 +100,16 @@ export function generatePatternMetadata(fieldPatterns, forbiddenFields, valueSet
95
100
  }
96
101
  const hasBindings = Object.keys(valueSetBindings).length > 0;
97
102
  return `
98
- /** Pattern constraints for profile fields (used by random() generator) */
99
- protected static readonly _fieldPatterns = ${JSON.stringify(patternMap, null, 2)};
103
+ /**
104
+ * Pattern constraints this profile fixes on its fields, keyed by field path
105
+ * (e.g. \`"shlSession.system": "https://example.org/fhir/shl-session"\`).
106
+ *
107
+ * Public alongside \`valueSetBindings\`: a consumer that needs a fixed value —
108
+ * an identifier system, a fixed code — would otherwise hand-write the literal
109
+ * the profile already declares, which is the duplication this package exists
110
+ * to remove. Read by the random() generator too.
111
+ */
112
+ static readonly _fieldPatterns = ${JSON.stringify(patternMap, null, 2)};
100
113
 
101
114
  /** Fields that are forbidden (max=0) in this profile - must not be generated */
102
115
  protected static readonly _forbiddenFields: string[] = ${JSON.stringify(forbiddenFields)};
@@ -91,6 +91,8 @@ export function chooseChoiceVariant(rf) {
91
91
  return null;
92
92
  }
93
93
  let chosen = options[0];
94
+ if (chosen === undefined)
95
+ return null;
94
96
  if (lowerBase === 'value' && preferCodeableConcept) {
95
97
  chosen = 'CodeableConcept';
96
98
  }
@@ -210,8 +212,8 @@ export function applyRequiredFieldHeuristic(rf, ctx) {
210
212
  return { expr: makeCoding(system, picked.code), isObjectLiteral: true };
211
213
  }
212
214
  }
213
- if (isCodeableConcept) {
214
- const first = rf.bindingCodes[0];
215
+ const [first] = rf.bindingCodes;
216
+ if (isCodeableConcept && first) {
215
217
  const system = first.system || TS.SNOMED;
216
218
  return { expr: `skeletonCodeableConcept('${system}', ['${first.code}'])` };
217
219
  }
@@ -1,4 +1,5 @@
1
1
  import { fetchStructureDefinition } from '../../parser/sdParser.js';
2
+ import { stripSliceSuffix, sliceSuffix } from '../../core/utils.js';
2
3
  export async function extractFieldPatternsAndMetadata(patternSources, baseResource, sd, requiredFields, candidateRequiredFields, nestedRequirementsMap, resolvedProfilePatterns) {
3
4
  const patternMap = new Map();
4
5
  for (const src of patternSources) {
@@ -7,7 +8,7 @@ export async function extractFieldPatternsAndMetadata(patternSources, baseResour
7
8
  continue;
8
9
  // Extract the field path parts
9
10
  const parts = f.name.split('.');
10
- const lastPart = (parts.pop() || f.name).split(':')[0]; // base field name without slice
11
+ const lastPart = stripSliceSuffix(parts.pop() ?? f.name); // base field name without slice
11
12
  // For patterns on '.coding' elements, associate with the parent CodeableConcept field
12
13
  // e.g., for "Coverage.type.coding:VersicherungsArtDeBasis", use "type" as key
13
14
  let key = lastPart;
@@ -15,7 +16,7 @@ export async function extractFieldPatternsAndMetadata(patternSources, baseResour
15
16
  // This is a coding inside a CodeableConcept - use the parent field name
16
17
  const parentPart = parts.pop();
17
18
  if (parentPart) {
18
- key = parentPart.split(':')[0];
19
+ key = stripSliceSuffix(parentPart);
19
20
  }
20
21
  }
21
22
  // For nested fields (children of children, e.g., identifier.type vs top-level type),
@@ -28,8 +29,9 @@ export async function extractFieldPatternsAndMetadata(patternSources, baseResour
28
29
  idParts.shift(); // remove resource type prefix
29
30
  const nestedParts = [];
30
31
  for (const part of idParts) {
31
- if (part.includes(':')) {
32
- nestedParts.push(part.split(':')[1]); // use slice name
32
+ const partSliceName = sliceSuffix(part);
33
+ if (partSliceName !== undefined) {
34
+ nestedParts.push(partSliceName); // use slice name
33
35
  }
34
36
  else if (!part.includes('[x]')) {
35
37
  nestedParts.push(part);
@@ -204,9 +206,10 @@ export async function extractFieldPatternsAndMetadata(patternSources, baseResour
204
206
  // e.g., "value[x]:valueQuantity.unit" -> "valueQuantity.unit"
205
207
  const nestedParts = [];
206
208
  for (const part of idParts) {
207
- if (part.includes(':')) {
208
- // Extract slice name (e.g., "value[x]:valueQuantity" -> "valueQuantity")
209
- nestedParts.push(part.split(':')[1]);
209
+ // Slice name, e.g. "value[x]:valueQuantity" -> "valueQuantity"
210
+ const partSliceName = sliceSuffix(part);
211
+ if (partSliceName !== undefined) {
212
+ nestedParts.push(partSliceName);
210
213
  }
211
214
  else if (!part.includes('[x]')) {
212
215
  nestedParts.push(part);
@@ -235,8 +238,9 @@ export async function extractFieldPatternsAndMetadata(patternSources, baseResour
235
238
  // Build nested key, extracting slice names where present
236
239
  const nestedParts = [];
237
240
  for (const part of idParts) {
238
- if (part.includes(':')) {
239
- nestedParts.push(part.split(':')[1]);
241
+ const partSliceName = sliceSuffix(part);
242
+ if (partSliceName !== undefined) {
243
+ nestedParts.push(partSliceName);
240
244
  }
241
245
  else if (!part.includes('[x]')) {
242
246
  nestedParts.push(part);
@@ -252,7 +256,7 @@ export async function extractFieldPatternsAndMetadata(patternSources, baseResour
252
256
  }
253
257
  }
254
258
  // Otherwise, if this is a slice with binding codes, synthesize a pattern
255
- else if (!patternMap.has(key) && f.sliceName && f.binding && f.binding.codes && f.binding.codes.length > 0) {
259
+ else if (!patternMap.has(key) && f.sliceName && f.binding?.codes?.[0]) {
256
260
  // Create a CodeableConcept pattern from the first binding code
257
261
  const firstCode = f.binding.codes[0];
258
262
  const synthesizedPattern = {
@@ -363,9 +367,9 @@ export async function extractFieldPatternsAndMetadata(patternSources, baseResour
363
367
  const [basePart, sliceName] = p.split(':', 2);
364
368
  // For choice types (e.g., value[x]:valueQuantity), use the slice name
365
369
  // as the actual JS property name
366
- if (basePart.includes('[x]') && sliceName)
370
+ if (basePart?.includes('[x]') && sliceName)
367
371
  return sliceName;
368
- return basePart; // strip slice qualifier for non-choice types
372
+ return basePart ?? p; // strip slice qualifier for non-choice types
369
373
  }
370
374
  return p;
371
375
  });
@@ -413,7 +417,7 @@ export async function extractFieldPatternsAndMetadata(patternSources, baseResour
413
417
  const parts = elemId.split('.');
414
418
  if (parts.length !== 2)
415
419
  continue;
416
- const fieldName = (parts[1] || '').split(':')[0]; // Strip slice names
420
+ const fieldName = stripSliceSuffix(parts[1] || '');
417
421
  if (fieldName && !seenFieldNames.has(fieldName)) {
418
422
  seenFieldNames.add(fieldName);
419
423
  fieldOrder.push(fieldName);
@@ -252,6 +252,13 @@ export function buildNestedRequirementsObject(nestedRequirements) {
252
252
  // Navigate/create nested objects for all but the last part
253
253
  for (let i = 0; i < parts.length - 1; i++) {
254
254
  const part = parts[i];
255
+ // The loop bound guarantees a segment here; if one is ever missing the
256
+ // requirement is abandoned rather than navigated halfway, which is the same
257
+ // course the non-object branch below takes.
258
+ if (part === undefined) {
259
+ skipThis = true;
260
+ break;
261
+ }
255
262
  const isArraySegment = allArraySegments.has(parts.slice(0, i + 1).join('.'));
256
263
  if (!(part in current)) {
257
264
  // Create either an array with one object or a plain object
@@ -290,7 +297,9 @@ export function buildNestedRequirementsObject(nestedRequirements) {
290
297
  continue;
291
298
  }
292
299
  // Set the final value
293
- const lastPart = parts[parts.length - 1];
300
+ const lastPart = parts.at(-1);
301
+ if (lastPart === undefined)
302
+ continue;
294
303
  // If this property already exists and is an object with properties, merge instead of overwrite
295
304
  const existingValue = current[lastPart];
296
305
  const hasExplicitConstraint = req.patternConstraint !== undefined || req.fixedValue !== undefined;
@@ -390,11 +399,17 @@ export function getTypePlaceholder(fhirType) {
390
399
  return '"https://babelfhir.dev"';
391
400
  case 'code':
392
401
  return '"code"';
402
+ // Dates are emitted as a call, not a value. This function returns code that
403
+ // lands in a generated class, so interpolating new Date() here froze the
404
+ // generation-time wall clock into the source: every regeneration produced a
405
+ // different file, and every consumer got the same instant forever. The
406
+ // sibling getDefaultValueForType already defers to randomDate() through its
407
+ // __DATE_EXPR__ placeholder; this now agrees with it.
393
408
  case 'dateTime':
394
409
  case 'instant':
395
- return '"' + new Date().toISOString() + '"';
410
+ return 'randomDate()';
396
411
  case 'date':
397
- return '"' + new Date().toISOString().split('T')[0] + '"';
412
+ return 'randomDate().slice(0, 10)';
398
413
  case 'time':
399
414
  return '"12:00:00"';
400
415
  case 'Reference':
@@ -414,7 +429,7 @@ export function getTypePlaceholder(fhirType) {
414
429
  case 'ContactPoint':
415
430
  return '{ system: "phone", value: "555-1234" }';
416
431
  case 'Period':
417
- return '{ start: "' + new Date().toISOString() + '" }';
432
+ return '{ start: randomDate() }';
418
433
  case 'Attachment':
419
434
  return '{ contentType: "text/plain" }';
420
435
  case 'Narrative':
@@ -438,9 +453,9 @@ export function getTypePlaceholder(fhirType) {
438
453
  case 'Annotation':
439
454
  return '{ text: "Note" }';
440
455
  case 'Signature':
441
- return `{ type: [{ system: "urn:iso-astm:E1762-95:2013", code: "1.2.840.10065.1.12.1.1" }], when: "${new Date().toISOString()}", who: { reference: "Practitioner/example" } }`;
456
+ return `{ type: [{ system: "urn:iso-astm:E1762-95:2013", code: "1.2.840.10065.1.12.1.1" }], when: randomDate(), who: { reference: "Practitioner/example" } }`;
442
457
  case 'Meta':
443
- return '{ lastUpdated: "' + new Date().toISOString() + '" }';
458
+ return '{ lastUpdated: randomDate() }';
444
459
  case 'ContactDetail':
445
460
  return '{ name: "Contact" }';
446
461
  case 'UsageContext':
@@ -102,8 +102,8 @@ export function generateSliceElement(slice, elementType) {
102
102
  // Detect already-resolved choice type variants
103
103
  if (!wasResolvedFromChoiceNotation) {
104
104
  const choiceMatch = childPath.match(/^([a-z]+)(Boolean|Integer|Decimal|DateTime|Date|Time|Instant|String|Uri|Url|Canonical|Base64Binary|Code|Oid|Id|Uuid|Markdown|UnsignedInt|PositiveInt|Coding|CodeableConcept|Quantity|Range|Period|Ratio|SampledData|Attachment|Duration|Distance|Count|Money|Age|Annotation|Signature|HumanName|Address|ContactPoint|Timing|Reference|Identifier|Meta|Dosage|Narrative|Expression|TriggerDefinition|DataRequirement|ParameterDefinition|RelatedArtifact|ContactDetail|UsageContext)$/);
105
- if (choiceMatch) {
106
- const baseName = choiceMatch[1];
105
+ const baseName = choiceMatch?.[1];
106
+ if (baseName) {
107
107
  if (resolvedChoiceBases.has(baseName)) {
108
108
  continue;
109
109
  }
@@ -169,7 +169,7 @@ export function generateSliceElement(slice, elementType) {
169
169
  else if (profileUrl) {
170
170
  const profileName = profileUrl.split('/').pop() || '';
171
171
  if (profileName.includes('-')) {
172
- resourceType = capitalise(profileName.split('-')[0]);
172
+ resourceType = capitalise(profileName.split('-')[0] ?? profileName);
173
173
  }
174
174
  else {
175
175
  resourceType = capitalise(profileName);
@@ -18,7 +18,7 @@
18
18
  * }
19
19
  * ```
20
20
  */
21
- import { capitalize, choicePropertyName, narrowedChoiceType, sanitizeIdentifier } from '../../core/utils.js';
21
+ import { capitalize, choicePropertyName, narrowedChoiceType, sanitizeIdentifier, firstSegment } from '../../core/utils.js';
22
22
  import { getRules } from '../../fhir/versionContext.js';
23
23
  import { logger } from '../../../logger.js';
24
24
  const log = logger.withTag('backbone-slices');
@@ -35,7 +35,11 @@ export function generateBackboneSliceTypes(ctx) {
35
35
  return;
36
36
  for (const group of sliceGroups) {
37
37
  const { parentField, sliceFields, childFields, slicingRules } = group;
38
- const fieldName = parentField.name.split('.').pop();
38
+ const fieldName = parentField.name.split('.').at(-1);
39
+ // A grouped parent always has a dotted path, so this is unreachable; skipping
40
+ // is right if it ever is not, and it replaces a non-null assertion.
41
+ if (!fieldName)
42
+ continue;
39
43
  // Determine the base backbone type for this field
40
44
  let backboneType = inferBackboneType(baseResource, fieldName);
41
45
  if (!backboneType) {
@@ -100,8 +104,9 @@ export function generateBackboneSliceTypes(ctx) {
100
104
  if (isOpen) {
101
105
  unionMembers.push(backboneType);
102
106
  }
103
- const unionType = unionMembers.length === 1
104
- ? `${unionMembers[0]}[]`
107
+ const [soleMember] = unionMembers;
108
+ const unionType = unionMembers.length === 1 && soleMember
109
+ ? `${soleMember}[]`
105
110
  : `(${unionMembers.join(' | ')})[]`;
106
111
  // Find and update the field in the root interface
107
112
  updateRootInterfaceField(interfaces, interfaceName, fieldName, parentField.isOptional, unionType, baseResource);
@@ -181,7 +186,7 @@ function isEligibleBackbonePath(baseType, path, baseResource) {
181
186
  const parts = path.split('.');
182
187
  if (parts.length !== 2)
183
188
  return false;
184
- if (baseResource && parts[0] !== baseResource)
189
+ if (baseResource && firstSegment(path) !== baseResource)
185
190
  return false;
186
191
  return true;
187
192
  }
@@ -224,9 +229,10 @@ function buildSliceInterfaceBody(sliceField, sliceChildren, sliceElementId, _bac
224
229
  const childId = child.elementId || child.name;
225
230
  const relativePath = childId.substring(sliceElementId.length + 1);
226
231
  const match = choiceSlicePattern.exec(relativePath);
227
- if (match) {
232
+ const choiceParent = match?.[1]; // e.g. "value[x]"
233
+ if (choiceParent) {
228
234
  choiceTypeSlices.push(child);
229
- choiceTypeParents.add(match[1]); // e.g. "value[x]"
235
+ choiceTypeParents.add(choiceParent);
230
236
  }
231
237
  }
232
238
  // Collect deeply nested fixed values for object literal synthesis.
@@ -240,7 +246,9 @@ function buildSliceInterfaceBody(sliceField, sliceChildren, sliceElementId, _bac
240
246
  if (relativePath.includes('.')) {
241
247
  if (child.fixedValue !== undefined) {
242
248
  const segments = relativePath.split('.');
243
- const rawPropName = segments[0];
249
+ // firstSegment is segments[0] without the possibly-undefined read; segments
250
+ // is still needed for the tail below.
251
+ const rawPropName = firstSegment(relativePath);
244
252
  const propName = rawPropName.replace(/\[x\]$/, '');
245
253
  // Skip nested values under:
246
254
  // - choice-type elements ([x]) — handled by direct child or sub-slices
@@ -425,8 +433,8 @@ function updateRootInterfaceField(interfaces, interfaceName, fieldName, isOption
425
433
  const newFieldLine = ` ${fieldName}${optMark}: ${unionType};`;
426
434
  // Try to find an existing interface declaration
427
435
  const rootIdx = interfaces.findIndex(i => i.startsWith(`export interface ${interfaceName} `) || i.startsWith(`export interface ${interfaceName}<`));
428
- if (rootIdx >= 0) {
429
- const iface = interfaces[rootIdx];
436
+ const iface = rootIdx >= 0 ? interfaces[rootIdx] : undefined;
437
+ if (iface !== undefined) {
430
438
  const fieldPattern = new RegExp(`^(\\s+)${fieldName}[?]?:\\s*.+;`, 'm');
431
439
  if (fieldPattern.test(iface)) {
432
440
  // Replace existing field line
@@ -443,10 +451,10 @@ function updateRootInterfaceField(interfaces, interfaceName, fieldName, isOption
443
451
  }
444
452
  // Try to find a type alias (e.g., "export type BloodPressure = Observation;")
445
453
  const aliasIdx = interfaces.findIndex(i => i.startsWith(`export type ${interfaceName} =`));
446
- if (aliasIdx >= 0) {
454
+ const alias = aliasIdx >= 0 ? interfaces[aliasIdx] : undefined;
455
+ if (alias !== undefined) {
447
456
  // Convert type alias to interface with the field
448
- const aliasMatch = interfaces[aliasIdx].match(/export type \S+ = (\S+);/);
449
- const baseType = aliasMatch ? aliasMatch[1] : baseResource || '';
457
+ const baseType = alias.match(/export type \S+ = (\S+);/)?.[1] ?? baseResource ?? '';
450
458
  interfaces[aliasIdx] = `export interface ${interfaceName} extends ${baseType} {\n${newFieldLine}\n}`;
451
459
  }
452
460
  }