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
@@ -263,7 +263,7 @@ export class ImportManager {
263
263
  const allFhirTypes = [];
264
264
  let match;
265
265
  while ((match = fhirImportRegex.exec(output)) !== null) {
266
- const types = match[1].split(',').map(t => t.trim()).filter(Boolean);
266
+ const types = (match[1] ?? '').split(',').map(t => t.trim()).filter(Boolean);
267
267
  allFhirTypes.push(...types);
268
268
  }
269
269
  if (allFhirTypes.length === 0) {
@@ -1,4 +1,4 @@
1
- import { capitalize, choicePropertyName, narrowedChoiceType, sanitizeIdentifier, toPascalCase, stripVersionFromCanonicalUrl } from '../../core/utils.js';
1
+ import { capitalize, choicePropertyName, narrowedChoiceType, sanitizeIdentifier, toPascalCase, stripVersionFromCanonicalUrl, stripSliceSuffix } from '../../core/utils.js';
2
2
  import { sanitizeValueSetName } from '../valueset/valueSetGenerator.js';
3
3
  import { logger } from '../../../logger.js';
4
4
  import { getRules } from '../../fhir/versionContext.js';
@@ -45,8 +45,8 @@ export function processFields(ctx, fields, parentInterfaceName, parentFieldType,
45
45
  }
46
46
  // Register external profiles for FHIR datatype profiles (like SimpleQuantity) early
47
47
  // This must happen before any returns to ensure the profile is registered
48
- if (field.isProfiled && field.profileUrls && field.profileUrls.length > 0) {
49
- const profileUrl = field.profileUrls[0];
48
+ const profileUrl = field.isProfiled ? field.profileUrls?.[0] : undefined;
49
+ if (profileUrl) {
50
50
  // Use profileUrlToName to get the actual interface name, fallback to URL-derived name
51
51
  // Strip version suffix from canonical URL (e.g., |5.3.0-ballot-tc1) before extracting name
52
52
  const strippedUrl = stripVersionFromCanonicalUrl(profileUrl);
@@ -61,13 +61,9 @@ export function processFields(ctx, fields, parentInterfaceName, parentFieldType,
61
61
  }
62
62
  }
63
63
  const fieldParts = field.name.split(".");
64
- let fieldName = fieldParts[fieldParts.length - 1];
65
- // Remove slice notation (e.g., "extension:Stadtteil" -> "extension") for property names
66
- // The slice name is preserved in field.sliceName for semantic purposes
67
- const colonIndex = fieldName.indexOf(':');
68
- if (colonIndex !== -1) {
69
- fieldName = fieldName.substring(0, colonIndex);
70
- }
64
+ // Slice notation is dropped for the property name ("extension:Stadtteil"
65
+ // -> "extension"); field.sliceName keeps it for semantic purposes.
66
+ let fieldName = stripSliceSuffix(fieldParts.at(-1) ?? field.name);
71
67
  const isRootInterface = parentInterfaceName === interfaceName;
72
68
  const isNested = isRootInterface ? fieldParts.length > 1 : fieldParts.length >= 1;
73
69
  // When generating a slice-specific interface, only include direct children
@@ -126,7 +122,7 @@ export function processFields(ctx, fields, parentInterfaceName, parentFieldType,
126
122
  // (2 segments: Identifier.system) and skip nested paths (4+ segments: Identifier.assigner.identifier.system)
127
123
  if (isNested && fieldParts.length >= 2) {
128
124
  const baseTypeName = fieldParts[0]; // e.g., "Identifier" from "Identifier.assigner.identifier.system"
129
- const isExtendingBaseType = baseResource && baseTypeName.toLowerCase() === baseResource.toLowerCase();
125
+ const isExtendingBaseType = !!baseResource && baseTypeName?.toLowerCase() === baseResource.toLowerCase();
130
126
  // Check if this field will create a nested interface (has children in fields)
131
127
  const hasChildren = fields.some((f) => f.name.startsWith(field.name + '.'));
132
128
  const shouldKeepForNested = field.mustSupport && hasChildren;
@@ -173,7 +169,8 @@ export function processFields(ctx, fields, parentInterfaceName, parentFieldType,
173
169
  }
174
170
  else if (!isLogicalModel) {
175
171
  // For other profiles, use the original logic
176
- if (!parentInterfaceName.toLowerCase().includes(fieldParts[fieldParts.length - 2].toLowerCase())) {
172
+ const parentSegment = fieldParts.at(-2);
173
+ if (!parentSegment || !parentInterfaceName.toLowerCase().includes(parentSegment.toLowerCase())) {
177
174
  return; // Skip nested fields that don't match the parent interface name
178
175
  }
179
176
  }
@@ -529,7 +526,13 @@ export function processFields(ctx, fields, parentInterfaceName, parentFieldType,
529
526
  deduped.push(line); // JSDoc or other non-field lines
530
527
  continue;
531
528
  }
529
+ // Group 1 is present whenever the regex matched, but the type does not
530
+ // say so; a line without it is passed through rather than deduped.
532
531
  const name = fieldMatch[1];
532
+ if (!name) {
533
+ deduped.push(line);
534
+ continue;
535
+ }
533
536
  if (seenFieldNames.has(name)) {
534
537
  continue; // Skip duplicate — keep the first (more specific) version
535
538
  }
@@ -26,11 +26,14 @@ export function canonicalToTypeName(name, profileUrlToName) {
26
26
  matches.add(generatedName);
27
27
  }
28
28
  }
29
- if (matches.size === 1)
30
- return [...matches][0];
29
+ if (matches.size === 1) {
30
+ const [only] = [...matches];
31
+ if (only)
32
+ return only;
33
+ }
31
34
  }
32
35
  // if already a valid identifier with capitalization, return as-is
33
- if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(t) && /[A-Z]/.test(t[0]))
36
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(t) && /^[A-Z]/.test(t))
34
37
  return t;
35
38
  const parts = t.split(/[^A-Za-z0-9]+/).filter(Boolean);
36
39
  let pascal = parts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('');
@@ -61,7 +61,9 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
61
61
  const baseFieldAnyArray = new Map();
62
62
  for (const bf of baseFields) {
63
63
  const parts = bf.name.split('.');
64
- const seg = parts[parts.length - 1];
64
+ const seg = parts.at(-1);
65
+ if (!seg)
66
+ continue;
65
67
  if (!baseFieldByLastSegment.has(seg))
66
68
  baseFieldByLastSegment.set(seg, bf);
67
69
  // Track the widest array flag across direct children only (not nested duplicates)
@@ -83,10 +85,11 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
83
85
  f.sliceName &&
84
86
  f.binding?.uri);
85
87
  // Use the first slice binding if available (slices typically have more specific constraints)
86
- if (slicesForThisField.length > 0 && slicesForThisField[0].binding) {
87
- binding = slicesForThisField[0].binding;
88
+ const firstSlice = slicesForThisField[0];
89
+ if (firstSlice?.binding) {
90
+ binding = firstSlice.binding;
88
91
  isSlicedField = true;
89
- debug(`Using binding from slice ${slicesForThisField[0].sliceName} for ${field.name}`);
92
+ debug(`Using binding from slice ${firstSlice.sliceName} for ${field.name}`);
90
93
  }
91
94
  }
92
95
  if (!binding) {
@@ -196,6 +199,8 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
196
199
  // This must run after all interfaces + extension unions are assembled.
197
200
  for (let i = 0; i < interfaces.length; i++) {
198
201
  const iface = interfaces[i];
202
+ if (!iface)
203
+ continue;
199
204
  // Match "export interface Foo extends Bar {" or "export interface Foo extends Omit<Bar, ...> {"
200
205
  const extendsMatch = iface.match(/^export interface (\S+) extends (Omit<)?(\S+?)([,>])? \{/);
201
206
  if (!extendsMatch)
@@ -203,16 +208,16 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
203
208
  const alreadyOmit = !!extendsMatch[2];
204
209
  const baseType = extendsMatch[3];
205
210
  // Only wrap FHIR base types (not local profile interfaces)
206
- if (!isFhirType(baseType))
211
+ if (!baseType || !isFhirType(baseType))
207
212
  continue;
208
213
  // Extract all declared property names from the interface body
209
214
  const bodyStart = iface.indexOf('{');
210
215
  const body = iface.slice(bodyStart + 1);
211
216
  const propNames = [];
212
217
  for (const line of body.split('\n')) {
213
- const m = line.match(/^\s+(\w+)[?:]/);
214
- if (m && m[1] !== 'resourceType')
215
- propNames.push(m[1]);
218
+ const propName = line.match(/^\s+(\w+)[?:]/)?.[1];
219
+ if (propName && propName !== 'resourceType')
220
+ propNames.push(propName);
216
221
  }
217
222
  if (propNames.length === 0)
218
223
  continue;
@@ -266,7 +271,7 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
266
271
  logger.debug('[DEBUG FINAL INTERFACES]', {
267
272
  interfaceName,
268
273
  interfaceCount: interfaces.length,
269
- interfaceNames: interfaces.map(i => i.split('\n')[0]).filter(n => n.includes('ConditionDeBasis02'))
274
+ interfaceNames: interfaces.map(i => i.split('\n')[0] ?? '').filter(n => n.includes('ConditionDeBasis02'))
270
275
  });
271
276
  }
272
277
  return {
@@ -122,18 +122,22 @@ export function postProcessExtensions(params) {
122
122
  }
123
123
  idx = findInterfaceIndex(name);
124
124
  }
125
- if (idx >= 0 && !interfaces[idx].includes(line.trim())) {
125
+ // Hoisted once: the block below reads the same entry five times, and
126
+ // indexing it each time reads as possibly-undefined even after the bound
127
+ // check. Reads go through `target`, writes still go to interfaces[idx].
128
+ const target = idx >= 0 ? interfaces[idx] : undefined;
129
+ if (target !== undefined && !target.includes(line.trim())) {
126
130
  // Also check if a property with the same name already exists to avoid duplicates
127
- const propName = line.trim().split(/[?:]/)[0].trim();
131
+ const propName = (line.trim().split(/[?:]/)[0] ?? '').trim();
128
132
  const propPattern = new RegExp(`^\\s*${propName}[?:]`, 'm');
129
- if (interfaces[idx].match(propPattern)) {
133
+ if (target.match(propPattern)) {
130
134
  // For extension properties, REPLACE the existing declaration with the new union
131
135
  // This handles both union `(Extension | Type)[]` and plain `Type[]` formats
132
136
  if (propName === 'extension' && line.includes('extension?:')) {
133
137
  const existingExtPattern = /\s*extension\?\s*:\s*[^;\n]+;/;
134
- if (existingExtPattern.test(interfaces[idx])) {
138
+ if (existingExtPattern.test(target)) {
135
139
  const newExtLine = line.trim();
136
- interfaces[idx] = interfaces[idx].replace(existingExtPattern, `\n ${newExtLine}`);
140
+ interfaces[idx] = target.replace(existingExtPattern, `\n ${newExtLine}`);
137
141
  logger.debug('[DEBUG replaced extension property]', { propName, line: newExtLine });
138
142
  return;
139
143
  }
@@ -142,9 +146,9 @@ export function postProcessExtensions(params) {
142
146
  return;
143
147
  }
144
148
  if (name === 'USCoreQuestionnaireResponseProfileItem' && line.includes('answer')) {
145
- logger.debug('[DEBUG appendToInterface Item answer]', { name, line, currentInterface: interfaces[idx] });
149
+ logger.debug('[DEBUG appendToInterface Item answer]', { name, line, currentInterface: target });
146
150
  }
147
- interfaces[idx] = interfaces[idx].replace(/}\s*$/, `\n ${line}\n}`);
151
+ interfaces[idx] = target.replace(/}\s*$/, `\n ${line}\n}`);
148
152
  }
149
153
  };
150
154
  // Ensure root interface exists and track its name
@@ -174,7 +178,9 @@ export function postProcessExtensions(params) {
174
178
  continue;
175
179
  }
176
180
  if (segs.length === 1) {
177
- const seg = segs[0].replace(/\[x\]/g, '');
181
+ const seg = (segs[0] ?? '').replace(/\[x\]/g, '');
182
+ if (!seg)
183
+ continue;
178
184
  const nestedName = `${rootName}${capitalize(seg)}`;
179
185
  // Create alias types and union
180
186
  const aliases = [];
@@ -204,8 +210,9 @@ export function postProcessExtensions(params) {
204
210
  const optionalLine = `_${seg}?: ${elementIface};`;
205
211
  // Replace any required with optional first
206
212
  const idx = findInterfaceIndex(rootName);
207
- if (idx >= 0) {
208
- interfaces[idx] = interfaces[idx].replace(requiredLine, optionalLine);
213
+ const existing = idx >= 0 ? interfaces[idx] : undefined;
214
+ if (existing !== undefined) {
215
+ interfaces[idx] = existing.replace(requiredLine, optionalLine);
209
216
  }
210
217
  appendToInterface(rootName, `_${seg}${opt}: ${elementIface};`);
211
218
  }
@@ -265,6 +272,8 @@ export function postProcessExtensions(params) {
265
272
  }
266
273
  else if (segs.length === 2) {
267
274
  const [seg1Raw, seg2Raw] = segs;
275
+ if (!seg1Raw || !seg2Raw)
276
+ continue;
268
277
  const seg1 = seg1Raw.replace(/\[x\]/g, '');
269
278
  const seg2 = seg2Raw.replace(/\[x\]/g, '');
270
279
  // Ensure first-level container exists
@@ -300,9 +309,15 @@ export function postProcessExtensions(params) {
300
309
  });
301
310
  const baseFld2 = baseFieldByLastSegment.get(seg2);
302
311
  let extendT2 = actualField2?.type || baseFld2?.type;
303
- // Fallback to specific mappings if needed
312
+ // A generic BackboneElement carries none of the base backbone's own properties, so
313
+ // resolve it against the level-1 backbone already resolved above
314
+ // (QuestionnaireResponseItem + 'answer' -> QuestionnaireResponseItemAnswer).
304
315
  if (!extendT2 || extendT2 === 'any' || extendT2 === 'BackboneElement') {
305
- if (baseResource === 'Encounter' && seg1 === 'hospitalization' && seg2 === 'dischargeDisposition') {
316
+ const inferred2 = extendT1 ? inferBackboneType(extendT1, seg2) : undefined;
317
+ if (inferred2) {
318
+ extendT2 = inferred2;
319
+ }
320
+ else if (baseResource === 'Encounter' && seg1 === 'hospitalization' && seg2 === 'dischargeDisposition') {
306
321
  extendT2 = 'CodeableConcept';
307
322
  }
308
323
  }
@@ -383,14 +398,15 @@ export function safetyNetRootExtensions(params) {
383
398
  importManager.addFhirType('Extension');
384
399
  const union = ['Extension', ...aliases].join(' | ');
385
400
  const idx = findInterfaceIndex(interfaceName);
386
- if (idx >= 0) {
401
+ const target = idx >= 0 ? interfaces[idx] : undefined;
402
+ if (target !== undefined) {
387
403
  // Replace any existing extension property (union or plain type) with the new union
388
404
  const existingExtPattern = /\s*extension\?\s*:\s*[^;\n]+;/;
389
- if (existingExtPattern.test(interfaces[idx])) {
390
- interfaces[idx] = interfaces[idx].replace(existingExtPattern, `\n extension?: (${union})[];`);
405
+ if (existingExtPattern.test(target)) {
406
+ interfaces[idx] = target.replace(existingExtPattern, `\n extension?: (${union})[];`);
391
407
  }
392
408
  else {
393
- interfaces[idx] = interfaces[idx].replace(/}\s*$/, `\n extension?: (${union})[];\n}`);
409
+ interfaces[idx] = target.replace(/}\s*$/, `\n extension?: (${union})[];\n}`);
394
410
  }
395
411
  }
396
412
  }
@@ -366,8 +366,9 @@ export function handleFlatField(args) {
366
366
  }
367
367
  else {
368
368
  const idxEl = findInterfaceIndex(elementIface);
369
- if (idxEl >= 0 && !interfaces[idxEl].includes('extension?: (')) {
370
- interfaces[idxEl] = interfaces[idxEl].replace(/\n}\s*$/, `\n ${extLine}\n}`);
369
+ const ifaceEl = idxEl >= 0 ? interfaces[idxEl] : undefined;
370
+ if (ifaceEl !== undefined && !ifaceEl.includes('extension?: (')) {
371
+ interfaces[idxEl] = ifaceEl.replace(/\n}\s*$/, `\n ${extLine}\n}`);
371
372
  }
372
373
  }
373
374
  // Add sidecar property _fieldName?: ElementIface to the current parent's interface lines
@@ -391,8 +392,8 @@ export function handleFlatField(args) {
391
392
  // If a required variant was already written elsewhere, replace it with optional
392
393
  if (writtenLines.has(sidecarLineRequired)) {
393
394
  // replace in existing interfaces
394
- for (let i = 0; i < interfaces.length; i++) {
395
- interfaces[i] = interfaces[i].replace(sidecarLineRequired, sidecarLineOptional);
395
+ for (const [i, iface] of interfaces.entries()) {
396
+ interfaces[i] = iface.replace(sidecarLineRequired, sidecarLineOptional);
396
397
  }
397
398
  writtenLines.delete(sidecarLineRequired);
398
399
  }
@@ -414,8 +415,9 @@ export function handleFlatField(args) {
414
415
  const nestedDefined = interfaces.some(i => i.startsWith(`export interface ${nestedInterfaceName}`));
415
416
  if (nestedDefined) {
416
417
  const idx = findInterfaceIndex(nestedInterfaceName);
417
- if (idx >= 0 && !interfaces[idx].includes('extension?: (')) {
418
- interfaces[idx] = interfaces[idx].replace(/\n}\s*$/, `\n ${extLine}\n}`);
418
+ const iface = idx >= 0 ? interfaces[idx] : undefined;
419
+ if (iface !== undefined && !iface.includes('extension?: (')) {
420
+ interfaces[idx] = iface.replace(/\n}\s*$/, `\n ${extLine}\n}`);
419
421
  }
420
422
  }
421
423
  else {
@@ -463,7 +465,7 @@ export function handleFlatField(args) {
463
465
  fieldType = applyBindingConstraint(fieldType, field, fields);
464
466
  // Apply ValueSet binding constraints for primitive code fields (generates union literal type)
465
467
  if (isPrimitiveCodeField && hasNarrowingBinding && field.binding?.codes) {
466
- const vsUri = field.binding.uri?.split('|')[0];
468
+ const vsUri = field.binding.uri ? stripVersionFromCanonicalUrl(field.binding.uri) : undefined;
467
469
  const vs = vsUri && valueSets ? (valueSets.get(vsUri) || valueSets.get(field.binding.uri)) : undefined;
468
470
  if (vs && vs.isSmall && vs.concepts.length > 0) {
469
471
  // Import the ValueSet Code type from the generated valuesets directory
@@ -169,18 +169,17 @@ export function handleNestedField(args) {
169
169
  if (nestedDefined) {
170
170
  // Append a line to the nested interface's content
171
171
  const idx = findInterfaceIndex(nestedInterfaceName);
172
- if (idx >= 0 && !interfaces[idx].includes('extension?: (')) {
173
- interfaces[idx] = interfaces[idx].replace(/\n}\s*$/, `\n ${extLine}\n}`);
172
+ const iface = idx >= 0 ? interfaces[idx] : undefined;
173
+ if (iface !== undefined && !iface.includes('extension?: (')) {
174
+ interfaces[idx] = iface.replace(/\n}\s*$/, `\n ${extLine}\n}`);
174
175
  }
175
176
  }
176
177
  else {
177
178
  // No nested interface existed; create a minimal one extending the base/inferred type to host the extension union
178
- let parentT = baseFieldByLastSegment.get(fieldName)?.type || field.type;
179
- if (!parentT || parentT === 'any') {
180
- const inferred = inferBackboneType(baseResource, fieldName);
181
- if (inferred)
182
- parentT = inferred;
183
- }
179
+ // Reuse the type resolved above rather than recomputing it: the local
180
+ // recomputation used to stop at a generic BackboneElement, which drops the
181
+ // base backbone's own properties (e.g. QuestionnaireResponseItem.linkId).
182
+ let parentT = baseFieldType;
184
183
  if (parentT && !isPrimitiveType(parentT)) {
185
184
  parentT = canonicalToTypeName(parentT);
186
185
  const sanitized = sanitizeIdentifier(parentT);
@@ -394,8 +393,8 @@ export function handleNestedField(args) {
394
393
  }
395
394
  // Register external profiles for any field with profile URLs, regardless of nesting
396
395
  // This handles FHIR datatype profiles like SimpleQuantity that aren't in @types/fhir
397
- if (field.isProfiled && field.profileUrls && field.profileUrls.length > 0 && !isFhirType(resolvedType)) {
398
- const profileUrl = field.profileUrls[0];
396
+ const [profileUrl] = field.profileUrls ?? [];
397
+ if (field.isProfiled && profileUrl && !isFhirType(resolvedType)) {
399
398
  // Strip version suffix from canonical URL before extracting name
400
399
  const strippedUrl = stripVersionFromCanonicalUrl(profileUrl);
401
400
  // Use helper to determine base type - extension fields get 'Extension' as base type
@@ -0,0 +1,136 @@
1
+ /**
2
+ * NamingSystem Generator
3
+ *
4
+ * Emits a `namingsystems/` module mirroring `valuesets/`, so an IG's declared
5
+ * identifier systems are consumable as typed constants instead of retyped as
6
+ * string literals in every downstream repo.
7
+ */
8
+ import { pascalCaseIdentifier } from '../../core/utils.js';
9
+ /**
10
+ * Sanitize a NamingSystem name for use as a TypeScript identifier.
11
+ */
12
+ export function sanitizeNamingSystemName(name) {
13
+ return pascalCaseIdentifier(name, {
14
+ stripPrefix: /^NamingSystem[-_]?/i,
15
+ digitPrefix: 'NS',
16
+ fallback: 'UnnamedNamingSystem',
17
+ });
18
+ }
19
+ /**
20
+ * Assign a unique identifier to every NamingSystem that will be emitted.
21
+ *
22
+ * Single source of truth for NamingSystem naming, shared by the file writer and
23
+ * the registry — the same arrangement `assignValueSetNames` uses, and for the
24
+ * same reason: two independent deduplication passes drift, and the registry
25
+ * then imports a file that was never written (TS2307).
26
+ *
27
+ * A NamingSystem has no canonical URL in R4, so identity here is the preferred
28
+ * uniqueId value rather than a `url` element.
29
+ */
30
+ export function assignNamingSystemNames(namingSystems) {
31
+ const takenNames = new Set();
32
+ const seenIdentities = new Set();
33
+ const assigned = [];
34
+ for (const namingSystem of namingSystems) {
35
+ const identity = preferredUniqueId(namingSystem).value;
36
+ if (seenIdentities.has(identity))
37
+ continue;
38
+ seenIdentities.add(identity);
39
+ const base = sanitizeNamingSystemName(namingSystem.name);
40
+ let sanitized = base;
41
+ let suffix = 2;
42
+ while (takenNames.has(sanitized)) {
43
+ sanitized = `${base}${suffix}`;
44
+ suffix++;
45
+ }
46
+ takenNames.add(sanitized);
47
+ assigned.push({ namingSystem, sanitized });
48
+ }
49
+ return assigned;
50
+ }
51
+ /**
52
+ * The uniqueId a consumer means when they say "the system".
53
+ *
54
+ * `preferred: true` wins, then the first `uri` entry (what a profile fixes on
55
+ * `Identifier.system`), then the first entry at all.
56
+ */
57
+ export function preferredUniqueId(namingSystem) {
58
+ return namingSystem.uniqueIds.find(u => u.preferred)
59
+ ?? namingSystem.uniqueIds.find(u => u.type === 'uri')
60
+ ?? namingSystem.uniqueIds[0];
61
+ }
62
+ /**
63
+ * Generate TypeScript content for a NamingSystem.
64
+ */
65
+ export function generateNamingSystemTypeScript(namingSystem, options) {
66
+ const name = options?.nameOverride ?? sanitizeNamingSystemName(namingSystem.name);
67
+ const filename = `NamingSystem-${name}.ts`;
68
+ const preferred = preferredUniqueId(namingSystem);
69
+ let content = `/**\n * NamingSystem: ${namingSystem.title || namingSystem.name}\n`;
70
+ if (namingSystem.description) {
71
+ content += ` * ${namingSystem.description.replace(/\*\//g, '*\\/').split('\n').join('\n * ')}\n`;
72
+ }
73
+ if (namingSystem.kind)
74
+ content += ` * Kind: ${namingSystem.kind}\n`;
75
+ content += ` */\n\n`;
76
+ content += `export const ${name}UniqueIds = [\n`;
77
+ for (const uid of namingSystem.uniqueIds) {
78
+ const preferredPart = uid.preferred !== undefined ? `, preferred: ${uid.preferred}` : '';
79
+ const commentPart = uid.comment ? `, comment: ${JSON.stringify(uid.comment)}` : '';
80
+ content += ` { type: ${JSON.stringify(uid.type)}, value: ${JSON.stringify(uid.value)}${preferredPart}${commentPart} },\n`;
81
+ }
82
+ content += `] as const;\n\n`;
83
+ content += `/**\n * The identifier system this NamingSystem defines.\n`;
84
+ content += ` * Use in place of a hand-written literal on \`Identifier.system\`.\n */\n`;
85
+ content += `export const ${name}System = ${JSON.stringify(preferred.value)};\n\n`;
86
+ const valueUnion = [...new Set(namingSystem.uniqueIds.map(u => JSON.stringify(u.value)))].join(' | ');
87
+ content += `/** Union of every uniqueId value this NamingSystem declares */\n`;
88
+ content += `export type ${name}Value = ${valueUnion};\n\n`;
89
+ content += `/** Type representing a uniqueId entry of this NamingSystem */\n`;
90
+ content += `export type ${name}UniqueId = typeof ${name}UniqueIds[number];\n\n`;
91
+ content += `/**\n * Check whether a value is one of this NamingSystem's declared identifiers\n */\n`;
92
+ content += `export function isValid${name}Value(value: string): value is ${name}Value {\n`;
93
+ content += ` return ${name}UniqueIds.some(u => u.value === value);\n`;
94
+ content += `}\n\n`;
95
+ content += `/**\n * Get this NamingSystem's identifier for a given uniqueId type (oid, uuid, uri, other)\n */\n`;
96
+ content += `export function get${name}Value(type: string): string | undefined {\n`;
97
+ content += ` return ${name}UniqueIds.find(u => u.type === type)?.value;\n`;
98
+ content += `}\n`;
99
+ return { filename, content };
100
+ }
101
+ /**
102
+ * Generate a registry file that exports all NamingSystems.
103
+ */
104
+ export function generateNamingSystemRegistry(namingSystems) {
105
+ let content = `/**\n * NamingSystem Registry\n * Central registry of all identifier systems declared by this package\n */\n\n`;
106
+ // Names come from the shared assignment pass so every import below refers to
107
+ // a file the writer actually emitted (see assignNamingSystemNames).
108
+ const sorted = assignNamingSystemNames(namingSystems)
109
+ .map(({ namingSystem, sanitized }) => ({ sanitized, system: preferredUniqueId(namingSystem).value }))
110
+ .sort((a, b) => a.sanitized.localeCompare(b.sanitized));
111
+ for (const { sanitized } of sorted) {
112
+ content += `import * as ${sanitized} from './NamingSystem-${sanitized}.js';\n`;
113
+ }
114
+ content += '\n';
115
+ content += `export const NamingSystemRegistry = {\n`;
116
+ for (const { sanitized } of sorted) {
117
+ content += ` ${sanitized},\n`;
118
+ }
119
+ content += `} as const;\n\n`;
120
+ content += `export type NamingSystemName = keyof typeof NamingSystemRegistry;\n\n`;
121
+ content += `export function getNamingSystem(name: NamingSystemName) {\n`;
122
+ content += ` return NamingSystemRegistry[name];\n`;
123
+ content += `}\n\n`;
124
+ content += `/** Map from identifier system value to registry name */\n`;
125
+ content += `export const NamingSystemUrlMap: Record<string, NamingSystemName> = {\n`;
126
+ for (const { sanitized, system } of sorted) {
127
+ content += ` ${JSON.stringify(system)}: '${sanitized}',\n`;
128
+ }
129
+ content += `};\n\n`;
130
+ content += `/**\n * Look up the registry entry that declares an identifier system\n * @param system - An identifier system value (e.g. from \`Identifier.system\`)\n */\n`;
131
+ content += `export function getNamingSystemBySystem(system: string) {\n`;
132
+ content += ` const name = NamingSystemUrlMap[system];\n`;
133
+ content += ` return name ? NamingSystemRegistry[name] : undefined;\n`;
134
+ content += `}\n`;
135
+ return content;
136
+ }
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
+ import { lowerFirstPreservingAcronym } from '../../core/utils.js';
11
12
  import { generatePrefabRenderer } from './prefabRenderer.js';
12
13
  import { FHIR_FORMAT_TS, DEFAULT_STYLES_TS, REGISTER_FHIR_PIPES_TS, buildPrefabIndexTs, } from './prefabRuntimeAssets.js';
13
14
  import { logger } from '../../../logger.js';
@@ -88,21 +89,7 @@ export function resetPrefabState() {
88
89
  emittedProfiles.clear();
89
90
  }
90
91
  // ── MCP View Registry generator ──────────────────────────────────────────────
91
- /**
92
- * Lower-case the first character(s) of a name, preserving acronym runs.
93
- * Mirrors the same logic in prefabRenderer.ts.
94
- */
95
- function lowerFirst(name) {
96
- if (!name)
97
- return name;
98
- let i = 0;
99
- while (i < name.length && name[i] >= 'A' && name[i] <= 'Z')
100
- i++;
101
- if (i <= 1)
102
- return name.charAt(0).toLowerCase() + name.slice(1);
103
- const acronymEnd = i === name.length ? i : i - 1;
104
- return name.slice(0, acronymEnd).toLowerCase() + name.slice(acronymEnd);
105
- }
92
+ const lowerFirst = lowerFirstPreservingAcronym;
106
93
  /**
107
94
  * Build the `mcpViews.ts` source — a `FhirViewRegistry` that maps FHIR
108
95
  * resource types to their generated prefab table/detail components.
@@ -11,26 +11,9 @@
11
11
  * The emitted code depends on `@maxhealth.tech/prefab` and the sibling
12
12
  * `fhirFormat.ts` / `styles.ts` files produced in the same output folder.
13
13
  */
14
+ import { lowerFirstPreservingAcronym } from '../../core/utils.js';
14
15
  import { describeField, dedupeDescriptors, pickTableDescriptors, findTitleDescriptor, } from './prefabTypeMapper.js';
15
- /**
16
- * camelCase first char lowercased, but preserve leading acronym runs.
17
- * "CHCorePatient" → "chCorePatient" (acronym lowered wholesale)
18
- * "Patient" → "patient"
19
- * "HumanName" → "humanName"
20
- */
21
- function lowerFirst(name) {
22
- if (!name)
23
- return name;
24
- // Count leading uppercase letters (the acronym run).
25
- let i = 0;
26
- while (i < name.length && name[i] >= 'A' && name[i] <= 'Z')
27
- i++;
28
- if (i <= 1)
29
- return name.charAt(0).toLowerCase() + name.slice(1);
30
- // Preserve boundary: last uppercase stays uppercase if it starts the next word.
31
- const acronymEnd = i === name.length ? i : i - 1;
32
- return name.slice(0, acronymEnd).toLowerCase() + name.slice(acronymEnd);
33
- }
16
+ const lowerFirst = lowerFirstPreservingAcronym;
34
17
  /** Emit a JS expression that extracts the display value for a descriptor from `r`. */
35
18
  function rowAccessor(d) {
36
19
  // Descriptors can pre-bake a bespoke accessor (e.g. synthesized per-system
@@ -100,6 +100,8 @@ export function describeField(field) {
100
100
  if (segments.length !== 2)
101
101
  return [];
102
102
  const bareName = segments[1];
103
+ if (!bareName)
104
+ return [];
103
105
  // Skip extension plumbing — surfaced separately once sliced-extension rendering lands.
104
106
  if (bareName === 'extension' || bareName === 'modifierExtension')
105
107
  return [];
@@ -34,7 +34,8 @@ function instanceUrl(slice, fields) {
34
34
  if (childId === sliceFullId + '.url' && typeof f.fixedValue === 'string')
35
35
  return f.fixedValue;
36
36
  }
37
- return slice.profileUrls?.length ? stripVersionFromCanonicalUrl(slice.profileUrls[0]) : undefined;
37
+ const firstProfile = slice.profileUrls?.[0];
38
+ return firstProfile ? stripVersionFromCanonicalUrl(firstProfile) : undefined;
38
39
  }
39
40
  /**
40
41
  * The property name a choice slice occupies.
@@ -87,7 +88,9 @@ export function generateClosedSlicingValidations(fields, profileUrl, baseResourc
87
88
  if (elemId.includes(':'))
88
89
  continue;
89
90
  const segments = elemId.split('.');
90
- const leaf = segments[segments.length - 1];
91
+ const leaf = segments.at(-1);
92
+ if (!leaf)
93
+ continue;
91
94
  const kind = classify(leaf);
92
95
  if (!kind)
93
96
  continue;
@@ -27,13 +27,19 @@ export function installFhirpathStub(outputDir) {
27
27
  types: 'index.d.ts',
28
28
  }, null, 2));
29
29
  // Minimal type declaration — only what the generated validators import
30
+ // `compile` is declared alongside `evaluate`: the emitted Zod schemas compile
31
+ // each invariant once at module load rather than re-parsing it per parse.
30
32
  fs.writeFileSync(path.join(pkgDir, 'index.d.ts'), `declare const fhirpath: {
31
33
  evaluate(resource: any, expression: string, context?: any, model?: any, options?: any): any[];
34
+ compile(expression: string, model?: any, options?: any): (resource: any, context?: any) => any[];
32
35
  };
33
36
  export default fhirpath;
34
37
  `);
35
38
  // Minimal JS stub (default export so ESM import doesn't crash if somehow reached at runtime)
36
- fs.writeFileSync(path.join(pkgDir, 'index.js'), `export default { evaluate() { throw new Error("fhirpath stub — install the real package"); } };\n`);
39
+ fs.writeFileSync(path.join(pkgDir, 'index.js'), `export default {
40
+ evaluate() { throw new Error("fhirpath stub — install the real package"); },
41
+ compile() { throw new Error("fhirpath stub — install the real package"); },
42
+ };\n`);
37
43
  // Stub fhir-context/<version>/index.js for each supported FHIR version
38
44
  for (const ver of FHIR_CONTEXT_VERSIONS) {
39
45
  const ctxDir = path.join(pkgDir, 'fhir-context', ver);