babelfhir-ts 1.0.12 → 1.0.13

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.
@@ -3,7 +3,7 @@ import fs from 'fs';
3
3
  import { generateClass } from './classGenerator';
4
4
  import { generateInterfaces } from './interfaceGenerator';
5
5
  import { extractPackage, readStructureDefinitionsFromDir, createPackageFromDir, readValueSetCodesFromDir, readValueSetsFromDir } from './packageParser';
6
- import { fetchStructureDefinitions, parseStructureDefinition, fetchStructureDefinition } from './sdParser';
6
+ import { fetchStructureDefinitions, parseStructureDefinition, fetchStructureDefinition, registerLocalStructureDefinitions, buildFhirChildTypeMap } from './sdParser';
7
7
  import { writeInterfaceAndValidatorToFile, mergeFields, alignFields, ensureDirectoryExists, writeToFile, getBaseResource, downloadFile, sanitizeIdentifier, toPascalCase } from './utils';
8
8
  import { generateValidateProfileFunction } from './validatorGenerator';
9
9
  import { loadExamplesFromPackage, generateTestFile } from './testGenerator';
@@ -55,7 +55,7 @@ export function skeletonQuantity(): Quantity { return { value: randomInt(1,100),
55
55
  }
56
56
  // Reusable processor for a single StructureDefinition
57
57
  async function processStructureDefinition(sd, ctx) {
58
- const { outputDir, fhirSourceHint, valueSetCodesMap, valueSets, examplesMap, existingStructureDefinitions, profileIdToName, flags } = ctx;
58
+ const { outputDir, fhirSourceHint, valueSetCodesMap, valueSets, examplesMap, existingStructureDefinitions, profileIdToName, flags, fhirChildTypeMap } = ctx;
59
59
  const rawInterfaceName = sd.name || sd.id || 'UnnamedInterface';
60
60
  const interfaceName = toPascalCase(rawInterfaceName);
61
61
  let baseResource = sanitizeIdentifier(getBaseResource(sd.baseDefinition));
@@ -72,7 +72,7 @@ async function processStructureDefinition(sd, ctx) {
72
72
  return;
73
73
  }
74
74
  const aligned = alignFields(parsed.newFields, parsed.oldFields);
75
- const interfaceResult = generateInterfaces(interfaceName, aligned.alignedNewFields, baseResource, parsed.oldFields, valueSets, sd.type, existingStructureDefinitions);
75
+ const interfaceResult = generateInterfaces(interfaceName, aligned.alignedNewFields, baseResource, parsed.oldFields, valueSets, sd.type, existingStructureDefinitions, fhirChildTypeMap);
76
76
  const interfaceContent = interfaceResult.content;
77
77
  // Generate type alias files for external profiled identifiers that don't exist as StructureDefinitions
78
78
  for (const [typeName, profileInfo] of interfaceResult.referencedExternalProfiles) {
@@ -200,6 +200,13 @@ export async function generate(fhirSource, outputDir, flags) {
200
200
  }
201
201
  console.log(`Fetched ${structureDefinitions.length} StructureDefinitions.`);
202
202
  ensureDirectoryExists(outputDir);
203
+ // Precompute child type map for common core datatypes (best effort; silent on failures)
204
+ let fhirChildTypeMap = new Map();
205
+ try {
206
+ const coreTypes = ['Identifier', 'CodeableConcept', 'Coding', 'Reference', 'Period', 'HumanName', 'Address'];
207
+ fhirChildTypeMap = await buildFhirChildTypeMap(coreTypes, 'R4');
208
+ }
209
+ catch { /* non-fatal */ }
203
210
  // Build set of existing StructureDefinition names to avoid generating type aliases for them
204
211
  const existingStructureDefinitions = new Set();
205
212
  const profileIdToName = new Map(); // Map from profile ID to interface name
@@ -216,7 +223,7 @@ export async function generate(fhirSource, outputDir, flags) {
216
223
  }
217
224
  const examples = loadExamplesFromPackage(fhirSource);
218
225
  for (const sd of structureDefinitions) {
219
- await processStructureDefinition(sd, { outputDir, fhirSourceHint: fhirSource, valueSetCodesMap, valueSets, examplesMap: examples, existingStructureDefinitions, profileIdToName, flags });
226
+ await processStructureDefinition(sd, { outputDir, fhirSourceHint: fhirSource, valueSetCodesMap, valueSets, examplesMap: examples, existingStructureDefinitions, profileIdToName, flags, fhirChildTypeMap });
220
227
  }
221
228
  }
222
229
  /**
@@ -237,9 +244,9 @@ export async function generateIntoPackage(packageArchivePath, outArchivePath, fl
237
244
  const outputDir = path.join(extractedRoot, 'generated');
238
245
  ensureDirectoryExists(outputDir);
239
246
  // Generate TypeScript files for ValueSets inside the embedded package
247
+ let didGenerateValueSets = false;
240
248
  if (valueSets.size > 0) {
241
249
  const valueSetOutputDir = path.join(outputDir, 'valuesets');
242
- ensureDirectoryExists(valueSetOutputDir);
243
250
  let generatedValueSetCount = 0;
244
251
  let skippedEmptyCount = 0;
245
252
  for (const valueSet of valueSets.values()) {
@@ -248,6 +255,10 @@ export async function generateIntoPackage(packageArchivePath, outArchivePath, fl
248
255
  continue;
249
256
  }
250
257
  try {
258
+ // Only create directory when we actually have files to write
259
+ if (generatedValueSetCount === 0) {
260
+ ensureDirectoryExists(valueSetOutputDir);
261
+ }
251
262
  const { filename, content } = generateValueSetTypeScript(valueSet);
252
263
  fs.writeFileSync(path.join(valueSetOutputDir, filename), content, 'utf-8');
253
264
  generatedValueSetCount++;
@@ -260,9 +271,29 @@ export async function generateIntoPackage(packageArchivePath, outArchivePath, fl
260
271
  const registryContent = generateValueSetRegistry(valueSets);
261
272
  fs.writeFileSync(path.join(valueSetOutputDir, 'index.ts'), registryContent, 'utf-8');
262
273
  console.log(`Generated ${generatedValueSetCount} ValueSet files (skipped ${skippedEmptyCount} empty ValueSets) in ${valueSetOutputDir}`);
274
+ didGenerateValueSets = true;
263
275
  }
264
- else if (skippedEmptyCount > 0) {
265
- console.log(`Skipped ${skippedEmptyCount} empty ValueSets (no files generated)`);
276
+ else {
277
+ if (skippedEmptyCount > 0) {
278
+ console.log(`Skipped ${skippedEmptyCount} empty ValueSets (no files generated)`);
279
+ }
280
+ }
281
+ }
282
+ // Ensure no stale valuesets directory remains if we didn't generate any files
283
+ if (!didGenerateValueSets) {
284
+ const vdir = path.join(path.join(extractedRoot, 'generated'), 'valuesets');
285
+ try {
286
+ if (fs.existsSync(vdir)) {
287
+ fs.rmSync(vdir, { recursive: true, force: true });
288
+ if (process.env.DEBUG_FHIR_GEN === 'true') {
289
+ console.debug(`Removed stale ValueSets directory: ${vdir}`);
290
+ }
291
+ }
292
+ }
293
+ catch (e) {
294
+ if (process.env.DEBUG_FHIR_GEN === 'true') {
295
+ console.warn(`Failed to remove stale ValueSets directory ${vdir}: ${e.message}`);
296
+ }
266
297
  }
267
298
  }
268
299
  // Build set of existing StructureDefinition names
@@ -278,6 +309,8 @@ export async function generateIntoPackage(packageArchivePath, outArchivePath, fl
278
309
  }
279
310
  }
280
311
  }
312
+ // Register all local StructureDefinitions for resolution before HTTP fetches
313
+ registerLocalStructureDefinitions(structureDefinitions);
281
314
  const examples = loadExamplesFromPackage(extractedRoot);
282
315
  for (const sd of structureDefinitions) {
283
316
  await processStructureDefinition(sd, { outputDir, fhirSourceHint: '', valueSetCodesMap, valueSets, examplesMap: examples, existingStructureDefinitions, profileIdToName, flags });
@@ -338,7 +371,6 @@ export async function generateForDirectory(inputDir, outputDir, flags) {
338
371
  console.log(`Loaded ${valueSets.size} ValueSets from ${inputDir} (${Array.from(valueSets.values()).filter(vs => vs.isSmall).length} suitable for union types)`);
339
372
  // Generate TypeScript files for ValueSets
340
373
  const valueSetOutputDir = path.join(outputDir, 'valuesets');
341
- ensureDirectoryExists(valueSetOutputDir);
342
374
  let generatedValueSetCount = 0;
343
375
  let skippedEmptyCount = 0;
344
376
  for (const valueSet of valueSets.values()) {
@@ -348,6 +380,10 @@ export async function generateForDirectory(inputDir, outputDir, flags) {
348
380
  continue;
349
381
  }
350
382
  try {
383
+ // Only create directory when we actually have files to write
384
+ if (generatedValueSetCount === 0) {
385
+ ensureDirectoryExists(valueSetOutputDir);
386
+ }
351
387
  const { filename, content } = generateValueSetTypeScript(valueSet);
352
388
  const outputPath = path.join(valueSetOutputDir, filename);
353
389
  fs.writeFileSync(outputPath, content, 'utf-8');
@@ -364,8 +400,24 @@ export async function generateForDirectory(inputDir, outputDir, flags) {
364
400
  fs.writeFileSync(registryPath, registryContent, 'utf-8');
365
401
  console.log(`Generated ${generatedValueSetCount} ValueSet files (skipped ${skippedEmptyCount} empty ValueSets) in ${valueSetOutputDir}`);
366
402
  }
367
- else if (skippedEmptyCount > 0) {
368
- console.log(`Skipped ${skippedEmptyCount} empty ValueSets (no files generated)`);
403
+ else {
404
+ if (skippedEmptyCount > 0) {
405
+ console.log(`Skipped ${skippedEmptyCount} empty ValueSets (no files generated)`);
406
+ }
407
+ // Remove any stale valuesets directory from previous runs
408
+ try {
409
+ if (fs.existsSync(valueSetOutputDir)) {
410
+ fs.rmSync(valueSetOutputDir, { recursive: true, force: true });
411
+ if (process.env.DEBUG_FHIR_GEN === 'true') {
412
+ console.debug(`Removed stale ValueSets directory: ${valueSetOutputDir}`);
413
+ }
414
+ }
415
+ }
416
+ catch (e) {
417
+ if (process.env.DEBUG_FHIR_GEN === 'true') {
418
+ console.warn(`Failed to remove stale ValueSets directory ${valueSetOutputDir}: ${e.message}`);
419
+ }
420
+ }
369
421
  }
370
422
  const entries = fs.readdirSync(inputDir);
371
423
  const archives = entries.filter(f => f.endsWith('.tgz') || f.endsWith('.zip'));
@@ -377,11 +429,13 @@ export async function generateForDirectory(inputDir, outputDir, flags) {
377
429
  // Build set of existing StructureDefinition names from JSON files
378
430
  const existingStructureDefinitions = new Set();
379
431
  const profileIdToName = new Map();
432
+ const localStructureDefinitions = [];
380
433
  for (const jf of jsonFiles) {
381
434
  try {
382
435
  const raw = JSON.parse(fs.readFileSync(path.join(inputDir, jf), 'utf-8'));
383
436
  if (typeof raw === 'object' && raw !== null && raw.resourceType === 'StructureDefinition') {
384
437
  const sd = raw;
438
+ localStructureDefinitions.push(sd); // Collect for local resolution
385
439
  const name = toPascalCase(sd.name || sd.id || '');
386
440
  if (name) {
387
441
  existingStructureDefinitions.add(name);
@@ -394,6 +448,8 @@ export async function generateForDirectory(inputDir, outputDir, flags) {
394
448
  }
395
449
  catch { /* ignore parse errors in pre-scan */ }
396
450
  }
451
+ // Register all local StructureDefinitions for resolution before HTTP fetches
452
+ registerLocalStructureDefinitions(localStructureDefinitions);
397
453
  for (const file of archives) {
398
454
  const fullPath = path.join(inputDir, file);
399
455
  console.log(`Processing package: ${file}`);
@@ -411,10 +467,6 @@ export async function generateForDirectory(inputDir, outputDir, flags) {
411
467
  const full = path.join(inputDir, jf);
412
468
  try {
413
469
  const raw = JSON.parse(fs.readFileSync(full, 'utf-8'));
414
- if (typeof raw !== 'object' || raw === null || raw.resourceType !== 'StructureDefinition') {
415
- console.warn(`Skipping non-StructureDefinition JSON: ${jf}`);
416
- continue;
417
- }
418
470
  await processStructureDefinition(raw, { outputDir, fhirSourceHint: full, valueSets, existingStructureDefinitions, profileIdToName, flags });
419
471
  }
420
472
  catch (err) {
@@ -1,10 +1,10 @@
1
1
  import fhirInterfaceNames from "./fhirInterfaces.json";
2
- import { capitalize, sanitizeIdentifier } from './utils';
2
+ import { capitalize, sanitizeIdentifier, toPascalCase } from './utils';
3
3
  import { generateCodeUnionType, getUniformSystem } from './vsParser.js';
4
4
  import { logger } from '../logger';
5
- export function generateInterfaces(interfaceName, newFields, baseResource, baseFields = [], valueSets, resourceType, existingProfiles) {
5
+ export function generateInterfaces(interfaceName, newFields, baseResource, baseFields = [], valueSets, resourceType, existingProfiles, fhirChildTypeMap) {
6
6
  const debug = (...args) => { if (process.env.DEBUG_FHIR_GEN === 'true')
7
- logger.log('[gen:interfaces]', ...args); };
7
+ console.log('[gen:interfaces]', ...args); };
8
8
  const imports = new Set();
9
9
  const customImports = new Set();
10
10
  const interfacesRaw = [];
@@ -14,7 +14,7 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
14
14
  return (...items) => {
15
15
  for (const item of items) {
16
16
  if (item.includes('Code') && interfaceName.includes('Condition')) {
17
- logger.log('[INTERFACES.PUSH]', { interfaceName, item: item.substring(0, 80), stack: new Error().stack?.split('\n')[2] });
17
+ console.log('[INTERFACES.PUSH]', { interfaceName, item: item.substring(0, 80), stack: new Error().stack?.split('\n')[2] });
18
18
  }
19
19
  }
20
20
  return target.push(...items);
@@ -24,7 +24,7 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
24
24
  return (...items) => {
25
25
  for (const item of items) {
26
26
  if (item.includes('Code') && interfaceName.includes('Condition')) {
27
- logger.log('[INTERFACES.UNSHIFT]', { interfaceName, item: item.substring(0, 80), stack: new Error().stack?.split('\n')[2] });
27
+ console.log('[INTERFACES.UNSHIFT]', { interfaceName, item: item.substring(0, 80), stack: new Error().stack?.split('\n')[2] });
28
28
  }
29
29
  }
30
30
  return target.unshift(...items);
@@ -138,10 +138,15 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
138
138
  return `CodeableConcept & { coding: Array<{ code: ${codeUnion} }> }`;
139
139
  }
140
140
  };
141
- function processFields(fields, parentInterfaceName, parentFieldType) {
141
+ function processFields(fields, parentInterfaceName, parentFieldType, isProcessingSlice = false) {
142
142
  const interfaceLines = [];
143
143
  const emittedLinesByField = new Map();
144
144
  const processedFields = new Set();
145
+ // Context-aware resolver: only use base field metadata for direct children of the base resource.
146
+ const getBaseFldForContext = (fname, fparts, currentParent) => {
147
+ const isDirectBaseChild = currentParent === interfaceName && fparts.length === 2 && fparts[0] === baseResource;
148
+ return isDirectBaseChild ? baseFieldByLastSegment.get(fname) : undefined;
149
+ };
145
150
  const canonicalToTypeName = (name) => {
146
151
  if (!name)
147
152
  return name;
@@ -178,11 +183,19 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
178
183
  fields.forEach((field) => {
179
184
  const fieldParts = field.name.split(".");
180
185
  let fieldName = fieldParts[fieldParts.length - 1];
181
- if (interfaceName.includes('address') && interfaceName.includes('0_2') && field.name.includes('line')) {
182
- logger.log('[FIELD PROCESSING]', { interfaceName, 'field.name': field.name, fieldName, sliceName: field.sliceName });
183
- }
184
186
  const isRootInterface = parentInterfaceName === interfaceName;
185
187
  const isNested = isRootInterface ? fieldParts.length > 1 : fieldParts.length >= 1;
188
+ // When generating a slice-specific interface, only include direct children
189
+ // (1 segment deep) and skip deeper nested paths (2+ segments) to avoid
190
+ // flattening nested structures into the slice interface.
191
+ if (isProcessingSlice && fieldParts.length > 1) {
192
+ debug('skipping deeply nested field in slice interface', {
193
+ fieldName: field.name,
194
+ parentInterfaceName,
195
+ depth: fieldParts.length
196
+ });
197
+ return;
198
+ }
186
199
  if (field.name.includes('bodySite.extension')) {
187
200
  logger.log('[DEBUG ALL bodySite.extension fields]', {
188
201
  fieldName: field.name,
@@ -226,7 +239,7 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
226
239
  // Only apply nested-parent name filtering for deeper nesting (>= 3 segments like Account.coverage.extension).
227
240
  // For profiles that extend a base type (e.g., IdentifierEfn extends Identifier), only include direct children
228
241
  // (2 segments: Identifier.system) and skip nested paths (4+ segments: Identifier.assigner.identifier.system)
229
- if (isNested && fieldParts.length > 2) {
242
+ if (isNested && fieldParts.length >= 2) {
230
243
  const baseTypeName = fieldParts[0]; // e.g., "Identifier" from "Identifier.assigner.identifier.system"
231
244
  const isExtendingBaseType = baseResource && baseTypeName.toLowerCase() === baseResource.toLowerCase();
232
245
  // Check if this field will create a nested interface (has children in fields)
@@ -234,6 +247,37 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
234
247
  const shouldKeepForNested = field.mustSupport && hasChildren;
235
248
  // Logical models extending Base should treat 2-segment paths as direct properties, not nested filtering
236
249
  const isLogicalModel = baseResource === 'Base';
250
+ // For slice-specific interfaces (e.g., SiuAppointmentIdentifier for Appointment.identifier:doctolib),
251
+ // only include direct children of the slice, not deeply nested descendants like assigner.reference
252
+ // Detect whether this element is part of a slice by checking its ancestors
253
+ // and find the depth (number of segments) of the slice root. This ensures
254
+ // that descendant elements (e.g. assigner.type) are recognized as part of
255
+ // the slice and can be skipped from the slice-specific interface so that
256
+ // nested structures remain nested instead of being flattened.
257
+ let sliceRootDepth = -1;
258
+ for (let i = fieldParts.length; i >= 1; i--) {
259
+ const testPath = fieldParts.slice(0, i).join('.');
260
+ // Use the top-level fields list (newFields) when searching for the slice
261
+ // definition so nested invocations with relative names can still detect
262
+ // the original slice root.
263
+ const hasSliceAtDepth = newFields.some(f => f.name === testPath && f.sliceName);
264
+ if (hasSliceAtDepth) {
265
+ sliceRootDepth = i;
266
+ break;
267
+ }
268
+ }
269
+ const isPartOfSlice = sliceRootDepth > -1 || !!field.sliceName;
270
+ // If this is part of a slice and we're more than 1 level deep from the slice root, skip it
271
+ // (We only want direct children of the slice root in the slice-specific interface.)
272
+ if (isPartOfSlice && sliceRootDepth > 0 && fieldParts.length > sliceRootDepth + 1) {
273
+ debug('skipping deeply nested field in slice', {
274
+ fieldName: field.name,
275
+ sliceRootDepth,
276
+ currentDepth: fieldParts.length,
277
+ parentInterfaceName
278
+ });
279
+ return;
280
+ }
237
281
  if (isExtendingBaseType && !isLogicalModel) {
238
282
  // For profiles extending their base type, only include direct children (exactly 2 segments)
239
283
  // UNLESS the field has mustSupport and children (will create nested interface)
@@ -286,7 +330,7 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
286
330
  return;
287
331
  }
288
332
  if (interfaceName.includes('address') && interfaceName.includes('0_2') && field.name === 'Address.line') {
289
- logger.log('[AFTER SKIP CHECK]', { 'field.name': field.name, 'passed skip check': true });
333
+ console.log('[AFTER SKIP CHECK]', { 'field.name': field.name, 'passed skip check': true });
290
334
  }
291
335
  if (fieldName === "value[x]")
292
336
  fieldName = "value";
@@ -332,7 +376,8 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
332
376
  if (isFhirType(field.type))
333
377
  addTypeImport(field.type);
334
378
  const conservative = baseFieldByLastSegment.size === 0;
335
- let treatAsNested = isNested;
379
+ // In nested contexts, direct children (single segment) should be treated as properties, not nested backbones
380
+ let treatAsNested = isNested && !(parentInterfaceName !== interfaceName && fieldParts.length === 1);
336
381
  // Don't create nested interfaces for extension slices - they contribute to extension unions
337
382
  const fieldPathParts = field.name.split('.');
338
383
  const lastPart = fieldPathParts[fieldPathParts.length - 1];
@@ -378,7 +423,7 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
378
423
  if (hasChildExtSlices && isPrimitiveType(mappedBase)) {
379
424
  treatAsNested = false;
380
425
  if (interfaceName.includes('address') && interfaceName.includes('0_2') && fieldName === 'line') {
381
- logger.log('[SET treatAsNested = false for line]');
426
+ console.log('[SET treatAsNested = false for line]');
382
427
  }
383
428
  }
384
429
  debug('retain nested backbone due to profiled descendant', field.name);
@@ -472,8 +517,9 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
472
517
  let baseFieldType = baseFieldByLastSegment.get(fieldName)?.type || field.type;
473
518
  // If base type is missing/any/generic BackboneElement and this is a known backbone of the base resource,
474
519
  // use the concrete backbone interface (e.g., DocumentReferenceContent)
520
+ // Use resourceType (the ultimate FHIR base like "Appointment") not baseResource (which might be a profile like "DoctolibAppointment")
475
521
  if (!baseFieldType || baseFieldType === 'any' || baseFieldType === 'BackboneElement') {
476
- const inferred = inferBackboneType(baseResource, fieldName);
522
+ const inferred = inferBackboneType(resourceType || baseResource, fieldName);
477
523
  if (inferred)
478
524
  baseFieldType = inferred;
479
525
  }
@@ -492,10 +538,14 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
492
538
  nestedFieldsCount: nestedFields.length
493
539
  });
494
540
  }
541
+ // Check if this nested interface is for a sliced element
542
+ // (there may be multiple fields at the same path with different sliceNames)
543
+ const isSlicedElement = fields.some(f => f.name === field.name && f.sliceName);
495
544
  processFields(nestedFields.map((f) => ({
496
545
  ...f,
497
546
  name: f.name.replace(`${field.name}.`, ""),
498
- })), nestedInterfaceName, baseFieldType);
547
+ })), nestedInterfaceName, baseFieldType, isSlicedElement // Pass true if ANY field at this path has a sliceName
548
+ );
499
549
  let nestedDefined = interfaces.some(i => i.startsWith(`export interface ${nestedInterfaceName}`));
500
550
  if (fieldName === 'answer' && field.mustSupport) {
501
551
  logger.log('[DEBUG answer after processFields]', {
@@ -520,7 +570,7 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
520
570
  const aliasTypes = [];
521
571
  for (const sl of extensionSlices) {
522
572
  for (const url of sl.profileUrls || []) {
523
- const alias = sanitizeIdentifier(url.split('/').pop() || 'ExtensionSlice');
573
+ const alias = toPascalCase(sanitizeIdentifier(url.split('/').pop() || 'ExtensionSlice'));
524
574
  // Generate minimal alias interface locally if not present elsewhere
525
575
  if (!generatedAliasTypes.has(alias)) {
526
576
  interfaces.unshift(`export interface ${alias} extends Extension { url: '${url}' }`);
@@ -576,7 +626,34 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
576
626
  // Fall through to emit the field with updated cardinality
577
627
  debug('emit base child with changed cardinality', field.name, { baseOptional: baseFld?.isOptional, profileOptional: field.isOptional });
578
628
  }
629
+ // For nested interfaces that extend a base type (e.g., SiuAppointmentParticipant extends AppointmentParticipant),
630
+ // if we're only changing cardinality (not type), preserve the base field's type instead of downgrading to primitives
579
631
  const baseFld = baseFieldByLastSegment.get(fieldName);
632
+ const isOnlyCardinalityChange = baseFld && !nestedDefined &&
633
+ (baseFld.isOptional !== field.isOptional || baseFld.isArray !== field.isArray) &&
634
+ field.type === baseFld.type && // Type hasn't changed
635
+ !field.isProfiled && !field.fixedValue; // No other constraints
636
+ // For nested interfaces extending FHIR base types, skip fields with no real constraints
637
+ // (FHIR base types like AppointmentParticipant have rich union types that we want to preserve)
638
+ const isNestedExtendingFhirBase = parentInterfaceName !== interfaceName && parentFieldType && isFhirType(parentFieldType);
639
+ // For root interfaces extending other profiles (e.g., SiuAppointment extends DoctolibAppointment),
640
+ // also skip fields with no constraints (they inherit from the profile chain)
641
+ const isRootExtendingProfile = parentInterfaceName === interfaceName && baseResource && baseResource !== interfaceName;
642
+ const hasChildren = fields.some((f) => f.name.startsWith(`${field.name}.`));
643
+ const hasNoConstraints = baseFld && !nestedDefined && !hasChildren &&
644
+ baseFld.isOptional === field.isOptional &&
645
+ baseFld.isArray === field.isArray &&
646
+ field.type === baseFld.type &&
647
+ !field.isProfiled && !field.fixedValue;
648
+ // Skip fields with NO constraints at all (they inherit perfectly from base)
649
+ // But DON'T skip fields that only change cardinality - we need to emit those with correct optionality
650
+ if ((isNestedExtendingFhirBase || isRootExtendingProfile) && hasNoConstraints && !isOnlyCardinalityChange) {
651
+ debug('skip field with no constraints - inherit from base', field.name);
652
+ return;
653
+ }
654
+ // For fields that only change cardinality, we'll emit them but use the base type
655
+ // (preserving union types, etc.) with the correct optionality marker
656
+ const useBaseTypeForCardinality = (isNestedExtendingFhirBase || isRootExtendingProfile) && isOnlyCardinalityChange;
580
657
  // Check if this is a primitive field with child extension slices
581
658
  const hasChildExtSlices = fields.some(f => f.name === `${field.name}.extension` && f.sliceName && (f.profileUrls || []).length > 0);
582
659
  const mappedBaseType = mapTypeToTS((baseFld?.type || field.type || 'string'));
@@ -589,16 +666,38 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
589
666
  // The nested interface is only for the sidecar _field element
590
667
  // Also, for extension fields with slices, the union will be added separately
591
668
  let resolvedType = (nestedDefined && !isPrimitiveWithExtSlices && !isExtensionWithSlices) ? nestedInterfaceName : resolveEffectiveType(field, baseFld);
592
- if (!nestedDefined) {
593
- // Prefer explicit backbone interface if resolvable (e.g., EncounterHospitalization)
594
- const inferred = inferBackboneType(baseResource, fieldName);
595
- if (!field.isProfiled && inferred) {
596
- resolvedType = inferred;
669
+ // For cardinality-only changes, use the base field's type directly to preserve union types, etc.
670
+ if (useBaseTypeForCardinality && baseFld?.type) {
671
+ resolvedType = baseFld.type;
672
+ debug('using base type for cardinality-only change', field.name, { baseType: baseFld.type });
673
+ }
674
+ else if (!nestedDefined) {
675
+ // For direct children of the root resource, prefer explicit backbone inference
676
+ if (parentInterfaceName === interfaceName) {
677
+ const inferred = inferBackboneType(baseResource, fieldName);
678
+ if (!field.isProfiled && inferred) {
679
+ resolvedType = inferred;
680
+ }
681
+ else {
682
+ resolvedType = mapTypeToTS(resolvedType);
683
+ }
597
684
  }
598
685
  else {
686
+ // Nested contexts: do not infer resource-level backbones, just map primitives
599
687
  resolvedType = mapTypeToTS(resolvedType);
600
688
  }
601
689
  }
690
+ // Ensure CodeableConcept.text remains string (avoid Resource.text Narrative leakage)
691
+ if (fieldName === 'text' && parentFieldType && sanitizeIdentifier(parentFieldType) === 'CodeableConcept') {
692
+ resolvedType = 'string';
693
+ }
694
+ // Special-case: Bundle.entry.resource should be FhirResource
695
+ if (fieldName === 'resource' && (baseResource === 'Bundle' || resourceType === 'Bundle')) {
696
+ resolvedType = 'FhirResource';
697
+ imports.add('FhirResource');
698
+ }
699
+ // Final primitive normalization for nested branch as well
700
+ resolvedType = mapTypeToTS(resolvedType);
602
701
  // For profiled child elements (Attachment with CustomAttachment profile etc.), prefer the profiled type name directly.
603
702
  if (field.isProfiled && field.type) {
604
703
  resolvedType = canonicalToTypeName(field.type);
@@ -614,7 +713,8 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
614
713
  resolvedType = sanitized;
615
714
  }
616
715
  }
617
- const baseArrFlag = baseFieldByLastSegment.get(fieldName)?.isArray;
716
+ // Only use base arrayness for direct children of the base resource; nested child fields must rely on their own cardinality
717
+ const baseArrFlag = getBaseFldForContext(fieldName, fieldParts, parentInterfaceName)?.isArray;
618
718
  // If base arrayness is unknown (failed to fetch base metadata), default to array for known repeating fields
619
719
  // like Account.coverage when emitting the root-level property.
620
720
  const defaultArrayForKnownRepeatsNested = (baseArrFlag === undefined &&
@@ -776,14 +876,14 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
776
876
  const mustSupportChanged = baseFld && (!baseFld.mustSupport && field.mustSupport);
777
877
  const cardinalityChanged = baseFld && (baseFld.isOptional !== field.isOptional || baseFld.isArray !== field.isArray);
778
878
  if (interfaceName.includes('address') && interfaceName.includes('0_2') && fieldName === 'line') {
779
- logger.log('[PRIMITIVE OVERRIDE CHECK]', { isPrimitive: isPrimitiveType(mapped), hasExtensionSlices, hasIdentifierSlices, mustSupportChanged, cardinalityChanged, willReturn: isPrimitiveType(mapped) && !hasExtensionSlices && !hasIdentifierSlices && !mustSupportChanged && !cardinalityChanged });
879
+ console.log('[PRIMITIVE OVERRIDE CHECK]', { isPrimitive: isPrimitiveType(mapped), hasExtensionSlices, hasIdentifierSlices, mustSupportChanged, cardinalityChanged, willReturn: isPrimitiveType(mapped) && !hasExtensionSlices && !hasIdentifierSlices && !mustSupportChanged && !cardinalityChanged });
780
880
  }
781
881
  if (isPrimitiveType(mapped) && !hasExtensionSlices && !hasIdentifierSlices && !mustSupportChanged && !cardinalityChanged) {
782
882
  debug('skip primitive override of base child', field.name, 'mappedType=', mapped);
783
883
  return;
784
884
  }
785
885
  if (interfaceName.includes('address') && interfaceName.includes('0_2') && fieldName === 'line') {
786
- logger.log('[AFTER PRIMITIVE OVERRIDE CHECK - continuing]');
886
+ console.log('[AFTER PRIMITIVE OVERRIDE CHECK - continuing]');
787
887
  }
788
888
  // Also check if cardinality changed (optional -> required) - if so, emit for documentation
789
889
  if (!cardinalityChanged && !mustSupportChanged && !isPrimitiveType(mapped) && !hasIdentifierSlices) {
@@ -809,10 +909,11 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
809
909
  }
810
910
  }
811
911
  // Heuristic: if base field exists and we're about to widen/narrow it improperly, skip emission.
812
- const baseField = baseFieldByLastSegment.get(fieldName);
912
+ const baseField = getBaseFldForContext(fieldName, fieldParts, parentInterfaceName);
813
913
  const enumeratedSkip = new Set(['status', 'gender', 'class', 'category', 'concept', 'entry', 'clinicalStatus', 'verificationStatus']);
814
914
  const mustSupportChanged = baseField && (!baseField.mustSupport && field.mustSupport);
815
- if (parentInterfaceName === interfaceName && !field.fixedValue && !mustSupportChanged) {
915
+ const cardinalityChanged = baseField && (baseField.isOptional !== field.isOptional || baseField.isArray !== field.isArray);
916
+ if (parentInterfaceName === interfaceName && !field.fixedValue && !mustSupportChanged && !cardinalityChanged) {
816
917
  if (enumeratedSkip.has(fieldName))
817
918
  return;
818
919
  if (baseResource === 'UsageContext' && fieldName === 'code' && field.type === 'string')
@@ -842,7 +943,7 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
842
943
  // If new type is primitive (or code -> string) while base is non-primitive, skip to retain richer base definition.
843
944
  const mapped = mapTypeToTS(field.type || 'string');
844
945
  if (interfaceName.includes('address') && interfaceName.includes('0_2') && fieldName === 'line') {
845
- logger.log('[MAPPED TYPE CHECK]', { mapped, isPrimitive: isPrimitiveType(mapped), baseFieldType: baseField.type, baseFieldIsPrimitive: baseField.type ? isPrimitiveType(baseField.type) : 'no baseField.type', hasExtensionSlices, willReturn: isPrimitiveType(mapped) && baseField.type && !isPrimitiveType(baseField.type) && !hasExtensionSlices });
946
+ console.log('[MAPPED TYPE CHECK]', { mapped, isPrimitive: isPrimitiveType(mapped), baseFieldType: baseField.type, baseFieldIsPrimitive: baseField.type ? isPrimitiveType(baseField.type) : 'no baseField.type', hasExtensionSlices, willReturn: isPrimitiveType(mapped) && baseField.type && !isPrimitiveType(baseField.type) && !hasExtensionSlices });
846
947
  }
847
948
  if (isPrimitiveType(mapped) && baseField.type && !isPrimitiveType(baseField.type) && !hasExtensionSlices) {
848
949
  return; // rely on inherited definition
@@ -851,7 +952,7 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
851
952
  // Additional guard: Sometimes differential loses specific complex type information and we parse as 'any' or 'string'.
852
953
  // If base field exists and is an array but new appears singular primitive, skip overriding to keep array.
853
954
  if (fieldName === 'line') {
854
- logger.log('[ARRAY CHECK 768]', {
955
+ console.log('[ARRAY CHECK 768]', {
855
956
  baseFieldIsArray: baseField.isArray,
856
957
  fieldIsArray: field.isArray,
857
958
  fieldType: field.type,
@@ -866,7 +967,7 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
866
967
  // Special cases: known enumerated code fields (status, gender, intent, class, category) -> avoid overriding unless fixed or mustSupport changed
867
968
  const mustSupportChanged = baseField.mustSupport !== field.mustSupport && field.mustSupport === true;
868
969
  if (fieldName === 'line') {
869
- logger.log('[ENUM CHECK 772]', {
970
+ console.log('[ENUM CHECK 772]', {
870
971
  fieldName,
871
972
  fixedValue: field.fixedValue,
872
973
  mustSupportChanged,
@@ -882,11 +983,11 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
882
983
  return;
883
984
  if (fieldName === 'beneficiary' && baseField.type === 'Reference')
884
985
  return;
885
- if (['concept', 'entry'].includes(fieldName) && !field.fixedValue)
986
+ if (['concept', 'entry', 'content'].includes(fieldName) && !field.fixedValue)
886
987
  return;
887
988
  // Additional guard: avoid overriding well-known enum/union fields with raw string
888
989
  // Even if mustSupport changes, we should inherit the base enum type, not override with string
889
- if (['gender', 'status', 'intent', 'lifecycleStatus', 'clinicalStatus', 'verificationStatus'].includes(fieldName) && isPrimitiveType(mapped)) {
990
+ if (['gender', 'status', 'intent', 'lifecycleStatus', 'clinicalStatus', 'verificationStatus', 'content'].includes(fieldName) && isPrimitiveType(mapped)) {
890
991
  return;
891
992
  }
892
993
  // Skip if identical to base (same type, array flag, optionality, AND mustSupport)
@@ -894,7 +995,7 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
894
995
  const mappedType = mapTypeToTS(field.type || 'string');
895
996
  const mustSupportSame = baseField.mustSupport === field.mustSupport;
896
997
  if (fieldName === 'line') {
897
- logger.log('[IDENTICAL CHECK 796]', {
998
+ console.log('[IDENTICAL CHECK 796]', {
898
999
  baseFieldType: baseField.type,
899
1000
  fieldType: field.type,
900
1001
  mappedType,
@@ -923,7 +1024,7 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
923
1024
  }
924
1025
  }
925
1026
  }
926
- const baseFld = baseFieldByLastSegment.get(fieldName);
1027
+ const baseFld = getBaseFldForContext(fieldName, fieldParts, parentInterfaceName);
927
1028
  let fieldType = resolveEffectiveType(field, baseFld);
928
1029
  // Map raw FHIR primitive codes (e.g., dateTime) to TS primitives unless the field is explicitly profiled.
929
1030
  if (!field.isProfiled) {
@@ -937,9 +1038,21 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
937
1038
  fieldType = mapTypeToTS(fieldType);
938
1039
  }
939
1040
  else {
940
- fieldType = mapTypeToTS(fieldType);
1041
+ // In nested contexts where parent is a known FHIR type, prefer known child types to avoid degrading to primitives
1042
+ if (parentFieldType && isFhirType(parentFieldType)) {
1043
+ const inferredChild = inferFhirChildType(parentFieldType, fieldName);
1044
+ fieldType = inferredChild ? inferredChild : mapTypeToTS(fieldType);
1045
+ }
1046
+ else {
1047
+ fieldType = mapTypeToTS(fieldType);
1048
+ }
941
1049
  }
942
1050
  }
1051
+ // Special-case: Bundle.entry.resource should be FhirResource, not Resource
1052
+ if (fieldName === 'resource' && (baseResource === 'Bundle' || resourceType === 'Bundle')) {
1053
+ fieldType = 'FhirResource';
1054
+ imports.add('FhirResource');
1055
+ }
943
1056
  // Normalize canonical URLs to local type names
944
1057
  if (!isPrimitiveType(fieldType) && !isFhirType(fieldType)) {
945
1058
  fieldType = canonicalToTypeName(fieldType);
@@ -951,7 +1064,7 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
951
1064
  const aliasTypes = [];
952
1065
  for (const sl of samePathSlices) {
953
1066
  for (const url of sl.profileUrls || []) {
954
- const alias = sanitizeIdentifier(url.split('/').pop() || 'ExtensionSlice');
1067
+ const alias = toPascalCase(sanitizeIdentifier(url.split('/').pop() || 'ExtensionSlice'));
955
1068
  if (!generatedAliasTypes.has(alias)) {
956
1069
  interfaces.unshift(`export interface ${alias} extends Extension { url: '${url}' }`);
957
1070
  generatedAliasTypes.add(alias);
@@ -1007,22 +1120,22 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
1007
1120
  // If this field has extension slices on its child '.extension', synthesize a nested interface
1008
1121
  // BUT: if the current field IS "extension" itself, skip this - extension slices are handled elsewhere
1009
1122
  if (interfaceName.includes('address') && interfaceName.includes('0_2') && fieldName === 'line') {
1010
- logger.log('[BEFORE CHILD EXT CHECK]', { 'field.name': field.name, fieldName, 'reached this point': true });
1123
+ console.log('[BEFORE CHILD EXT CHECK]', { 'field.name': field.name, fieldName, 'reached this point': true });
1011
1124
  }
1012
1125
  const childExtensionSlices = fields.filter(f => f.name === `${field.name}.extension` && f.sliceName && (f.profileUrls || []).length > 0);
1013
1126
  if (interfaceName.includes('address') && interfaceName.includes('0_2') && field.name === 'Address.line') {
1014
- logger.log('[CHILD EXT CHECK - Address.line]', { 'field.name': field.name, childExtensionSlicesLength: childExtensionSlices.length, fieldName, filterCheck: fields.filter(f => f.name === `${field.name}.extension` && f.sliceName).map(f => ({ name: f.name, sliceName: f.sliceName, profileUrls: f.profileUrls })) });
1127
+ console.log('[CHILD EXT CHECK - Address.line]', { 'field.name': field.name, childExtensionSlicesLength: childExtensionSlices.length, fieldName, filterCheck: fields.filter(f => f.name === `${field.name}.extension` && f.sliceName).map(f => ({ name: f.name, sliceName: f.sliceName, profileUrls: f.profileUrls })) });
1015
1128
  }
1016
1129
  if (childExtensionSlices.length > 0 && fieldName !== 'extension') {
1017
1130
  if (interfaceName.includes('address') && fieldName === 'line') {
1018
- logger.log('[CREATING SIDECAR]', { interfaceName, parentInterfaceName, fieldName, childExtensionSlicesLength: childExtensionSlices.length });
1131
+ console.log('[CREATING SIDECAR]', { interfaceName, parentInterfaceName, fieldName, childExtensionSlicesLength: childExtensionSlices.length });
1019
1132
  }
1020
1133
  const nestedInterfaceName = `${parentInterfaceName}${capitalize(fieldName)}`;
1021
1134
  // Build union members and alias types
1022
1135
  const aliasTypes = [];
1023
1136
  for (const sl of childExtensionSlices) {
1024
1137
  for (const url of sl.profileUrls || []) {
1025
- const alias = sanitizeIdentifier(url.split('/').pop() || 'ExtensionSlice');
1138
+ const alias = toPascalCase(sanitizeIdentifier(url.split('/').pop() || 'ExtensionSlice'));
1026
1139
  if (!generatedAliasTypes.has(alias)) {
1027
1140
  interfaces.unshift(`export interface ${alias} extends Extension { url: '${url}' }`);
1028
1141
  generatedAliasTypes.add(alias);
@@ -1035,7 +1148,7 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
1035
1148
  const union = ['Extension', ...aliasTypes].join(' | ');
1036
1149
  const extLine = `extension?: (${union})[];`;
1037
1150
  // Determine parent type to extend
1038
- const baseFld2 = baseFieldByLastSegment.get(fieldName);
1151
+ const baseFld2 = getBaseFldForContext(fieldName, fieldParts, parentInterfaceName);
1039
1152
  let parentT = resolveEffectiveType(field, baseFld2);
1040
1153
  if (!field.isProfiled)
1041
1154
  parentT = mapTypeToTS(parentT || 'string');
@@ -1068,7 +1181,7 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
1068
1181
  // For root interfaces, check baseResource; for nested, check parentFieldType
1069
1182
  const effectiveParentType = parentFieldType || baseResource;
1070
1183
  if (fieldName === 'line' || fieldName === 'prefix') {
1071
- logger.log('[SIDECAR DEBUG]', {
1184
+ console.log('[SIDECAR DEBUG]', {
1072
1185
  profileName: interfaceName,
1073
1186
  parentInterfaceName,
1074
1187
  fieldName,
@@ -1089,7 +1202,7 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
1089
1202
  }
1090
1203
  }
1091
1204
  if (fieldName === 'line' || fieldName === 'prefix') {
1092
- logger.log('[SIDECAR RESULT]', { parentInterfaceName, fieldName, finalSidecarIsArray: sidecarIsArray });
1205
+ console.log('[SIDECAR RESULT]', { parentInterfaceName, fieldName, finalSidecarIsArray: sidecarIsArray });
1093
1206
  }
1094
1207
  const sidecarType = sidecarIsArray ? `${elementIface}[]` : elementIface;
1095
1208
  // Prefer optional sidecar by default to match base optionality and avoid duplicate required/optional declarations
@@ -1162,7 +1275,7 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
1162
1275
  // Preserve arrayness from base when known; if base metadata is missing and field name is a known repeating element (e.g., 'coverage' on Account),
1163
1276
  // default to array to avoid incorrect narrowing.
1164
1277
  // Skip adding [] if fieldType already contains array syntax (e.g., from identifier/extension union types)
1165
- const baseArrKnown = baseFieldByLastSegment.get(fieldName)?.isArray;
1278
+ const baseArrKnown = getBaseFldForContext(fieldName, fieldParts, parentInterfaceName)?.isArray;
1166
1279
  const defaultArrayForKnownRepeats = (!isNested && fieldName === 'coverage' && baseResource === 'Account');
1167
1280
  const alreadyHasArraySyntax = fieldType.endsWith('[]');
1168
1281
  // For nested contexts (or when extending FHIR types), check if the parent type is a known FHIR type with known field cardinality
@@ -1174,6 +1287,8 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
1174
1287
  const isArrayOut = alreadyHasArraySyntax ? false : ((baseArrKnown !== undefined) ? baseArrKnown :
1175
1288
  (fhirParentArrayKnown !== undefined) ? fhirParentArrayKnown :
1176
1289
  (field.isArray || defaultArrayForKnownRepeats));
1290
+ // Final primitive normalization to ensure uri/url/etc are mapped to string
1291
+ fieldType = mapTypeToTS(fieldType);
1177
1292
  const line = `${fieldName}${field.isOptional ? '?' : ''}: ${fieldType}${isArrayOut ? '[]' : ''};`;
1178
1293
  debug(`About to check line for ${field.name}: fieldType=${fieldType}, line="${line}"`);
1179
1294
  if (parentInterfaceName === interfaceName && writtenLines.has(line)) {
@@ -1308,7 +1423,7 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
1308
1423
  }
1309
1424
  // lines preserved; accidental duplicates removed
1310
1425
  function isFhirType(type) {
1311
- return fhirInterfaceNames.includes(type);
1426
+ return type === 'FhirResource' || fhirInterfaceNames.includes(type);
1312
1427
  }
1313
1428
  // Returns true if a field on a known FHIR type is an array
1314
1429
  function isFhirFieldArray(parentType, fieldName) {
@@ -1338,6 +1453,69 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
1338
1453
  return undefined; // unknown parent type
1339
1454
  return arrayFields.has(fieldName);
1340
1455
  }
1456
+ // Returns the known FHIR child type for a given parent FHIR type and field name, to preserve correct types in nested contexts
1457
+ function inferFhirChildType(parentType, fieldName) {
1458
+ // Prefer data-driven map if available
1459
+ const parentSan = sanitizeIdentifier(parentType);
1460
+ const fromMap = fhirChildTypeMap?.get(parentSan)?.get(fieldName)?.type;
1461
+ if (fromMap) {
1462
+ // Normalize primitives like uri/url/canonical to string
1463
+ return mapTypeToTS(fromMap);
1464
+ }
1465
+ // Fallback minimal hardcoded hints for common types
1466
+ const map = {
1467
+ Identifier: {
1468
+ type: 'CodeableConcept',
1469
+ period: 'Period',
1470
+ assigner: 'Reference',
1471
+ system: 'string',
1472
+ value: 'string',
1473
+ use: 'string',
1474
+ },
1475
+ Reference: {
1476
+ identifier: 'Identifier',
1477
+ type: 'string',
1478
+ reference: 'string',
1479
+ display: 'string',
1480
+ },
1481
+ CodeableConcept: {
1482
+ coding: 'Coding',
1483
+ text: 'string',
1484
+ },
1485
+ Coding: {
1486
+ system: 'string',
1487
+ version: 'string',
1488
+ code: 'string',
1489
+ display: 'string',
1490
+ userSelected: 'boolean',
1491
+ },
1492
+ Period: {
1493
+ start: 'string',
1494
+ end: 'string',
1495
+ },
1496
+ HumanName: {
1497
+ use: 'string',
1498
+ text: 'string',
1499
+ family: 'string',
1500
+ given: 'string',
1501
+ prefix: 'string',
1502
+ suffix: 'string',
1503
+ },
1504
+ Address: {
1505
+ use: 'string',
1506
+ type: 'string',
1507
+ text: 'string',
1508
+ city: 'string',
1509
+ district: 'string',
1510
+ state: 'string',
1511
+ postalCode: 'string',
1512
+ country: 'string',
1513
+ period: 'Period',
1514
+ line: 'string',
1515
+ },
1516
+ };
1517
+ return map[parentSan]?.[fieldName];
1518
+ }
1341
1519
  if (baseResource && !isPrimitiveType(baseResource) && sanitizeIdentifier(baseResource) !== 'Base') {
1342
1520
  const sanitizedBase = sanitizeIdentifier(baseResource);
1343
1521
  // Skip custom German coverage base pseudo-types that we cannot resolve
@@ -1384,7 +1562,7 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
1384
1562
  const containerPath = f.name.replace(/\.extension$/, '');
1385
1563
  const arr = byContainer.get(containerPath) || [];
1386
1564
  for (const url of f.profileUrls) {
1387
- const alias = sanitizeIdentifier(url.split('/').pop() || 'ExtensionSlice');
1565
+ const alias = toPascalCase(sanitizeIdentifier(url.split('/').pop() || 'ExtensionSlice'));
1388
1566
  arr.push({ url, alias });
1389
1567
  }
1390
1568
  byContainer.set(containerPath, arr);
@@ -1641,7 +1819,7 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
1641
1819
  const aliases = [];
1642
1820
  for (const sl of rootExtSlices) {
1643
1821
  for (const url of sl.profileUrls || []) {
1644
- const alias = sanitizeIdentifier(url.split('/').pop() || 'ExtensionSlice');
1822
+ const alias = toPascalCase(sanitizeIdentifier(url.split('/').pop() || 'ExtensionSlice'));
1645
1823
  if (!generatedAliasTypes.has(alias)) {
1646
1824
  interfaces.unshift(`export interface ${alias} extends Extension { url: '${url}' }`);
1647
1825
  generatedAliasTypes.add(alias);
@@ -1711,7 +1889,13 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
1711
1889
  for (const typeName of allFhirTypes) {
1712
1890
  if (existingProfiles.has(typeName)) {
1713
1891
  logger.log('[DEBUG local type]', { typeName });
1714
- localTypes.push(typeName);
1892
+ // Never move FhirResource to local imports; it must stay from fhir/r4
1893
+ if (typeName !== 'FhirResource') {
1894
+ localTypes.push(typeName);
1895
+ }
1896
+ else {
1897
+ remainingFhirTypes.push(typeName);
1898
+ }
1715
1899
  }
1716
1900
  else {
1717
1901
  logger.log('[DEBUG fhir type]', { typeName });
@@ -1,9 +1,23 @@
1
1
  import axios from "axios";
2
2
  import path from "path";
3
3
  import fs from "fs";
4
- import { logger } from "../logger";
5
4
  // Cache directory for fetched FHIR resources
6
5
  const CACHE_DIR = path.join(process.cwd(), ".cache", "fhir-resources");
6
+ // Local StructureDefinitions indexed by canonical URL for fast lookup
7
+ const localStructureDefinitions = new Map();
8
+ /**
9
+ * Register local StructureDefinitions to enable resolution before HTTP fetch
10
+ */
11
+ export function registerLocalStructureDefinitions(structureDefinitions) {
12
+ localStructureDefinitions.clear();
13
+ for (const sd of structureDefinitions) {
14
+ if (sd.url) {
15
+ localStructureDefinitions.set(sd.url, sd);
16
+ console.debug(`[sdParser] Registered local SD: ${sd.url}`);
17
+ }
18
+ }
19
+ console.log(`[sdParser] Registered ${localStructureDefinitions.size} local StructureDefinitions`);
20
+ }
7
21
  function ensureCacheDir() {
8
22
  if (!fs.existsSync(CACHE_DIR)) {
9
23
  fs.mkdirSync(CACHE_DIR, { recursive: true });
@@ -19,6 +33,11 @@ function urlToFilename(url) {
19
33
  * Fetch a StructureDefinition from cache or network
20
34
  */
21
35
  export async function fetchStructureDefinition(url, fallbackUrl) {
36
+ // First, check if we have this SD registered locally (from input directory)
37
+ if (localStructureDefinitions.has(url)) {
38
+ console.debug(`[fetchSD] Found local StructureDefinition for ${url}`);
39
+ return localStructureDefinitions.get(url);
40
+ }
22
41
  const cacheFile = path.join(CACHE_DIR, urlToFilename(url));
23
42
  // Try to load from cache first
24
43
  if (fs.existsSync(cacheFile)) {
@@ -180,11 +199,11 @@ export async function parseStructureDefinition(structureDefinition, fhirServerUr
180
199
  const baseField = baseFields.find(f => f.name === element.path);
181
200
  if (baseField) {
182
201
  // Use base resource's array flag when profile doesn't override max
183
- logger.log(`[sdParser] Using base cardinality for ${element.path}: baseField.isArray=${baseField.isArray}, element.max=${element.max}`);
202
+ console.log(`[sdParser] Using base cardinality for ${element.path}: baseField.isArray=${baseField.isArray}, element.max=${element.max}`);
184
203
  isArray = baseField.isArray;
185
204
  }
186
205
  else {
187
- logger.log(`[sdParser] No base field found for ${element.path}, using calculated isArray=${isArray}`);
206
+ console.log(`[sdParser] No base field found for ${element.path}, using calculated isArray=${isArray}`);
188
207
  }
189
208
  }
190
209
  const rawMax = element.max ?? '';
@@ -303,6 +322,15 @@ export async function parseStructureDefinition(structureDefinition, fhirServerUr
303
322
  max,
304
323
  typeOptions: typeOptions.length ? typeOptions : undefined,
305
324
  };
325
+ // Debug logging for mustSupport
326
+ if (process.env.DEBUG_FHIR_GEN === 'true' && (element.path?.includes('identifier') || element.path?.includes('status') || element.path?.includes('category'))) {
327
+ console.log(`[sdParser] Creating field for ${element.path}:`, {
328
+ elementMustSupport: element.mustSupport,
329
+ fieldMustSupport: field.mustSupport,
330
+ isOptional: field.isOptional,
331
+ min: element.min,
332
+ });
333
+ }
306
334
  return field;
307
335
  });
308
336
  return { newFields, oldFields: baseFields };
@@ -319,3 +347,34 @@ export async function fetchStructureDefinitions(fhirServerUrl) {
319
347
  }
320
348
  return resources;
321
349
  }
350
+ /**
351
+ * Build a child field map for core FHIR types using official StructureDefinitions.
352
+ * Returns a Map where key is parent type name (e.g., 'Identifier') and value is a Map of
353
+ * child field name -> { type, isArray } based on the base (snapshot) definition.
354
+ */
355
+ export async function buildFhirChildTypeMap(typeNames, fhirVersion = 'R4') {
356
+ const result = new Map();
357
+ for (const typeName of typeNames) {
358
+ try {
359
+ const sdUrl = `http://hl7.org/fhir/${fhirVersion}/StructureDefinition/${typeName}`;
360
+ const sd = await fetchStructureDefinition(sdUrl, '');
361
+ if (!sd)
362
+ continue;
363
+ const { newFields } = await parseStructureDefinition(sd, '');
364
+ const map = new Map();
365
+ for (const f of newFields) {
366
+ // Interested only in direct children of the type (e.g., Identifier.system)
367
+ const parts = f.name.split('.');
368
+ if (parts.length === 2 && parts[0] === typeName) {
369
+ const child = parts[1];
370
+ map.set(child, { type: f.type || 'any', isArray: f.isArray });
371
+ }
372
+ }
373
+ result.set(typeName, map);
374
+ }
375
+ catch {
376
+ // Best-effort; skip on failure
377
+ }
378
+ }
379
+ return result;
380
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "babelfhir-ts",
3
- "version": "1.0.12",
3
+ "version": "1.0.13",
4
4
  "description": "BabelFHIR-TS: generate TypeScript interfaces, validators, and helper classes from FHIR R4 StructureDefinitions (profiles) directly inside package archives.",
5
5
  "type": "module",
6
6
  "main": "out/main.js",