/** * Core FHIR package resolver. * * Downloads the FHIR core package (e.g., hl7.fhir.r4.core\@4.0.1) from the * registry if not already cached, then parses its base StructureDefinitions * to derive all per-version data: resource names, interface names, data-type * field maps, primitive types, cardinality tables, and type fallbacks. * * This replaces hand-maintained JSON files (resources.json, interfaces.json, * dataTypes.json) and hardcoded tables (KNOWN_ARRAYS, KNOWN_SINGULAR, * CHILD_TYPE_FALLBACK) in per-version rules.ts files. */ import fs from 'fs'; import path from 'path'; import { getFhirPackagesCacheDir, ensureCacheDir } from '../core/cacheConfig.js'; import { downloadFile } from '../core/utils.js'; import { logger } from '../../logger.js'; const log = logger.withTag('core-resolver'); /** * FHIR package registries in priority order. * packages.fhir.org is the canonical source for core FHIR packages; * packages.simplifier.net hosts a subset of some core packages and * serves as a fallback. */ const REGISTRIES = [ 'https://packages.fhir.org', 'https://packages.simplifier.net', ]; // ── Derived data shape ────────────────────────────────────────────────────── /** * All per-version data derived from parsing base StructureDefinitions * in a FHIR core package. */ export interface DerivedVersionData { /** Canonical list of FHIR resource type names (Patient, Encounter, …) */ resourceNames: string[]; /** All interface names: data types + nested backbone types + resource backbone types */ interfaceNames: string[]; /** Data-type field definitions: TypeName → { fieldName → { type, isArray } } */ dataTypes: Record>; /** Resource direct-child field definitions for cardinality / type inference */ resourceFields: Record>; /** Primitive FHIR types that cannot have nested child properties */ primitiveTypes: ReadonlySet; } // ── In-memory cache ───────────────────────────────────────────────────────── const _derivedCache = new Map(); /** Clear the derivation cache (for testing). @internal */ export function resetDerivedCache(): void { _derivedCache.clear(); } // ── Package download / cache ──────────────────────────────────────────────── /** * Ensure the core FHIR package is downloaded and extracted in cache. * Returns the path to the extracted package directory. * * Checks the configured cache dir first, then falls back to the default * project cache (`.cache/`) and common system cache locations to avoid * re-downloading when using `--cache-dir`. */ export async function ensureCorePackage(corePackageSpec: string): Promise { const match = corePackageSpec.match(/^(.+)@([^@]+)$/); if (!match) throw new Error(`Invalid core package spec: ${corePackageSpec}`); const [, packageName, version] = match; const dirName = `${packageName}@${version}`; // Also accept '#' separator used by the FHIR IG Publisher / Java validator const altDirName = `${packageName}#${version}`; // Check the configured cache location (respects --cache-dir / FHIR_CACHE_ROOT) const configuredCacheDir = getFhirPackagesCacheDir(); for (const name of [dirName, altDirName]) { const candidate = path.join(configuredCacheDir, name); if (fs.existsSync(candidate) && fs.readdirSync(candidate).length > 0) { log.debug(`Core package found in cache: ${candidate}`); return candidate; } } // Not cached — download from registry (streaming to avoid buffering large packages in RAM) ensureCacheDir(configuredCacheDir); const packageDir = path.join(configuredCacheDir, dirName); let downloaded = false; const tgzPath = path.join(configuredCacheDir, `${packageName}-${version}.tgz`); for (const registry of REGISTRIES) { const url = `${registry}/${packageName}/${version}`; try { log.info(`Downloading core package ${corePackageSpec} from ${registry}…`); await downloadFile(url, tgzPath); downloaded = true; break; } catch (err) { log.debug(`Failed to download from ${registry}: ${(err as Error).message}`); } } if (!downloaded) { throw new Error( `Failed to download core package ${corePackageSpec} from any registry: ${REGISTRIES.join(', ')}`, ); } // Extract fs.mkdirSync(packageDir, { recursive: true }); const { extract } = await import('tar'); await extract({ cwd: packageDir, file: tgzPath }); log.info(`Core package cached: ${packageDir}`); return packageDir; } // ── Element field name (skip infrastructure) ──────────────────────────────── /** * Fields always skipped when collecting data-type field definitions. * `id` and `extension` are infrastructure on every element. * NOTE: `modifierExtension` is intentionally NOT excluded because some * BackboneElement-derived data types (Dosage, MarketingStatus, etc.) include * it and the old hand-maintained dataTypes.json preserved it. */ const INFRA_FIELDS = new Set(['id', 'extension']); // ── Derivation entry point ────────────────────────────────────────────────── /** * Ensure the core package is present and derive all per-version data. * Caches the result in memory keyed on the package spec. */ export async function resolveAndDerive(corePackageSpec: string): Promise { const cached = _derivedCache.get(corePackageSpec); if (cached) return cached; const pkgDir = await ensureCorePackage(corePackageSpec); const data = deriveVersionData(pkgDir); _derivedCache.set(corePackageSpec, data); return data; } /** * Derive all version-specific data from a (previously extracted) core package. */ export function deriveVersionData(corePackageDir: string): DerivedVersionData { const packageSubdir = path.join(corePackageDir, 'package'); const sdDir = fs.existsSync(packageSubdir) ? packageSubdir : corePackageDir; const resourceNames: string[] = []; const interfaceNames: string[] = []; const primitiveTypeNames: string[] = []; const dataTypes: Record> = {}; const resourceFields: Record> = {}; const files = fs.readdirSync(sdDir) .filter(f => f.startsWith('StructureDefinition-') && f.endsWith('.json')); // Memory-efficient approach: parse SDs one file at a time. // Pass 1 processes base specialisations and records which constraint SDs // need full processing in Pass 2 (only complex-type constraints whose // base type is a known specialisation — typically <10 out of ~828). const complexTypeSpecNames = new Set(); const constraintSDFiles: string[] = []; // filenames needing Pass 2 for (const file of files) { try { const raw = fs.readFileSync(path.join(sdDir, file), 'utf-8'); const sd = JSON.parse(raw); if (sd.resourceType !== 'StructureDefinition') continue; const kind = sd.kind; const derivation = sd.derivation; const typeName = sd.type; // Constraint SDs are deferred to Pass 2 if (derivation === 'constraint' && kind === 'complex-type') { constraintSDFiles.push(file); continue; } if (!typeName) continue; if (derivation && derivation !== 'specialization') continue; if (kind === 'logical') continue; if (kind === 'resource') { resourceNames.push(typeName); interfaceNames.push(typeName); collectBackboneInterfaces(sd, typeName, interfaceNames); collectDataTypeFields(sd, typeName, resourceFields); } else if (kind === 'primitive-type') { primitiveTypeNames.push(typeName); } else if (kind === 'complex-type') { complexTypeSpecNames.add(typeName); interfaceNames.push(typeName); collectDataTypeFields(sd, typeName, dataTypes); collectBackboneInterfaces(sd, typeName, interfaceNames); } // sd is now GC-eligible — no reference kept } catch { /* skip */ } } // Pass 2: re-read only the constraint complex-type SDs whose base type is a // known specialisation (e.g. MoneyQuantity constraining Quantity). const CONSTRAINT_EXCLUDE_TYPES = new Set(['Extension', 'ElementDefinition']); for (const file of constraintSDFiles) { try { const raw = fs.readFileSync(path.join(sdDir, file), 'utf-8'); const sd = JSON.parse(raw); const typeName = sd.type; const sdName = sd.name || sd.id; if (!typeName || !sdName) continue; if (sdName === typeName) continue; if (!complexTypeSpecNames.has(typeName)) continue; if (CONSTRAINT_EXCLUDE_TYPES.has(typeName)) continue; interfaceNames.push(sdName); collectDataTypeFields(sd, sdName, dataTypes); } catch { /* skip */ } } // Deterministic ordering resourceNames.sort(); interfaceNames.sort(); log.info( `Derived from core package: ${resourceNames.length} resources, ` + `${interfaceNames.length} interfaces, ` + `${Object.keys(dataTypes).length} data types, ` + `${primitiveTypeNames.length} primitives`, ); return { resourceNames, interfaceNames, dataTypes, resourceFields, primitiveTypes: new Set(primitiveTypeNames), }; } // ── Helpers ───────────────────────────────────────────────────────────────── /** * Parse snapshot elements of a complex-type SD to populate the dataTypes map. * Each direct child element (one level deep, excluding infrastructure) is recorded * with its FHIR type and whether it's an array. */ function collectDataTypeFields( sd: { snapshot?: { element?: ElementLike[] }; differential?: { element?: ElementLike[] } }, typeName: string, dataTypes: Record>, ): void { const elements = sd.snapshot?.element ?? sd.differential?.element; if (!elements) return; const fields: Record = {}; for (const el of elements) { const id = el.id; if (!id || !id.includes('.')) continue; const parts = id.split('.'); // Only direct children of the root type if (parts.length !== 2) continue; const fieldName = parts[1]; if (!fieldName || INFRA_FIELDS.has(fieldName)) continue; const typeCode = el.type?.[0]?.code; if (!typeCode) continue; // Primitive element names start with http://... System types — skip if (typeCode.startsWith('http://')) continue; const isArray = el.max === '*' || (Number.parseInt(el.max as string, 10) > 1); fields[fieldName] = { type: typeCode, isArray }; } if (Object.keys(fields).length > 0) { dataTypes[typeName] = fields; } } /** * Collect backbone element interfaces from a StructureDefinition. * * For each element whose type is BackboneElement (or Element for data types), * generates a PascalCase interface name by concatenating path segments: * `CapabilityStatement.rest.resource` → `CapabilityStatementRestResource` */ function collectBackboneInterfaces( sd: { snapshot?: { element?: ElementLike[] }; differential?: { element?: ElementLike[] } }, rootType: string, interfaceNames: string[], ): void { const elements = sd.snapshot?.element ?? sd.differential?.element; if (!elements) return; for (const el of elements) { const id = el.id; if (!id || !id.includes('.')) continue; const typeCode = el.type?.[0]?.code; if (typeCode !== 'BackboneElement' && typeCode !== 'Element') continue; // Build interface name from path segments const parts = id.split('.'); // Skip the root type segment (parts[0]) let name = rootType; for (const seg of parts.slice(1)) { name += seg.charAt(0).toUpperCase() + seg.slice(1); } interfaceNames.push(name); } } // ── Serialization (for offline fallback cache) ────────────────────────────── /** * JSON-safe shape used to persist DerivedVersionData to disk. * Sets are serialized as sorted arrays. */ interface SerializedDerivedVersionData { resourceNames: string[]; interfaceNames: string[]; dataTypes: Record>; resourceFields: Record>; primitiveTypes: string[]; } /** Serialize DerivedVersionData to a JSON-safe object. */ export function serializeDerivedData(data: DerivedVersionData): SerializedDerivedVersionData { return { resourceNames: data.resourceNames, interfaceNames: data.interfaceNames, dataTypes: data.dataTypes, resourceFields: data.resourceFields, primitiveTypes: [...data.primitiveTypes].sort(), }; } /** Deserialize a JSON-parsed object back to DerivedVersionData. */ export function deserializeDerivedData(raw: SerializedDerivedVersionData): DerivedVersionData { return { resourceNames: raw.resourceNames, interfaceNames: raw.interfaceNames, dataTypes: raw.dataTypes, resourceFields: raw.resourceFields, primitiveTypes: new Set(raw.primitiveTypes), }; } // ── Internal types ────────────────────────────────────────────────────────── interface ElementLike { id?: string; min?: number; max?: string; type?: Array<{ code?: string }>; }