babelfhir-ts 1.5.21 → 1.6.0

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 (33) hide show
  1. package/README.md +6 -3
  2. package/out/src/generator/core/extensionSdReader.js +2 -1
  3. package/out/src/generator/core/utils.js +69 -0
  4. package/out/src/generator/emitters/class/classGeneratorHelpers.js +10 -2
  5. package/out/src/generator/emitters/class/sliceElementDefaults.js +10 -1
  6. package/out/src/generator/emitters/interface/interfaceFieldProcessor.js +15 -12
  7. package/out/src/generator/emitters/interface/interfaceFieldUtils.js +6 -3
  8. package/out/src/generator/emitters/interface/postProcessExtensions.js +8 -2
  9. package/out/src/generator/emitters/interface/processNestedField.js +4 -6
  10. package/out/src/generator/emitters/namingsystem/namingSystemGenerator.js +136 -0
  11. package/out/src/generator/emitters/prefab/prefabEmitter.js +2 -15
  12. package/out/src/generator/emitters/prefab/prefabRenderer.js +2 -19
  13. package/out/src/generator/emitters/validator/closedSlicingValidation.js +5 -2
  14. package/out/src/generator/emitters/validator/fhirpathStubInstaller.js +7 -1
  15. package/out/src/generator/emitters/validator/sliceBackboneValidation.js +27 -12
  16. package/out/src/generator/emitters/validator/sliceValidatorGenerator.js +15 -27
  17. package/out/src/generator/emitters/validator/sliceValidatorUtils.js +5 -1
  18. package/out/src/generator/emitters/validator/subExtensionScope.js +10 -2
  19. package/out/src/generator/emitters/validator/validatorExpressions.js +44 -0
  20. package/out/src/generator/emitters/validator/validatorFieldBuilders.js +34 -17
  21. package/out/src/generator/emitters/validator/validatorGenerator.js +31 -40
  22. package/out/src/generator/emitters/validator/validatorRuntime.js +13 -2
  23. package/out/src/generator/emitters/valueset/valueSetGenerator.js +6 -10
  24. package/out/src/generator/emitters/zod/zodRefinementBuilder.js +39 -3
  25. package/out/src/generator/emitters/zod/zodSchemaGenerator.js +36 -8
  26. package/out/src/generator/generationHelpers.js +59 -7
  27. package/out/src/generator/index.js +90 -47
  28. package/out/src/generator/parser/packageParser.js +76 -0
  29. package/out/src/generator/parser/vsParser.js +4 -2
  30. package/out/src/generator/sdProcessor.js +30 -21
  31. package/out/src/main.js +5 -0
  32. package/package.json +12 -9
  33. package/parity-matrix.json +6 -0
@@ -3,6 +3,7 @@
3
3
  * and required ValueSet bindings.
4
4
  */
5
5
  import { sanitizeValueSetName } from '../valueset/valueSetGenerator.js';
6
+ import { scopeConstraintExpression } from '../validator/validatorExpressions.js';
6
7
  /** Escape double-quote characters in refinement messages */
7
8
  function escStr(s) {
8
9
  return s.replace(/"/g, '\\"');
@@ -11,8 +12,11 @@ function escStr(s) {
11
12
  * Build refinement chains for pattern constraints, required slice cardinalities,
12
13
  * and FHIRPath invariants. Returns refinement code strings plus tracked constraint metadata.
13
14
  */
14
- export function buildRefinements(allFields, rootPrefix, valueSets) {
15
+ export function buildRefinements(allFields, rootPrefix, valueSets,
16
+ /** See generateZodSchema's `emitInvariants`: opt-in, for the per-parse cost. */
17
+ emitInvariants = false) {
15
18
  const refinements = [];
19
+ const invariantEvaluators = [];
16
20
  const bindingVsImports = new Map();
17
21
  // Build set of direct (root-level) field names for safety checks
18
22
  const directFieldNames = new Set();
@@ -163,10 +167,42 @@ export function buildRefinements(allFields, rootPrefix, valueSets) {
163
167
  unsupportedConstraints.push(meta);
164
168
  }
165
169
  else {
166
- // Supported but not yet emitted — future FHIRPath→JS translation point
167
170
  supportedConstraints.push(meta);
168
171
  }
169
172
  }
173
+ // Emit the supported invariants.
174
+ //
175
+ // These were tracked and never emitted, on the premise that FHIRPath
176
+ // evaluation is async while Zod refinements are synchronous. Neither half
177
+ // holds: fhirpath.js returns an array and only returns a Promise when async
178
+ // mode is opted into, which it needs solely for memberOf()/resolve() — and
179
+ // those are already in `unsupportedConstraints` above. So every constraint
180
+ // here evaluates synchronously inside a plain refinement, and `safeParse`
181
+ // keeps working for consumers.
182
+ //
183
+ // The expression goes through the same scoping the validator uses, and the
184
+ // message repeats its wording, so the two agree per profile rather than
185
+ // reporting the same defect under different text.
186
+ supportedConstraints.forEach((meta, i) => {
187
+ if (!emitInvariants)
188
+ return;
189
+ const scoped = scopeConstraintExpression(meta.expression, meta.fieldPath);
190
+ if (!scoped)
191
+ return;
192
+ const human = escStr(meta.human).replace(/[\r\n]+/g, ' ');
193
+ const fn = `_inv${i}`;
194
+ // Compiled once at module load, not per parse. `evaluate` re-parses the
195
+ // expression on every call, which cost ~0.3ms each: 69 invariants made a
196
+ // C4BBOrganization parse 242x slower on an empty resource and 964x on a
197
+ // populated one. `compile` parses once and returns a reusable evaluator.
198
+ //
199
+ // traceFn is silenced deliberately: fhirpath's trace() defaults to writing
200
+ // to the console, so a schema parse would print "TRACE:[…]" out of a
201
+ // published package.
202
+ invariantEvaluators.push(`const ${fn} = fhirpath.compile("${scoped}", fhirpath_model, { preciseMath: true, traceFn: () => {} });`);
203
+ refinements.push(`.refine(d => { try { const _r = ${fn}(d, { resource: d, rootResource: d }); return Array.isArray(_r) ? _r.every(Boolean) : true; } catch { return true; } },`
204
+ + ` { message: "Constraint violation: ${human}", when: () => true })`);
205
+ });
170
206
  // 4. Meta sub-field constraints
171
207
  // When a profile constrains meta.lastUpdated, meta.profile, etc. with min>=1,
172
208
  // emit refinements that check for their presence. Uses `when: () => true` so
@@ -253,5 +289,5 @@ export function buildRefinements(allFields, rootPrefix, valueSets) {
253
289
  }
254
290
  }
255
291
  }
256
- return { refinements, supportedConstraints, unsupportedConstraints, valueSetImports: bindingVsImports };
292
+ return { refinements, supportedConstraints, unsupportedConstraints, valueSetImports: bindingVsImports, invariantEvaluators };
257
293
  }
@@ -53,7 +53,16 @@ function isPrimitive(fhirType) {
53
53
  * @param baseFields Fields inherited from the base resource
54
54
  * @param valueSets Optional parsed ValueSets for enum generation
55
55
  */
56
- export function generateZodSchema(interfaceName, newFields, baseResource, baseFields = [], valueSets) {
56
+ export function generateZodSchema(interfaceName, newFields, baseResource, baseFields = [], valueSets,
57
+ /**
58
+ * Emit the FHIRPath invariants as refinements.
59
+ *
60
+ * Off by default: each invariant is a compiled FHIRPath evaluation on every
61
+ * parse (~5.5us, so +0.38ms across C4BBOrganization's 69), which is the right
62
+ * trade for conformance checking and the wrong one for per-request validation
63
+ * in a hot path. The caller decides.
64
+ */
65
+ emitInvariants = false) {
57
66
  const lines = [];
58
67
  const imports = new Set();
59
68
  const baseTypeImports = new Set();
@@ -113,14 +122,22 @@ export function generateZodSchema(interfaceName, newFields, baseResource, baseFi
113
122
  // Pre-compute minimum cardinality implied by required slices.
114
123
  // If an array field (e.g., extension) has slices with min>=1, the parent array
115
124
  // must contain at least that many elements even if its own min is 0.
125
+ //
126
+ // Keyed by the slice's full path. Keying by the last segment made every
127
+ // `*.extension:slice` anywhere in the resource count towards the *root*
128
+ // `extension`, so PASClaimBase emitted `z.array(ExtensionSchema).min(3)` for a
129
+ // profile whose Claim.extension slices are all min=0.
130
+ //
131
+ // An unstated `min` counts as 0, not 1. A differential states only what it
132
+ // changes, so a slice that says nothing about cardinality inherits the base,
133
+ // and a slice can only become required by stating min>=1 itself.
116
134
  const sliceMinByParent = new Map();
117
135
  for (const f of allFields) {
118
136
  if (!f.sliceName)
119
137
  continue;
120
- const sliceMin = typeof f.min === 'number' ? f.min : (f.isOptional ? 0 : 1);
138
+ const sliceMin = typeof f.min === 'number' ? f.min : 0;
121
139
  if (sliceMin > 0) {
122
- const parentKey = f.name.split('.').pop();
123
- sliceMinByParent.set(parentKey, (sliceMinByParent.get(parentKey) || 0) + sliceMin);
140
+ sliceMinByParent.set(f.name, (sliceMinByParent.get(f.name) || 0) + sliceMin);
124
141
  }
125
142
  }
126
143
  for (const field of schemaFields) {
@@ -181,7 +198,7 @@ export function generateZodSchema(interfaceName, newFields, baseResource, baseFi
181
198
  if (field.isArray) {
182
199
  let minItems = typeof field.min === 'number' && field.min > 0 ? field.min : 0;
183
200
  // If required slices exist, the parent array needs at least that many items
184
- const sliceImpliedMin = sliceMinByParent.get(fieldName) || 0;
201
+ const sliceImpliedMin = sliceMinByParent.get(field.name) || 0;
185
202
  if (sliceImpliedMin > minItems)
186
203
  minItems = sliceImpliedMin;
187
204
  const maxItems = typeof field.max === 'number' && field.max !== Infinity ? field.max : 0;
@@ -220,7 +237,7 @@ export function generateZodSchema(interfaceName, newFields, baseResource, baseFi
220
237
  }
221
238
  // Make optional or required
222
239
  // A field is required if it has min>=1 OR required slices imply a minimum
223
- const effectiveRequired = !field.isOptional || (sliceMinByParent.get(fieldName) || 0) > 0;
240
+ const effectiveRequired = !field.isOptional || (sliceMinByParent.get(field.name) || 0) > 0;
224
241
  if (!effectiveRequired) {
225
242
  zodType = `${zodType}.optional()`;
226
243
  }
@@ -256,8 +273,8 @@ export function generateZodSchema(interfaceName, newFields, baseResource, baseFi
256
273
  // because DataType random() methods don't produce pattern-aware data.
257
274
  const isDataTypeProfile = getZodPackageTypes().has(rootPrefix);
258
275
  const refinementResult = isDataTypeProfile
259
- ? { refinements: [], supportedConstraints: [], unsupportedConstraints: [], valueSetImports: new Map() }
260
- : buildRefinements(allFields, rootPrefix, valueSets);
276
+ ? { refinements: [], supportedConstraints: [], unsupportedConstraints: [], valueSetImports: new Map(), invariantEvaluators: [] }
277
+ : buildRefinements(allFields, rootPrefix, valueSets, emitInvariants);
261
278
  // Merge ValueSet imports from binding refinements
262
279
  for (const [name, importPath] of refinementResult.valueSetImports) {
263
280
  if (!valueSetImports.has(name)) {
@@ -277,7 +294,18 @@ export function generateZodSchema(interfaceName, newFields, baseResource, baseFi
277
294
  const headerIndex = lines.indexOf(`import { z } from "zod";`);
278
295
  lines.splice(headerIndex + 1, 0, `import { ${usedHelpers.join(', ')} } from "./ValidatorOptions.js";`);
279
296
  }
297
+ // Invariant refinements evaluate FHIRPath synchronously, so the engine and the
298
+ // version's model have to be imported alongside the schema.
299
+ if (refinementResult.invariantEvaluators.length > 0) {
300
+ const headerIndex = lines.indexOf(`import { z } from "zod";`);
301
+ lines.splice(headerIndex + 1, 0, `import fhirpath from "fhirpath";`, `import fhirpath_model from "fhirpath/fhir-context/${versionSlug()}/index.js";`);
302
+ }
280
303
  lines.push('');
304
+ // Compiled invariant evaluators, hoisted above the schema so each FHIRPath
305
+ // expression is parsed once at import instead of on every parse.
306
+ if (refinementResult.invariantEvaluators.length > 0) {
307
+ lines.push(...refinementResult.invariantEvaluators, '');
308
+ }
281
309
  // Emit schema
282
310
  const schemaName = `${interfaceName}Schema`;
283
311
  lines.push(`export const ${schemaName} =`);
@@ -6,6 +6,7 @@ import path from 'path';
6
6
  import fs from 'fs';
7
7
  import { expandValueSetsFromTx, addPlaceholdersForExternalBindings } from './parser/vsParser.js';
8
8
  import { generateValueSetTypeScript, generateValueSetRegistry, assignValueSetNames } from './emitters/valueset/valueSetGenerator.js';
9
+ import { generateNamingSystemTypeScript, generateNamingSystemRegistry, assignNamingSystemNames } from './emitters/namingsystem/namingSystemGenerator.js';
9
10
  import { collectValueSetBindingUrls, clearLocalStructureDefinitions } from './parser/sdParser.js';
10
11
  import { ensureDirectoryExists, sanitizeIdentifier, toPascalCase } from './core/utils.js';
11
12
  import { getCacheConfig, getFhirPackagesCacheDir } from './core/cacheConfig.js';
@@ -183,6 +184,12 @@ export async function expandValueSetsWithTx(valueSets, valueSetCodesMap, structu
183
184
  * Returns whether any files were generated. Handles stale directory cleanup.
184
185
  */
185
186
  export function emitValueSetFiles(valueSets, outputDir) {
187
+ const generated = writeValueSetFiles(valueSets, outputDir);
188
+ if (!generated)
189
+ cleanupStaleSubdir(outputDir, 'valuesets');
190
+ return generated;
191
+ }
192
+ function writeValueSetFiles(valueSets, outputDir) {
186
193
  if (valueSets.size === 0)
187
194
  return false;
188
195
  const valueSetOutputDir = path.join(outputDir, 'valuesets');
@@ -282,17 +289,62 @@ export function enrichValueSetsFromCodeMap(valueSets, valueSetCodesMap, bindingU
282
289
  logger.log(`ValueSet enrichment: ${enrichedCount} enriched, ${createdCount} created from CodeSystem resolution`);
283
290
  }
284
291
  }
285
- /** Remove stale valuesets directory if it exists and no ValueSets were generated. */
286
- export function cleanupStaleValueSetDir(outputDir) {
287
- const vdir = path.join(outputDir, 'valuesets');
292
+ /**
293
+ * Generate TypeScript files for NamingSystems and write them to disk.
294
+ *
295
+ * Mirrors {@link emitValueSetFiles}: an IG's declared identifier systems become
296
+ * typed constants rather than literals retyped by every consumer.
297
+ * Returns whether any files were generated. Handles stale directory cleanup.
298
+ */
299
+ export function emitNamingSystemFiles(namingSystems, outputDir) {
300
+ const generated = writeNamingSystemFiles(namingSystems, outputDir);
301
+ if (!generated)
302
+ cleanupStaleSubdir(outputDir, 'namingsystems');
303
+ return generated;
304
+ }
305
+ function writeNamingSystemFiles(namingSystems, outputDir) {
306
+ if (namingSystems.length === 0)
307
+ return false;
308
+ const nsOutputDir = path.join(outputDir, 'namingsystems');
309
+ let generatedCount = 0;
310
+ const assigned = assignNamingSystemNames(namingSystems);
311
+ for (const { namingSystem, sanitized } of assigned) {
312
+ try {
313
+ if (generatedCount === 0) {
314
+ ensureDirectoryExists(nsOutputDir);
315
+ }
316
+ const { filename, content } = generateNamingSystemTypeScript(namingSystem, { nameOverride: sanitized });
317
+ fs.writeFileSync(path.join(nsOutputDir, filename), content, 'utf-8');
318
+ generatedCount++;
319
+ }
320
+ catch (err) {
321
+ log.error(`Failed to generate NamingSystem ${namingSystem.name}:`, err);
322
+ }
323
+ }
324
+ if (generatedCount === 0)
325
+ return false;
326
+ const registryContent = generateNamingSystemRegistry(namingSystems);
327
+ fs.writeFileSync(path.join(nsOutputDir, 'index.ts'), registryContent, 'utf-8');
328
+ logger.log(`Generated ${generatedCount} NamingSystem files in ${nsOutputDir}`);
329
+ return true;
330
+ }
331
+ /**
332
+ * Remove a generated subdirectory left over from an earlier run.
333
+ *
334
+ * A cached extraction can keep a `valuesets/`/`namingsystems/` folder whose
335
+ * source resources are gone; leaving it behind makes the barrel export a module
336
+ * nothing regenerates.
337
+ */
338
+ function cleanupStaleSubdir(outputDir, subdir) {
339
+ const dir = path.join(outputDir, subdir);
288
340
  try {
289
- if (fs.existsSync(vdir)) {
290
- fs.rmSync(vdir, { recursive: true, force: true });
291
- log.debug(`Removed stale ValueSets directory: ${vdir}`);
341
+ if (fs.existsSync(dir)) {
342
+ fs.rmSync(dir, { recursive: true, force: true });
343
+ log.debug(`Removed stale ${subdir} directory: ${dir}`);
292
344
  }
293
345
  }
294
346
  catch (e) {
295
- log.debug(`Failed to remove stale ValueSets directory ${vdir}: ${e.message}`);
347
+ log.debug(`Failed to remove stale ${subdir} directory ${dir}: ${e.message}`);
296
348
  }
297
349
  }
298
350
  /** Initialize logger and clear local StructureDefinitions for a generation run. */
@@ -1,7 +1,7 @@
1
1
  import path from 'path';
2
2
  import fs from 'fs';
3
3
  import { fileURLToPath } from 'url';
4
- import { extractPackage, readStructureDefinitionsFromDir, readStructureDefinitionsFromDependencies, createPackageFromDir, readValueSetCodesWithDependencies, readValueSetsFromDir, detectFhirVersion, ensureDependenciesDownloaded, readSearchParametersFromDir } from './parser/packageParser.js';
4
+ import { extractPackage, readStructureDefinitionsFromDir, readStructureDefinitionsFromDependencies, createPackageFromDir, readValueSetCodesWithDependencies, readValueSetsFromDir, detectFhirVersion, ensureDependenciesDownloaded, readSearchParametersFromDir, readNamingSystemsFromDir } from './parser/packageParser.js';
5
5
  import { fetchStructureDefinitions, fetchStructureDefinition, registerLocalStructureDefinitions, clearLocalStructureDefinitions, collectValueSetBindingUrls } from './parser/sdParser.js';
6
6
  import { ensureDirectoryExists, downloadFile } from './core/utils.js';
7
7
  import { getFhirPackagesCacheDir } from './core/cacheConfig.js';
@@ -13,7 +13,7 @@ import { DEFAULT_FHIR_VERSION } from './fhir/types.js';
13
13
  import { FHIR_VERSIONS } from './fhir/versionRegistry.js';
14
14
  import { ensureCorePackage } from './fhir/corePackageResolver.js';
15
15
  import { spawn } from 'child_process';
16
- import { buildProfileRegistries, expandValueSetsWithTx, emitValueSetFiles, cleanupStaleValueSetDir, initGenerationContext, enrichValueSetsFromCodeMap, collectReferencedDependencyProfiles, } from './generationHelpers.js';
16
+ import { buildProfileRegistries, expandValueSetsWithTx, emitValueSetFiles, emitNamingSystemFiles, initGenerationContext, enrichValueSetsFromCodeMap, collectReferencedDependencyProfiles, } from './generationHelpers.js';
17
17
  /** Resolve the babelfhir-ts CLI version from its own package.json (used to stamp generated packages). */
18
18
  const __generatorDir = path.dirname(fileURLToPath(import.meta.url));
19
19
  const _generatorPkgPath = [
@@ -218,7 +218,62 @@ async function compileTypeScriptToJS(dir) {
218
218
  });
219
219
  });
220
220
  }
221
- /** Generate index.ts that exports all generated interfaces and classes */
221
+ /**
222
+ * Output directories that only some generation runs produce.
223
+ *
224
+ * Single source of truth for both the generated `package.json` exports map and
225
+ * the root index barrel. These were three separate lists — two identical
226
+ * `exports` blocks plus a hardcoded check in the barrel writer — so registering
227
+ * an emitter meant remembering all three, and reaching a second entry module
228
+ * meant naming it literally (`./dicomweb/cornerstone`). `modules` covers any
229
+ * module a folder emits, so nothing here names one.
230
+ */
231
+ const OPTIONAL_OUTPUT_DIRS = [
232
+ { dir: 'fhir-client', barrel: true },
233
+ // dicomweb writes a second entry module (cornerstone) beside its barrel.
234
+ { dir: 'dicomweb', barrel: true, modules: true },
235
+ { dir: 'prefab', barrel: true },
236
+ { dir: 'valuesets', modules: true, rootReExport: true },
237
+ { dir: 'namingsystems', modules: true, rootReExport: true },
238
+ ];
239
+ /**
240
+ * Add subpath exports for the optional output directories that were created.
241
+ *
242
+ * Shared by generateIntoPackage() and generateIntoPackageDirect(), which
243
+ * previously carried identical copies of this block.
244
+ */
245
+ function addOptionalSubpathExports(outputDir, generatedExports) {
246
+ for (const { dir, barrel, modules } of OPTIONAL_OUTPUT_DIRS) {
247
+ if (!fs.existsSync(path.join(outputDir, dir)))
248
+ continue;
249
+ if (barrel) {
250
+ generatedExports[`./${dir}`] = { types: `./${dir}/index.d.ts`, import: `./${dir}/index.js` };
251
+ }
252
+ if (modules) {
253
+ generatedExports[`./${dir}/*`] = { types: `./${dir}/*.d.ts`, import: `./${dir}/*.js` };
254
+ }
255
+ }
256
+ }
257
+ /**
258
+ * Put the extraction root's `package.json` back the way it was found.
259
+ *
260
+ * The generated manifest is staged there only so `createPackageFromDir` puts it
261
+ * at the tarball root; it must not outlive the repack when the extraction root
262
+ * is a shared cache entry.
263
+ */
264
+ export function restoreRootManifest(rootPackageJsonPath, prior) {
265
+ try {
266
+ if (prior) {
267
+ fs.writeFileSync(rootPackageJsonPath, prior);
268
+ }
269
+ else {
270
+ fs.rmSync(rootPackageJsonPath, { force: true });
271
+ }
272
+ }
273
+ catch (err) {
274
+ log.warn(`Could not restore ${rootPackageJsonPath}: ${err.message}`);
275
+ }
276
+ }
222
277
  async function generateIndexFile(dir) {
223
278
  const files = fs.readdirSync(dir, { withFileTypes: true });
224
279
  const exports = [];
@@ -279,10 +334,11 @@ async function generateIndexFile(dir) {
279
334
  }
280
335
  }
281
336
  }
282
- // Also export from valuesets subfolder if it exists
283
- const valueSetsDir = path.join(dir, 'valuesets');
284
- if (fs.existsSync(valueSetsDir)) {
285
- exports.push(`export * from './valuesets/index.js';`);
337
+ // Re-export the optional subfolders that opt into it (see OPTIONAL_OUTPUT_DIRS)
338
+ for (const { dir: subdir } of OPTIONAL_OUTPUT_DIRS.filter(d => d.rootReExport)) {
339
+ if (fs.existsSync(path.join(dir, subdir))) {
340
+ exports.push(`export * from './${subdir}/index.js';`);
341
+ }
286
342
  }
287
343
  // Export shared ValidatorOptions type
288
344
  if (fs.existsSync(path.join(dir, 'ValidatorOptions.ts'))) {
@@ -450,10 +506,8 @@ export async function generateIntoPackage(packageArchivePath, outArchivePath, fl
450
506
  ensureDirectoryExists(outputDir);
451
507
  // Generate TypeScript files for ValueSets inside the embedded package
452
508
  endPhase = startPhase('generate');
453
- const didGenerateValueSets = emitValueSetFiles(valueSets, outputDir);
454
- if (!didGenerateValueSets) {
455
- cleanupStaleValueSetDir(outputDir);
456
- }
509
+ emitValueSetFiles(valueSets, outputDir);
510
+ emitNamingSystemFiles(readNamingSystemsFromDir(extractedRoot), outputDir);
457
511
  // Build profile registries from BOTH main and dependency SDs so delegation can resolve names
458
512
  const { existingStructureDefinitions, profileIdToName, profileUrlToName, profileUrlToType } = buildProfileRegistries(allSDs, fhirInterfaceNames);
459
513
  // Register all local StructureDefinitions for resolution before HTTP fetches
@@ -543,20 +597,7 @@ export async function generateIntoPackage(packageArchivePath, outArchivePath, fl
543
597
  const { finalizePrefabOutput } = await import('./emitters/prefab/prefabEmitter.js');
544
598
  finalizePrefabOutput(outputDir, { prefabStylesPath: flags.prefabStyles });
545
599
  }
546
- // Only add subpath exports if their directories were actually created
547
- if (fs.existsSync(path.join(outputDir, 'fhir-client'))) {
548
- generatedExports['./fhir-client'] = { types: './fhir-client/index.d.ts', import: './fhir-client/index.js' };
549
- }
550
- if (fs.existsSync(path.join(outputDir, 'dicomweb'))) {
551
- generatedExports['./dicomweb'] = { types: './dicomweb/index.d.ts', import: './dicomweb/index.js' };
552
- generatedExports['./dicomweb/cornerstone'] = { types: './dicomweb/cornerstone.d.ts', import: './dicomweb/cornerstone.js' };
553
- }
554
- if (fs.existsSync(path.join(outputDir, 'prefab'))) {
555
- generatedExports['./prefab'] = { types: './prefab/index.d.ts', import: './prefab/index.js' };
556
- }
557
- if (fs.existsSync(path.join(outputDir, 'valuesets'))) {
558
- generatedExports['./valuesets/*'] = { types: './valuesets/*.d.ts', import: './valuesets/*.js' };
559
- }
600
+ addOptionalSubpathExports(outputDir, generatedExports);
560
601
  const generatedPackageJsonPath = path.join(outputDir, 'package.json');
561
602
  fs.writeFileSync(generatedPackageJsonPath, JSON.stringify(generatedPackageJson, null, 2));
562
603
  logger.log(`Created package.json in generated folder: ${packageName}-generated@${packageVersion}`);
@@ -611,14 +652,30 @@ export async function generateIntoPackage(packageArchivePath, outArchivePath, fl
611
652
  fs.rmdirSync(nodeModulesDir);
612
653
  }
613
654
  catch { /* not empty or doesn't exist — fine */ }
614
- // Copy package.json to root of extractedRoot so it's at the tarball root
655
+ // Copy package.json to root of extractedRoot so it's at the tarball root.
656
+ //
657
+ // extractedRoot is the shared FHIR cache entry whenever the package was
658
+ // already cached (see shouldCleanup), so this file is not ours to keep: a
659
+ // package installed flat — no `package/` subdir, as `babelfhir-ts install`
660
+ // produces for a locally built IG — keeps its own manifest right here, and
661
+ // leaving the generated one behind makes the next run read `@types/fhir`
662
+ // and friends as FHIR package specs. Restore the previous state once the
663
+ // archive is written.
615
664
  const rootPackageJsonPath = path.join(extractedRoot, 'package.json');
665
+ const priorRootManifest = fs.existsSync(rootPackageJsonPath)
666
+ ? fs.readFileSync(rootPackageJsonPath)
667
+ : undefined;
616
668
  fs.copyFileSync(generatedPackageJsonPath, rootPackageJsonPath);
617
669
  logger.log('Copied package.json to tarball root');
618
670
  const finalArchive = outArchivePath || deriveOutputArchiveName(packageArchivePath);
619
671
  endPhase = startPhase('repack');
620
- await createPackageFromDir(extractedRoot, finalArchive);
621
- endPhase(); // repack
672
+ try {
673
+ await createPackageFromDir(extractedRoot, finalArchive);
674
+ }
675
+ finally {
676
+ endPhase(); // repack
677
+ restoreRootManifest(rootPackageJsonPath, priorRootManifest);
678
+ }
622
679
  // Write timing summary to output directory
623
680
  const timing = getTimingSummary();
624
681
  fs.writeFileSync(path.join(outputDir, 'generation-timing.json'), JSON.stringify(timing, null, 2));
@@ -692,9 +749,8 @@ export async function generateIntoPackageDirect(packageArchivePath, flags) {
692
749
  }
693
750
  ensureDirectoryExists(outputDir);
694
751
  endPhase = startPhase('generate');
695
- const didGenerateValueSets = emitValueSetFiles(valueSets, outputDir);
696
- if (!didGenerateValueSets)
697
- cleanupStaleValueSetDir(outputDir);
752
+ emitValueSetFiles(valueSets, outputDir);
753
+ emitNamingSystemFiles(readNamingSystemsFromDir(extractedRoot), outputDir);
698
754
  const { existingStructureDefinitions, profileIdToName, profileUrlToName, profileUrlToType } = buildProfileRegistries(allSDs, fhirInterfaceNames);
699
755
  registerLocalStructureDefinitions(allSDs);
700
756
  // PackageManager already loaded and registered dependency SDs above —
@@ -775,19 +831,7 @@ export async function generateIntoPackageDirect(packageArchivePath, flags) {
775
831
  finalizePrefabOutput(outputDir, { prefabStylesPath: flags.prefabStyles });
776
832
  }
777
833
  // Only add subpath exports if their directories were actually created
778
- if (fs.existsSync(path.join(outputDir, 'fhir-client'))) {
779
- generatedExports['./fhir-client'] = { types: './fhir-client/index.d.ts', import: './fhir-client/index.js' };
780
- }
781
- if (fs.existsSync(path.join(outputDir, 'dicomweb'))) {
782
- generatedExports['./dicomweb'] = { types: './dicomweb/index.d.ts', import: './dicomweb/index.js' };
783
- generatedExports['./dicomweb/cornerstone'] = { types: './dicomweb/cornerstone.d.ts', import: './dicomweb/cornerstone.js' };
784
- }
785
- if (fs.existsSync(path.join(outputDir, 'prefab'))) {
786
- generatedExports['./prefab'] = { types: './prefab/index.d.ts', import: './prefab/index.js' };
787
- }
788
- if (fs.existsSync(path.join(outputDir, 'valuesets'))) {
789
- generatedExports['./valuesets/*'] = { types: './valuesets/*.d.ts', import: './valuesets/*.js' };
790
- }
834
+ addOptionalSubpathExports(outputDir, generatedExports);
791
835
  const generatedPackageJsonPath = path.join(outputDir, 'package.json');
792
836
  fs.writeFileSync(generatedPackageJsonPath, JSON.stringify(generatedPackageJson, null, 2));
793
837
  logger.log(`Created package.json in generated folder: ${packageName}-generated@${packageVersion}`);
@@ -918,9 +962,8 @@ export async function generateForDirectory(inputDir, outputDir, flags) {
918
962
  }
919
963
  // Generate TypeScript files for ValueSets
920
964
  endPhase = startPhase('generate');
921
- if (!emitValueSetFiles(valueSets, outputDir)) {
922
- cleanupStaleValueSetDir(outputDir);
923
- }
965
+ emitValueSetFiles(valueSets, outputDir);
966
+ emitNamingSystemFiles(readNamingSystemsFromDir(inputDir), outputDir);
924
967
  const { existingStructureDefinitions, profileIdToName, profileUrlToName, profileUrlToType } = buildProfileRegistries(allLocalSDs, fhirInterfaceNames);
925
968
  // Register all local StructureDefinitions for resolution before HTTP fetches
926
969
  registerLocalStructureDefinitions(allLocalSDs);
@@ -498,6 +498,20 @@ export function readValueSetCodesFromDir(extractedRoot, codeSystemMap) {
498
498
  }
499
499
  return map;
500
500
  }
501
+ /**
502
+ * Whether a `dependencies` entry names a FHIR package at a concrete version.
503
+ *
504
+ * A FHIR IG manifest lists unscoped names pinned to exact versions. An npm
505
+ * scope or a semver range means the manifest being read is not an IG's — and
506
+ * the cache path built from it is unwritable (`<cache>/@types/fhir->=0.0.44
507
+ * <0.1.0.tgz`), which surfaced as an unhandled rejection from the download
508
+ * stream and took the whole run down instead of skipping one entry.
509
+ */
510
+ export function isFhirPackageSpec(depName, depVersion) {
511
+ if (depName.startsWith('@'))
512
+ return false;
513
+ return /^[A-Za-z0-9][A-Za-z0-9.\-+]*$/.test(depVersion);
514
+ }
501
515
  /**
502
516
  * Recursively ensure all dependency packages are downloaded and extracted.
503
517
  * Walks the dependency tree from an extracted package's package.json,
@@ -508,6 +522,10 @@ export async function ensureDependenciesDownloaded(extractedRoot) {
508
522
  ensureCacheDir(cacheDir);
509
523
  const visited = new Set();
510
524
  async function downloadAndExtract(depName, depVersion) {
525
+ if (!isFhirPackageSpec(depName, depVersion)) {
526
+ log.warn(`Ignoring dependency that is not a FHIR package spec: ${depName}@${depVersion}`);
527
+ return null;
528
+ }
511
529
  const depDir = path.join(cacheDir, `${depName}@${depVersion}`);
512
530
  // Already cached (@ separator)?
513
531
  if (fs.existsSync(depDir) && fs.readdirSync(depDir).length > 0) {
@@ -824,6 +842,64 @@ export function readValueSetsFromDir(extractedRoot) {
824
842
  }
825
843
  return map;
826
844
  }
845
+ /**
846
+ * Read all NamingSystem resources from an extracted package directory.
847
+ *
848
+ * Entries with no `uniqueId` are dropped: there is nothing to emit for them.
849
+ */
850
+ export function readNamingSystemsFromDir(extractedRoot) {
851
+ const jsonFiles = findJsonFilesByPrefix(extractedRoot, 'NamingSystem-');
852
+ const found = [];
853
+ let skippedRetired = 0;
854
+ let skippedNoId = 0;
855
+ for (const file of jsonFiles) {
856
+ try {
857
+ const content = JSON.parse(fs.readFileSync(file, 'utf-8'));
858
+ if (content.resourceType !== 'NamingSystem')
859
+ continue;
860
+ if (content.status === 'retired') {
861
+ skippedRetired++;
862
+ continue;
863
+ }
864
+ const uniqueIds = (Array.isArray(content.uniqueId) ? content.uniqueId : [])
865
+ .filter((u) => typeof u?.value === 'string' && u.value.length > 0)
866
+ .map((u) => ({
867
+ type: u.type || 'other',
868
+ value: u.value,
869
+ ...(u.preferred !== undefined && { preferred: u.preferred }),
870
+ ...(u.comment && { comment: u.comment }),
871
+ }));
872
+ // Destructured rather than length-checked: this is what proves the tuple
873
+ // shape to the compiler, so the emitters need no undefined handling.
874
+ const [firstId, ...restIds] = uniqueIds;
875
+ if (!firstId) {
876
+ skippedNoId++;
877
+ continue;
878
+ }
879
+ const name = content.name || content.title || content.id;
880
+ if (!name) {
881
+ skippedNoId++;
882
+ continue;
883
+ }
884
+ found.push({
885
+ name,
886
+ ...(content.title && { title: content.title }),
887
+ ...(content.description && { description: content.description }),
888
+ ...(content.kind && { kind: content.kind }),
889
+ uniqueIds: [firstId, ...restIds],
890
+ });
891
+ log.debug(`Loaded NamingSystem: ${name} (${uniqueIds.length} uniqueId entries)`);
892
+ }
893
+ catch (err) {
894
+ log.debug(`Skipping invalid JSON while reading NamingSystems ${file}: ${err.message}`);
895
+ }
896
+ }
897
+ // Say what was dropped: a silently shorter list reads as "the IG declares none".
898
+ if (skippedRetired > 0 || skippedNoId > 0) {
899
+ log.info(`NamingSystems: emitting ${found.length}, skipped ${skippedRetired} retired and ${skippedNoId} without a usable uniqueId`);
900
+ }
901
+ return found;
902
+ }
827
903
  /**
828
904
  * Read all SearchParameter resources from a package directory.
829
905
  * Returns a map of base resource type → array of search parameter definitions.
@@ -118,7 +118,9 @@ export function getUniformSystem(valueSet) {
118
118
  if (valueSet.concepts.length === 0) {
119
119
  return null;
120
120
  }
121
- const firstSystem = valueSet.concepts[0].system;
121
+ const firstSystem = valueSet.concepts[0]?.system;
122
+ if (firstSystem === undefined)
123
+ return null;
122
124
  const allSameSystem = valueSet.concepts.every(c => c.system === firstSystem);
123
125
  return allSameSystem ? firstSystem : null;
124
126
  }
@@ -222,7 +224,7 @@ export function addPlaceholdersForExternalBindings(valueSets, bindingUrls, fhirC
222
224
  function findValueSetInCache(canonicalUrl, fhirCacheDir) {
223
225
  // Derive the expected filename from the canonical URL
224
226
  // e.g. "http://hl7.org/fhir/ValueSet/mimetypes" → "ValueSet-mimetypes.json"
225
- const urlName = canonicalUrl.split('|')[0].split('/').pop();
227
+ const urlName = (canonicalUrl.split('|')[0] ?? canonicalUrl).split('/').pop();
226
228
  if (!urlName)
227
229
  return undefined;
228
230
  const expectedFilename = `ValueSet-${urlName}.json`;