From d2806393554fb888bb3af1877e40b0c82ee8cd14 Mon Sep 17 00:00:00 2001 From: Malte Sussdorff Date: Thu, 20 Aug 2026 18:06:41 +0200 Subject: [PATCH 1/2] feat(typescript): emit opt-in per-package terminology surfaces Generate a terminology.ts module per resolved package when typescript.terminology.enabled is set. Only CodeSystems with content complete emit code unions and display maps; ValueSet expansions are never promoted to constants. --- README.md | 10 + .../writer-generator/typescript/profile.ts | 6 +- src/api/writer-generator/typescript/writer.ts | 268 ++++++- src/typeschema/register.ts | 126 ++++ .../typescript/terminology.test.ts | 664 ++++++++++++++++++ 5 files changed, 1060 insertions(+), 14 deletions(-) create mode 100644 test/unit/api/writer-generator/typescript/terminology.test.ts diff --git a/README.md b/README.md index 3374c578..3c10b91c 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,12 @@ const builder = new APIBuilder() generateProfile?: boolean, withDebugComment?: boolean, openResourceTypeSet?: boolean, + terminology?: { + enabled: true, + packageVerification: { + "hl7.fhir.r4.core@4.0.1": "registry-integrity", + }, + }, }) .python({ // Python generator client?: "fhirpy" | "none", // client integration (default: fhirpy) @@ -204,6 +210,10 @@ Each language generator accepts its own option object. All options are optional; | `sliceGetterDefault` | `"flat" \| "raw"` | `"flat"` | Default return shape for generated slice getters (`flat` strips discriminators, `raw` returns the full FHIR element). | | `lineWidth` | `number` | `120` | Maximum line width before wrapping. | | `withDebugComment` | `boolean` | `false` | Emit comments tracing each generated type back to its source schema. | +| `terminology.enabled` | `boolean` | `false` | Emit a `terminology.ts` module for every package in the resolved closure. | +| `terminology.packageVerification` | `Record` | `{}` | Map package references such as `hl7.fhir.r4.core@4.0.1` to a closure verification state (`registry-integrity`, `unverifiable`, ...). Absent entries record `not-recorded`. | + +When terminology generation is enabled, each exported symbol includes its canonical identity, source package and version, declared content mode, and verification state. Only CodeSystems declaring `content: "complete"` emit code unions and display maps. ValueSet expansions are never promoted to constants, and an `unverifiable` package emits identity and provenance without concept content. **Python** — `.python({ ... })` diff --git a/src/api/writer-generator/typescript/profile.ts b/src/api/writer-generator/typescript/profile.ts index d3d49dba..310a0423 100644 --- a/src/api/writer-generator/typescript/profile.ts +++ b/src/api/writer-generator/typescript/profile.ts @@ -17,9 +17,7 @@ import { tsCamelCase, tsExtensionFlatTypeName, tsFieldName, - tsModulePath, tsNameFromCanonical, - tsPackageDir, tsProfileClassName, tsProfileModuleName, tsResourceName, @@ -316,9 +314,9 @@ export const generateProfileImports = ( const getModulePath = (typeId: TypeIdentifier): string => { if (isNestedIdentifier(typeId)) { const path = tsNameFromCanonical(typeId.url, true); - if (path) return `../../${tsPackageDir(typeId.package)}/${pascalCase(path)}`; + if (path) return `../../${w.packageDirectory(typeId)}/${pascalCase(path)}`; } - return `../../${tsModulePath(typeId)}`; + return `../../${w.modulePath(typeId)}`; }; const addType = (typeId: TypeIdentifier) => { diff --git a/src/api/writer-generator/typescript/writer.ts b/src/api/writer-generator/typescript/writer.ts index 6ebee66a..1ffdbbb3 100644 --- a/src/api/writer-generator/typescript/writer.ts +++ b/src/api/writer-generator/typescript/writer.ts @@ -1,6 +1,8 @@ import * as Path from "node:path"; import { fileURLToPath } from "node:url"; +import { pascalCase, uppercaseFirstLetter } from "@root/api/writer-generator/utils"; import { Writer, type WriterOptions } from "@root/api/writer-generator/writer"; +import type { PackageTerminology, TerminologyConcept, TerminologyResource } from "@root/typeschema/register"; import { type CanonicalUrl, isChoiceDeclarationField, @@ -12,18 +14,19 @@ import { isSnapshotProfileTypeSchema, isSpecializationTypeSchema, type NestedTypeSchema, + type PackageMeta, packageMeta, packageMetaToFhir, + packageMetaToNpm, type SpecializationTypeSchema, type TypeIdentifier, type TypeSchema, } from "@root/typeschema/types"; -import { groupByPackages, type TypeSchemaIndex } from "@root/typeschema/utils"; +import type { TypeSchemaIndex } from "@root/typeschema/utils"; import { tsFieldName, tsModuleFileName, tsModuleName, - tsModulePath, tsNameFromCanonical, tsPackageDir, tsProfileModuleFileName, @@ -47,6 +50,13 @@ const leafOf = (path: string[]): string => path[path.length - 1] ?? ""; // `generic.params` (if any, computed via structural propagation) must be ignored at reference sites // so we don't emit `` args clashing with the hardcoded `T extends string` declaration. const TS_HARDCODED_GENERIC_NAMES = new Set(["Reference", "Coding", "CodeableConcept"]); +const CODE_SYSTEM_SUFFIX_RE = /CodeSystem$/; +const CHOICE_SUFFIX_RE = /\[x\]/g; +const INVALID_TS_IDENTIFIER_RUN_RE = /[^A-Za-z0-9_$]+/g; +const PACKAGE_PATH_SEPARATOR_RE = /\\/g; +const INVALID_PACKAGE_DIR_RUN_RE = /[^a-z0-9-]+/g; +const PACKAGE_DIR_EDGE_RE = /^-+|-+$/g; +const TS_IDENTIFIER_START_RE = /^[A-Za-z_$]/; export type TypeScriptOptions = { lineWidth?: number; @@ -59,13 +69,89 @@ export type TypeScriptOptions = { primitiveTypeExtension: boolean; extensionGetterDefault?: "flat" | "profile" | "raw"; sliceGetterDefault?: "flat" | "raw"; + terminology?: { + /** Emit one terminology module for every resolved package. Defaults to false. */ + enabled?: boolean; + /** Optional map of `name@version` package refs to a closure verification state. */ + packageVerification?: Record; + }; } & WriterOptions; +const validTsIdentifier = (source: string): string => { + const normalized = source.replace(CHOICE_SUFFIX_RE, "_x_").replace(INVALID_TS_IDENTIFIER_RUN_RE, "_"); + return TS_IDENTIFIER_START_RE.test(normalized) ? normalized : `_${normalized}`; +}; + +const terminologySymbolName = (resource: TerminologyResource): string => { + const sourceName = resource.name ?? resource.id ?? tsNameFromCanonical(resource.url) ?? "Terminology"; + return validTsIdentifier(`${uppercaseFirstLetter(sourceName)}${resource.resourceType}`); +}; + +const terminologyResourceIdentity = (resource: TerminologyResource): string => + `${resource.id ?? ""}\u0000${resource.name ?? ""}`; + +const safeTsPackageDir = (source: string): string => { + const normalized = tsPackageDir(source.replace(PACKAGE_PATH_SEPARATOR_RE, "_")); + return normalized.replace(INVALID_PACKAGE_DIR_RUN_RE, "-").replace(PACKAGE_DIR_EDGE_RE, "") || "package"; +}; + +const allocateTerminologySymbols = ( + resources: TerminologyResource[], +): { resource: TerminologyResource; symbol: string }[] => { + const baseNames = resources.map(terminologySymbolName); + const counts: Record = {}; + for (const name of baseNames) counts[name] = (counts[name] ?? 0) + 1; + const used = new Set(); + + return resources.map((resource, index) => { + const baseName = baseNames[index] ?? "Terminology"; + const localIdentity = resource.id ?? tsNameFromCanonical(resource.url) ?? "Resource"; + const desired = + counts[baseName] === 1 ? baseName : validTsIdentifier(`${baseName}_${pascalCase(localIdentity)}`); + let symbol = desired; + let suffix = 2; + while (used.has(symbol)) { + symbol = `${desired}_${suffix}`; + suffix += 1; + } + used.add(symbol); + return { resource, symbol }; + }); +}; + +const flattenConcepts = (concepts: TerminologyConcept[] | undefined): TerminologyConcept[] => { + const flattened: TerminologyConcept[] = []; + const stack = [...(concepts ?? [])].reverse(); + while (stack.length > 0) { + const concept = stack.pop(); + if (!concept) continue; + flattened.push(concept); + if (concept.concept) { + for (let index = concept.concept.length - 1; index >= 0; index -= 1) { + const nested = concept.concept[index]; + if (nested) stack.push(nested); + } + } + } + return flattened; +}; + export class TypeScript extends Writer { + private packageDirectories = new Map(); + constructor(options: TypeScriptOptions) { super({ lineWidth: 120, ...options, resolveAssets: options.resolveAssets ?? resolveTsAssets }); } + packageDirectory(physical: PackageMeta | TypeIdentifier): string { + const pkg = "package" in physical ? { name: physical.package, version: physical.version } : physical; + return this.packageDirectories.get(packageMetaToNpm(pkg)) ?? safeTsPackageDir(pkg.name); + } + + modulePath(identifier: TypeIdentifier): string { + return `${this.packageDirectory(identifier)}/${tsModuleName(identifier)}`; + } + ifElseChain(branches: { cond: string; body: () => void }[], elseBody?: () => void) { branches.forEach((branch, i) => { const prefix = i === 0 ? "if" : "} else if"; @@ -102,8 +188,9 @@ export class TypeScript extends Writer { } } - generateFhirPackageIndexFile(schemas: TypeSchema[]) { + generateFhirPackageIndexFile(schemas: TypeSchema[], hasTerminology = false) { this.cat("index.ts", () => { + if (hasTerminology) this.lineSM(`export * from "./terminology"`); const profiles = schemas.filter(isSnapshotProfileTypeSchema); if (profiles.length > 0) { this.lineSM(`export * from "./profiles"`); @@ -159,7 +246,7 @@ export class TypeScript extends Writer { for (const dep of schema.dependencies) { if (["complex-type", "resource", "logical"].includes(dep.kind)) { imports.push({ - tsPackage: `${importPrefix}${tsModulePath(dep)}`, + tsPackage: `${importPrefix}${this.modulePath(dep)}`, name: tsResourceName(dep), dep: dep, }); @@ -185,7 +272,7 @@ export class TypeScript extends Writer { const element = tsIndex.resolveByUrl(schema.identifier.package, elementUrl); if (!element) throw new Error(`'${elementUrl}' not found for ${schema.identifier.package}.`); - this.tsImport(`${importPrefix}${tsModulePath(element.identifier)}`, "Element", { typeOnly: true }); + this.tsImport(`${importPrefix}${this.modulePath(element.identifier)}`, "Element", { typeOnly: true }); } } } @@ -195,7 +282,7 @@ export class TypeScript extends Writer { if (complexTypeDeps && complexTypeDeps.length > 0) { for (const dep of complexTypeDeps) { this.debugComment(dep); - this.lineSM(`export type { ${tsResourceName(dep)} } from "${`../${tsModulePath(dep)}`}"`); + this.lineSM(`export type { ${tsResourceName(dep)} } from "${`../${this.modulePath(dep)}`}"`); } this.line(); } @@ -373,6 +460,84 @@ export class TypeScript extends Writer { } } + generateTerminologyModule(packageTerminology: PackageTerminology) { + const { packageMeta: pkg, resources } = packageTerminology; + const verification = this.opts.terminology?.packageVerification?.[packageMetaToNpm(pkg)] ?? "not-recorded"; + const resourcesByCanonical = new Map(); + for (const resource of resources) { + const key = `${resource.resourceType}\u0000${resource.url}`; + const matching = resourcesByCanonical.get(key) ?? []; + matching.push(resource); + resourcesByCanonical.set(key, matching); + } + const duplicate = [...resourcesByCanonical] + .filter(([, matching]) => matching.length > 1) + .sort(([left], [right]) => left.localeCompare(right))[0]; + if (duplicate) { + const resource = duplicate[1][0]; + if (!resource) throw new Error(`Duplicate terminology resource has no representative`); + const identities = duplicate[1] + .map((candidate) => candidate.id ?? candidate.name ?? candidate.url) + .sort((left, right) => left.localeCompare(right)); + throw new Error( + `Package ${packageMetaToNpm(pkg)} contains duplicate ${resource.resourceType} canonical URL ${JSON.stringify(resource.url)} for resources ${identities.join(", ")}`, + ); + } + const sortedResources = resources.slice().sort((left, right) => { + if (left.resourceType !== right.resourceType) return left.resourceType.localeCompare(right.resourceType); + const symbolOrder = terminologySymbolName(left).localeCompare(terminologySymbolName(right)); + if (symbolOrder !== 0) return symbolOrder; + const canonicalOrder = left.url.localeCompare(right.url); + if (canonicalOrder !== 0) return canonicalOrder; + return terminologyResourceIdentity(left).localeCompare(terminologyResourceIdentity(right)); + }); + const allocatedResources = allocateTerminologySymbols(sortedResources); + + this.cat("terminology.ts", () => { + this.generateDisclaimer(); + allocatedResources.forEach(({ resource, symbol }, index) => { + const concepts = flattenConcepts(resource.concept); + const emitsConcepts = + resource.resourceType === "CodeSystem" && + resource.content === "complete" && + verification !== "unverifiable"; + if (emitsConcepts) { + const seenCodes = new Set(); + for (const concept of concepts) { + if (seenCodes.has(concept.code)) + throw new Error(`CodeSystem ${resource.url} repeats code ${JSON.stringify(concept.code)}`); + seenCodes.add(concept.code); + } + } + + this.curlyBlock(["export", "const", symbol, "="], () => { + this.line(`canonicalUrl: ${JSON.stringify(resource.url)},`); + this.line(`packageId: ${JSON.stringify(pkg.name)},`); + this.line(`packageVersion: ${JSON.stringify(pkg.version)},`); + this.line(`verification: ${JSON.stringify(verification)},`); + this.line(`resourceType: ${JSON.stringify(resource.resourceType)},`); + this.line( + `contentMode: ${resource.content === undefined ? "null" : JSON.stringify(resource.content)},`, + ); + if (emitsConcepts) { + this.line(`codes: [${concepts.map(({ code }) => JSON.stringify(code)).join(", ")}],`); + this.curlyBlock(["displays:"], () => { + for (const concept of concepts) { + if (concept.display !== undefined) + this.line(`[${JSON.stringify(concept.code)}]: ${JSON.stringify(concept.display)},`); + } + }, [","]); + } + }, [" as const;"]); + if (emitsConcepts) + this.lineSM( + `export type ${symbol.replace(CODE_SYSTEM_SUFFIX_RE, "Code")} = (typeof ${symbol}.codes)[number]`, + ); + if (index < allocatedResources.length - 1) this.line(); + }); + }); + } + override async generate(tsIndex: TypeSchemaIndex) { // Only generate code for schemas from focused packages const typesToGenerate = [ @@ -381,7 +546,88 @@ export class TypeScript extends Writer { ...tsIndex.collectLogicalModels(), ...(this.opts.generateProfile ? tsIndex.collectSnapshotProfiles() : []), ]; - const grouped = groupByPackages(typesToGenerate); + const terminology = this.opts.terminology?.enabled + ? (tsIndex.register?.allTerminology() ?? []).filter(({ resources }) => resources.length > 0) + : []; + const logicalUnits = new Map< + string, + { packageMeta: PackageMeta; packageSchemas: TypeSchema[]; terminology?: PackageTerminology } + >(); + for (const schema of typesToGenerate) { + const pkg = packageMeta(schema); + const identity = packageMetaToNpm(pkg); + const unit = logicalUnits.get(identity) ?? { packageMeta: pkg, packageSchemas: [] }; + unit.packageSchemas.push(schema); + logicalUnits.set(identity, unit); + } + for (const packageTerminology of terminology) { + const identity = packageMetaToNpm(packageTerminology.packageMeta); + const unit = logicalUnits.get(identity) ?? { + packageMeta: packageTerminology.packageMeta, + packageSchemas: [], + }; + unit.terminology = packageTerminology; + logicalUnits.set(identity, unit); + } + + const identitiesByPackageName = new Map(); + for (const [identity, { packageMeta: pkg }] of logicalUnits) { + const identities = identitiesByPackageName.get(pkg.name) ?? []; + identities.push(identity); + identitiesByPackageName.set(pkg.name, identities); + } + + const unitsByBaseDir = new Map< + string, + { identity: string; packageSchemas: TypeSchema[]; terminology?: PackageTerminology }[] + >(); + for (const [identity, { packageMeta: pkg, packageSchemas, terminology: packageTerminology }] of logicalUnits) { + const directorySource = + (identitiesByPackageName.get(pkg.name)?.length ?? 0) > 1 ? packageMetaToNpm(pkg) : pkg.name; + const baseDir = safeTsPackageDir(directorySource); + const units = unitsByBaseDir.get(baseDir) ?? []; + const schemasByIdentity = new Map( + packageSchemas.map((schema) => [JSON.stringify(schema.identifier), schema]), + ); + const sortedSchemas = [...schemasByIdentity.values()].sort((left, right) => + left.identifier.name.localeCompare(right.identifier.name), + ); + units.push({ identity, packageSchemas: sortedSchemas, terminology: packageTerminology }); + unitsByBaseDir.set(baseDir, units); + } + + const generationUnits = new Map(); + const usedPackageDirs = new Set(); + for (const [baseDir, units] of unitsByBaseDir) { + if (units.length !== 1) continue; + const unit = units[0]; + if (!unit) continue; + generationUnits.set(baseDir, unit); + usedPackageDirs.add(baseDir); + } + for (const [baseDir, units] of [...unitsByBaseDir].sort(([left], [right]) => left.localeCompare(right))) { + if (units.length < 2) continue; + let suffix = 1; + for (const unit of units.sort((left, right) => left.identity.localeCompare(right.identity))) { + let packageDir = `${baseDir}--${suffix}`; + while (usedPackageDirs.has(packageDir)) { + suffix += 1; + packageDir = `${baseDir}--${suffix}`; + } + generationUnits.set(packageDir, unit); + usedPackageDirs.add(packageDir); + suffix += 1; + } + } + this.packageDirectories = new Map( + [...generationUnits].flatMap(([packageDir, unit]) => { + if (unit.terminology) { + return [[packageMetaToNpm(unit.terminology.packageMeta), packageDir] as const]; + } + const schema = unit.packageSchemas[0]; + return schema ? [[packageMetaToNpm(packageMeta(schema)), packageDir] as const] : []; + }), + ); const hasProfiles = this.opts.generateProfile && typesToGenerate.some(isSnapshotProfileTypeSchema); @@ -390,14 +636,16 @@ export class TypeScript extends Writer { this.cp("profile-helpers.ts", "profile-helpers.ts"); } - for (const [packageName, packageSchemas] of Object.entries(grouped)) { - const packageDir = tsPackageDir(packageName); + for (const [packageDir, { packageSchemas, terminology }] of [...generationUnits].sort(([left], [right]) => + left.localeCompare(right), + )) { this.cd(packageDir, () => { for (const schema of packageSchemas) { this.generateResourceModule(tsIndex, schema); } generateProfileIndexFile(this, tsIndex, packageSchemas.filter(isSnapshotProfileTypeSchema)); - this.generateFhirPackageIndexFile(packageSchemas); + if (terminology) this.generateTerminologyModule(terminology); + this.generateFhirPackageIndexFile(packageSchemas, terminology !== undefined); }); } }); diff --git a/src/typeschema/register.ts b/src/typeschema/register.ts index 7354193d..73d7d43a 100644 --- a/src/typeschema/register.ts +++ b/src/typeschema/register.ts @@ -32,6 +32,8 @@ export type Register = { allFs(): RichFHIRSchema[]; /** Returns all ValueSets from all packages in the resolver */ allVs(): RichValueSet[]; + /** Returns raw terminology resources grouped by their originating package. */ + allTerminology(): PackageTerminology[]; resolveVs(_pkg: PackageMeta, canonicalUrl: CanonicalUrl): RichValueSet | undefined; resolveAny(canonicalUrl: CanonicalUrl): any | undefined; resolveElementSnapshot(fhirSchema: RichFHIRSchema, path: string[]): FHIRSchemaElement; @@ -57,6 +59,120 @@ type PkgId = string; type PkgName = string; type FocusedResource = StructureDefinition | ValueSet | CodeSystem; +export type TerminologyConcept = { + code: string; + display?: string; + concept?: TerminologyConcept[]; +}; + +export type TerminologyResource = { + resourceType: "CodeSystem" | "ValueSet" | "NamingSystem"; + id?: string; + name?: string; + url: string; + content?: string; + concept?: TerminologyConcept[]; +}; + +export type PackageTerminology = { + packageMeta: PackageMeta; + resources: TerminologyResource[]; +}; + +const projectTerminologyConcepts = (concepts: unknown): TerminologyConcept[] | undefined => { + if (!Array.isArray(concepts)) return undefined; + const projected: TerminologyConcept[] = []; + const stack: { + source: unknown[]; + target: TerminologyConcept[]; + index: number; + parent?: TerminologyConcept; + }[] = [{ source: concepts, target: projected, index: 0 }]; + + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + if (!frame) break; + if (frame.index >= frame.source.length) { + if (frame.parent && frame.target.length === 0) delete frame.parent.concept; + stack.pop(); + continue; + } + const concept = frame.source[frame.index]; + frame.index += 1; + if (concept === null || typeof concept !== "object") continue; + const candidate = concept as { code?: unknown; display?: unknown; concept?: unknown }; + if (typeof candidate.code !== "string") continue; + const copy: TerminologyConcept = { + code: candidate.code, + ...(typeof candidate.display === "string" ? { display: candidate.display } : {}), + }; + frame.target.push(copy); + if (Array.isArray(candidate.concept)) { + const nested: TerminologyConcept[] = []; + copy.concept = nested; + stack.push({ source: candidate.concept, target: nested, index: 0, parent: copy }); + } + } + + return projected; +}; + +const namingSystemIdentity = ( + candidate: { id?: unknown; name?: unknown; uniqueId?: unknown }, + logger?: CodegenLog, +): string | undefined => { + const uniqueIds = Array.isArray(candidate.uniqueId) + ? candidate.uniqueId.filter( + (identifier): identifier is { type: string; value: string; preferred?: boolean } => + identifier !== null && + typeof identifier === "object" && + typeof (identifier as { type?: unknown }).type === "string" && + typeof (identifier as { value?: unknown }).value === "string" && + (identifier as { value: string }).value.length > 0, + ) + : []; + const preferred = uniqueIds.filter(({ preferred }) => preferred === true); + const candidates = preferred.length > 0 ? preferred : uniqueIds; + const identifier = candidates.find(({ type }) => type === "uri") ?? candidates[0]; + if (identifier) { + if (identifier.type === "oid" && !identifier.value.startsWith("urn:oid:")) return `urn:oid:${identifier.value}`; + if (identifier.type === "uuid" && !identifier.value.startsWith("urn:uuid:")) + return `urn:uuid:${identifier.value}`; + return identifier.value; + } + if (typeof candidate.id === "string" && candidate.id.length > 0) return `NamingSystem/${candidate.id}`; + if (typeof candidate.name === "string" && candidate.name.length > 0) return `NamingSystem/${candidate.name}`; + logger?.dryWarn("NamingSystem has no uniqueId, id, or name and cannot be emitted."); + return undefined; +}; + +const asTerminologyResource = (resource: unknown, logger?: CodegenLog): TerminologyResource | undefined => { + if (resource === null || typeof resource !== "object") return undefined; + const candidate = resource as { + resourceType?: unknown; + id?: unknown; + name?: unknown; + url?: unknown; + content?: unknown; + concept?: unknown; + uniqueId?: unknown; + }; + if (!["CodeSystem", "ValueSet", "NamingSystem"].includes(String(candidate.resourceType))) return undefined; + const resourceType = candidate.resourceType as TerminologyResource["resourceType"]; + let url = typeof candidate.url === "string" && candidate.url.length > 0 ? candidate.url : undefined; + if (url === undefined && resourceType === "NamingSystem") url = namingSystemIdentity(candidate, logger); + if (typeof url !== "string" || url.length === 0) return undefined; + const concepts = projectTerminologyConcepts(candidate.concept); + return { + resourceType, + ...(typeof candidate.id === "string" ? { id: candidate.id } : {}), + ...(typeof candidate.name === "string" ? { name: candidate.name } : {}), + url, + ...(typeof candidate.content === "string" ? { content: candidate.content } : {}), + ...(concepts && concepts.length > 0 ? { concept: concepts } : {}), + }; +}; + type CanonicalResolution = { deep: number; pkg: PackageMeta; @@ -69,6 +185,7 @@ type PackageIndex = { canonicalResolution: Record[]>; fhirSchemas: Record; valueSets: Record; + terminology: TerminologyResource[]; }; type PackageAwareResolver = Record; @@ -80,6 +197,7 @@ const mkEmptyPkgIndex = (pkg: PackageMeta): PackageIndex => { canonicalResolution: {}, fhirSchemas: {}, valueSets: {}, + terminology: [], }; }; @@ -97,6 +215,8 @@ const mkPackageAwareResolver = async ( const index = mkEmptyPkgIndex(pkg); acc[pkgId] = index; for (const resource of await manager.search({ package: pkg })) { + const terminologyResource = asTerminologyResource(resource, logger); + if (terminologyResource) index.terminology.push(terminologyResource); const rawUrl = resource.url; if (!rawUrl) continue; if (!(isStructureDefinition(resource) || isValueSet(resource) || isCodeSystem(resource))) continue; @@ -312,6 +432,12 @@ export const registerFromManager = async ( .sort((sd1, sd2) => sd1.url.localeCompare(sd2.url)), allFs: () => Object.values(resolver).flatMap((pkgIndex) => Object.values(pkgIndex.fhirSchemas)), allVs: () => Object.values(resolver).flatMap((pkgIndex) => Object.values(pkgIndex.valueSets)), + allTerminology: () => + Object.values(resolver) + .map(({ pkg, terminology }) => ({ packageMeta: pkg, resources: terminology })) + .sort((left, right) => + packageMetaToNpm(left.packageMeta).localeCompare(packageMetaToNpm(right.packageMeta)), + ), resolveVs, resolveAny: (canonicalUrl: CanonicalUrl) => packageAgnosticResolveCanonical(resolver, canonicalUrl, logger), resolveElementSnapshot, diff --git a/test/unit/api/writer-generator/typescript/terminology.test.ts b/test/unit/api/writer-generator/typescript/terminology.test.ts new file mode 100644 index 00000000..60a80e23 --- /dev/null +++ b/test/unit/api/writer-generator/typescript/terminology.test.ts @@ -0,0 +1,664 @@ +import { describe, expect, it } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import type { FHIRSchema } from "@atomic-ehr/fhirschema"; +import { APIBuilder } from "@root/api/builder"; +import { registerFromManager } from "@root/typeschema/register"; +import { enrichFHIRSchema } from "@root/typeschema/types"; +import { mkErrorLogger } from "@typeschema-test/utils"; + +const packageMeta = { name: "fixture.ig", version: "1.2.3" }; + +const resources = [ + { + resourceType: "CodeSystem", + id: "complete-example", + name: "CompleteExample", + url: "http://example.test/CodeSystem/complete", + content: "complete", + concept: [ + { code: "second", display: "Second display" }, + { code: "first", display: "First display" }, + ], + }, + { + resourceType: "CodeSystem", + id: "not-present-example", + name: "NotPresentExample", + url: "http://example.test/CodeSystem/not-present", + content: "not-present", + concept: [{ code: "must-not-appear", display: "Must not appear" }], + }, + { + resourceType: "CodeSystem", + id: "example-content", + name: "ExampleContent", + url: "http://example.test/CodeSystem/example", + content: "example", + concept: [{ code: "also-forbidden", display: "Also forbidden" }], + }, + { + resourceType: "ValueSet", + id: "expanded-value-set", + name: "ExpandedValueSet", + url: "http://example.test/ValueSet/expanded", + expansion: { + timestamp: "2026-08-19T00:00:00Z", + contains: [{ code: "expansion-only", display: "Expansion only" }], + }, + }, + { + resourceType: "NamingSystem", + id: "local-identifiers", + name: "LocalIdentifiers", + uniqueId: [{ type: "uri", value: "http://example.test/NamingSystem/local-identifiers", preferred: true }], + }, +] as const; + +const generateTerminology = async ( + verification = "registry-integrity", + sourceResources: readonly object[] = resources, +) => { + const manager = { + packageJson: async () => ({ ...packageMeta, dependencies: {} }), + search: async () => sourceResources, + } as unknown as Parameters[0]; + const register = await registerFromManager(manager, { focusedPackages: [packageMeta] }); + + const result = await new APIBuilder({ register, logger: mkErrorLogger() }) + .typescript({ + inMemoryOnly: true, + terminology: { + enabled: true, + packageVerification: { "fixture.ig@1.2.3": verification }, + }, + }) + .generate(); + + if (!result.success) throw new Error(`generation failed: ${result.errors.join(", ")}`); + const output = result.filesGenerated.typescript!["generated/types/fixture-ig/terminology.ts"]; + if (output === undefined) throw new Error("terminology module was not generated"); + return output; +}; + +describe("TypeScript terminology surface", () => { + it("does not emit terminology unless explicitly enabled", async () => { + const manager = { + packageJson: async () => ({ ...packageMeta, dependencies: {} }), + search: async () => resources, + } as unknown as Parameters[0]; + const register = await registerFromManager(manager, { focusedPackages: [packageMeta] }); + + const result = await new APIBuilder({ register, logger: mkErrorLogger() }) + .typescript({ inMemoryOnly: true }) + .generate(); + + expect(result.filesGenerated.typescript?.["generated/types/fixture-ig/terminology.ts"]).toBeUndefined(); + }); + + it("emits package provenance and declared content for every terminology resource", async () => { + expect(await generateTerminology()).toMatchInlineSnapshot(` + "// WARNING: This file is autogenerated by @atomic-ehr/codegen. + // GitHub: https://github.com/atomic-ehr/codegen + // Any manual changes made to this file may be overwritten. + + export const CompleteExampleCodeSystem = { + canonicalUrl: "http://example.test/CodeSystem/complete", + packageId: "fixture.ig", + packageVersion: "1.2.3", + verification: "registry-integrity", + resourceType: "CodeSystem", + contentMode: "complete", + codes: ["second", "first"], + displays: { + ["second"]: "Second display", + ["first"]: "First display", + }, + } as const; + export type CompleteExampleCode = (typeof CompleteExampleCodeSystem.codes)[number]; + + export const ExampleContentCodeSystem = { + canonicalUrl: "http://example.test/CodeSystem/example", + packageId: "fixture.ig", + packageVersion: "1.2.3", + verification: "registry-integrity", + resourceType: "CodeSystem", + contentMode: "example", + } as const; + + export const NotPresentExampleCodeSystem = { + canonicalUrl: "http://example.test/CodeSystem/not-present", + packageId: "fixture.ig", + packageVersion: "1.2.3", + verification: "registry-integrity", + resourceType: "CodeSystem", + contentMode: "not-present", + } as const; + + export const LocalIdentifiersNamingSystem = { + canonicalUrl: "http://example.test/NamingSystem/local-identifiers", + packageId: "fixture.ig", + packageVersion: "1.2.3", + verification: "registry-integrity", + resourceType: "NamingSystem", + contentMode: null, + } as const; + + export const ExpandedValueSetValueSet = { + canonicalUrl: "http://example.test/ValueSet/expanded", + packageId: "fixture.ig", + packageVersion: "1.2.3", + verification: "registry-integrity", + resourceType: "ValueSet", + contentMode: null, + } as const; + " + `); + }); + + it("does not derive constants from non-complete CodeSystems or ValueSet expansions", async () => { + const output = await generateTerminology(); + + expect(output).not.toContain("must-not-appear"); + expect(output).not.toContain("also-forbidden"); + expect(output).not.toContain("expansion-only"); + expect(output).not.toContain("export type NotPresentExampleCode ="); + expect(output).not.toContain("export type ExampleContentCode ="); + }); + + it("projects only fields required by terminology generation into the register", async () => { + const manager = { + packageJson: async () => ({ ...packageMeta, dependencies: {} }), + search: async () => [ + { + resourceType: "CodeSystem", + id: "projected", + name: "Projected", + url: "http://example.test/CodeSystem/projected", + content: "complete", + concept: [ + { + code: "kept", + display: "Kept display", + designation: [{ language: "de", value: "Discarded designation" }], + property: [{ code: "status", valueCode: "active" }], + }, + ], + meta: { profile: ["http://example.test/Profile/discarded"] }, + }, + { + resourceType: "ValueSet", + id: "projected-value-set", + name: "ProjectedValueSet", + url: "http://example.test/ValueSet/projected", + compose: { include: [{ system: "http://example.test/CodeSystem/projected" }] }, + expansion: { contains: [{ code: "discarded-expansion" }] }, + }, + ], + } as unknown as Parameters[0]; + + const register = await registerFromManager(manager, { focusedPackages: [packageMeta] }); + + expect(register.allTerminology()).toEqual([ + { + packageMeta, + resources: [ + { + resourceType: "CodeSystem", + id: "projected", + name: "Projected", + url: "http://example.test/CodeSystem/projected", + content: "complete", + concept: [{ code: "kept", display: "Kept display" }], + }, + { + resourceType: "ValueSet", + id: "projected-value-set", + name: "ProjectedValueSet", + url: "http://example.test/ValueSet/projected", + }, + ], + }, + ]); + }); + + it("preserves complete CodeSystem concept order and exact displays", async () => { + const output = await generateTerminology(); + const sourceConcepts = resources[0].concept; + + expect(output.indexOf(JSON.stringify(sourceConcepts[0].code))).toBeLessThan( + output.indexOf(JSON.stringify(sourceConcepts[1].code)), + ); + for (const concept of sourceConcepts) { + expect(output).toContain(`[${JSON.stringify(concept.code)}]: ${JSON.stringify(concept.display)}`); + } + }); + + it("preserves __proto__ as an own display-map property at runtime", async () => { + const output = await generateTerminology("registry-integrity", [ + { + resourceType: "CodeSystem", + id: "proto-display", + name: "ProtoDisplay", + url: "http://example.test/CodeSystem/proto-display", + content: "complete", + concept: [{ code: "__proto__", display: "Prototype display" }], + }, + ]); + const javascript = new Bun.Transpiler({ loader: "ts" }).transformSync(output); + const generated = await import(`data:text/javascript;base64,${Buffer.from(javascript).toString("base64")}`); + const displays = generated.ProtoDisplayCodeSystem.displays; + + expect(Object.hasOwn(displays, "__proto__")).toBeTrue(); + expect(Reflect.get(displays, "__proto__")).toBe("Prototype display"); + }); + + it("flattens hierarchical concepts in source order with exact displays", async () => { + const output = await generateTerminology("registry-integrity", [ + { + resourceType: "CodeSystem", + id: "hierarchical", + name: "Hierarchical", + url: "http://example.test/CodeSystem/hierarchical", + content: "complete", + concept: [ + { + code: "parent", + display: "Parent display", + concept: [{ code: "child", display: "Child display" }], + }, + { code: "sibling", display: "Sibling display" }, + ], + }, + ]); + + expect(output).toContain('codes: ["parent", "child", "sibling"]'); + expect(output.indexOf('["parent"]: "Parent display"')).toBeLessThan( + output.indexOf('["child"]: "Child display"'), + ); + expect(output.indexOf('["child"]: "Child display"')).toBeLessThan( + output.indexOf('["sibling"]: "Sibling display"'), + ); + }); + + it("handles deeply nested concepts without exhausting the stack", async () => { + const depth = 12_000; + let concept: { code: string; display: string; concept?: object[] } = { + code: `code-${depth}`, + display: `Display ${depth}`, + }; + for (let index = depth - 1; index >= 0; index -= 1) { + concept = { + code: `code-${index}`, + display: `Display ${index}`, + concept: [concept], + }; + } + + const output = await generateTerminology("registry-integrity", [ + { + resourceType: "CodeSystem", + id: "deep-hierarchy", + name: "DeepHierarchy", + url: "http://example.test/CodeSystem/deep-hierarchy", + content: "complete", + concept: [concept], + }, + ]); + + expect(output).toContain('codes: ["code-0", "code-1"'); + expect(output).toContain(`["code-${depth}"]: "Display ${depth}"`); + }); + + it("fails deterministically when hierarchical concepts repeat a code", async () => { + const generation = generateTerminology("registry-integrity", [ + { + resourceType: "CodeSystem", + id: "duplicate-code", + name: "DuplicateCode", + url: "http://example.test/CodeSystem/duplicate-code", + content: "complete", + concept: [ + { code: "same", display: "Parent display", concept: [{ code: "same", display: "Child display" }] }, + ], + }, + ]); + + await expect(generation).rejects.toThrow( + 'CodeSystem http://example.test/CodeSystem/duplicate-code repeats code "same"', + ); + }); + + it("fails deterministically when one package repeats a resource type and canonical URL", async () => { + const generation = generateTerminology("registry-integrity", [ + { + resourceType: "CodeSystem", + id: "first-duplicate", + name: "FirstDuplicate", + url: "http://example.test/CodeSystem/duplicate-canonical", + content: "complete", + concept: [{ code: "first", display: "First display" }], + }, + { + resourceType: "CodeSystem", + id: "second-duplicate", + name: "SecondDuplicate", + url: "http://example.test/CodeSystem/duplicate-canonical", + content: "complete", + concept: [{ code: "second", display: "Second display" }], + }, + ]); + + await expect(generation).rejects.toThrow( + 'Package fixture.ig@1.2.3 contains duplicate CodeSystem canonical URL "http://example.test/CodeSystem/duplicate-canonical" for resources first-duplicate, second-duplicate', + ); + }); + + it("emits no concept content for an unverifiable package", async () => { + const output = await generateTerminology("unverifiable"); + + expect(output).toContain('canonicalUrl: "http://example.test/CodeSystem/complete"'); + expect(output).toContain('packageVersion: "1.2.3"'); + expect(output).toContain('verification: "unverifiable"'); + expect(output).not.toContain("codes:"); + expect(output).not.toContain("displays:"); + expect(output).not.toContain("export type CompleteExampleCode"); + }); + + it("derives NamingSystem identity from URI, OID-only, and other-only unique identifiers", async () => { + const output = await generateTerminology("registry-integrity", [ + { + resourceType: "NamingSystem", + id: "uri-identity", + name: "UriIdentity", + uniqueId: [{ type: "uri", value: "http://example.test/NamingSystem/uri", preferred: true }], + }, + { + resourceType: "NamingSystem", + id: "oid-identity", + name: "OidIdentity", + uniqueId: [{ type: "oid", value: "1.2.276.0.76.3.1", preferred: true }], + }, + { + resourceType: "NamingSystem", + id: "other-identity", + name: "OtherIdentity", + uniqueId: [{ type: "other", value: "practice-local-identifier", preferred: true }], + }, + ]); + + expect(output).toContain('canonicalUrl: "http://example.test/NamingSystem/uri"'); + expect(output).toContain('canonicalUrl: "urn:oid:1.2.276.0.76.3.1"'); + expect(output).toContain('canonicalUrl: "practice-local-identifier"'); + expect([...output.matchAll(/^export const /gm)]).toHaveLength(3); + }); + + it("disambiguates resources that declare the same FHIR name", async () => { + const output = await generateTerminology("registry-integrity", [ + { + resourceType: "ValueSet", + id: "radiation-relevant-billing-code", + name: "RadiationRelevantBillingCodeVS", + url: "http://example.test/ValueSet/radiation-relevant-billing-code", + }, + { + resourceType: "ValueSet", + id: "radiation-relevant-billing-codes", + name: "RadiationRelevantBillingCodeVS", + url: "http://example.test/ValueSet/radiation-relevant-billing-codes", + }, + ]); + + const symbols = [...output.matchAll(/^export const (\w+)/gm)].map((match) => match[1]); + expect(symbols).toHaveLength(2); + expect(new Set(symbols).size).toBe(2); + expect(() => new Bun.Transpiler({ loader: "ts" }).transformSync(output)).not.toThrow(); + }); + + it("emits byte-identical terminology and stable symbols when discovery order reverses", async () => { + const discovered = [ + { + resourceType: "ValueSet", + id: "zeta", + name: "SharedName", + url: "http://example.test/ValueSet/zeta", + }, + { + resourceType: "ValueSet", + id: "alpha", + name: "SharedName", + url: "http://example.test/ValueSet/alpha", + }, + { + resourceType: "ValueSet", + id: "unique", + name: "UniqueName", + url: "http://example.test/ValueSet/unique", + }, + ]; + + const forward = await generateTerminology("registry-integrity", discovered); + const reversed = await generateTerminology("registry-integrity", discovered.toReversed()); + + expect(reversed).toBe(forward); + expect(forward).toContain("export const UniqueNameValueSet ="); + expect(forward).toContain("export const SharedNameValueSet_Alpha ="); + expect(forward).toContain("export const SharedNameValueSet_Zeta ="); + expect(forward.indexOf('canonicalUrl: "http://example.test/ValueSet/alpha"')).toBeLessThan( + forward.indexOf('canonicalUrl: "http://example.test/ValueSet/zeta"'), + ); + }); + + it("emits valid identifiers and adjacent code-union names for punctuation and leading digits", async () => { + const output = await generateTerminology("registry-integrity", [ + { + resourceType: "CodeSystem", + id: "3-weird-terms", + name: "3/Weird (Doctor's), Terms", + url: "http://example.test/CodeSystem/3-weird-terms", + content: "complete", + concept: [{ code: "valid", display: "Valid display" }], + }, + ]); + + expect(output).toContain("export const _3_Weird_Doctor_s_TermsCodeSystem ="); + expect(output).toContain("export type _3_Weird_Doctor_s_TermsCode ="); + expect(() => new Bun.Transpiler({ loader: "ts" }).transformSync(output)).not.toThrow(); + }); + + it("emits both versions when a prebuilt public register contains the same package twice", async () => { + const packageVersions = [ + { name: "fixture.ig", version: "1.0.0" }, + { name: "fixture.ig", version: "2.0.0" }, + ]; + const manager = { + packageJson: async () => ({ dependencies: {} }), + search: async ({ package: pkg }: { package: { version: string } }) => [ + { + resourceType: "ValueSet", + id: `version-${pkg.version}`, + name: `Version${pkg.version}`, + url: `http://example.test/ValueSet/${pkg.version}`, + }, + ], + } as unknown as Parameters[0]; + const register = await registerFromManager(manager, { focusedPackages: packageVersions }); + + const result = await new APIBuilder({ register, logger: mkErrorLogger() }) + .typescript({ + inMemoryOnly: true, + terminology: { + enabled: true, + packageVerification: { + "fixture.ig@1.0.0": "registry-integrity", + "fixture.ig@2.0.0": "registry-integrity", + }, + }, + }) + .generate(); + + const first = result.filesGenerated.typescript?.["generated/types/fixture-ig-1-0-0/terminology.ts"]; + const second = result.filesGenerated.typescript?.["generated/types/fixture-ig-2-0-0/terminology.ts"]; + expect(first).toContain('packageVersion: "1.0.0"'); + expect(second).toContain('packageVersion: "2.0.0"'); + expect(first).toContain('canonicalUrl: "http://example.test/ValueSet/1.0.0"'); + expect(second).toContain('canonicalUrl: "http://example.test/ValueSet/2.0.0"'); + }); + + it("preserves packages whose normalized directories collide and contains unsafe path characters", async () => { + const packageMetas = [ + { name: "fixture.ig", version: "1.0.0" }, + { name: "fixture-ig", version: "1.0.0" }, + { name: "..\\outside/fixture.ig", version: "1.0.0" }, + ]; + const manager = { + packageJson: async () => ({ dependencies: {} }), + search: async ({ package: pkg }: { package: { name: string } }) => [ + { + resourceType: "ValueSet", + id: "package-identity", + name: "PackageIdentity", + url: `http://example.test/ValueSet/${encodeURIComponent(pkg.name)}`, + }, + ], + } as unknown as Parameters[0]; + const register = await registerFromManager(manager, { focusedPackages: packageMetas }); + const packageVerification = Object.fromEntries( + packageMetas.map(({ name, version }) => [`${name}@${version}`, "registry-integrity"]), + ); + + const result = await new APIBuilder({ register, logger: mkErrorLogger() }) + .typescript({ inMemoryOnly: true, terminology: { enabled: true, packageVerification } }) + .generate(); + const terminologyFiles = Object.entries(result.filesGenerated.typescript ?? {}).filter(([path]) => + path.endsWith("/terminology.ts"), + ); + + expect(terminologyFiles).toHaveLength(3); + expect( + terminologyFiles + .map(([, content]) => content.match(/packageId: ("(?:\\.|[^"])+")/)?.[1]) + .map((packageId) => (packageId === undefined ? undefined : JSON.parse(packageId))) + .sort(), + ).toEqual(packageMetas.map(({ name }) => name).sort()); + for (const [path] of terminologyFiles) { + expect(path).toMatch(/^generated\/types\/[a-z0-9][a-z0-9-]*\/terminology\.ts$/); + expect(path).not.toContain(".."); + expect(path).not.toContain("\\"); + } + }); + + it("uses one physical package directory mapping for schemas, terminology, and cross-package imports", async () => { + const packageMetas = [ + { name: "fixture.ig", version: "1.0.0" }, + { name: "fixture-ig", version: "1.0.0" }, + { name: "versioned.ig", version: "1.0.0" }, + { name: "versioned.ig", version: "2.0.0" }, + ]; + const manager = { + packageJson: async () => ({ dependencies: {} }), + search: async ({ package: pkg }: { package: { name: string; version: string } }) => [ + { + resourceType: "ValueSet", + id: `terminology-${pkg.version}`, + name: `Terminology${pkg.version}`, + url: `http://example.test/${encodeURIComponent(pkg.name)}/${pkg.version}/ValueSet/terminology`, + }, + ], + } as unknown as Parameters[0]; + const register = await registerFromManager(manager, { focusedPackages: packageMetas }); + const appendSchema = ( + pkg: { name: string; version: string }, + name: string, + url: string, + elements: Record, + ) => { + register.testAppendFs( + enrichFHIRSchema( + { + name, + type: name, + kind: "complex-type", + url, + elements, + class: "complex-type", + } as unknown as FHIRSchema, + pkg, + ), + ); + }; + appendSchema(packageMetas[0]!, "Shared", "http://example.test/fixture.ig/Shared", {}); + appendSchema(packageMetas[1]!, "Consumer", "http://example.test/fixture-ig/Consumer", { + shared: { type: "http://example.test/fixture.ig/Shared" }, + }); + appendSchema(packageMetas[2]!, "Versioned", "http://example.test/versioned.ig/1/Versioned", {}); + appendSchema(packageMetas[3]!, "Versioned", "http://example.test/versioned.ig/2/Versioned", {}); + const packageVerification = Object.fromEntries( + packageMetas.map(({ name, version }) => [`${name}@${version}`, "registry-integrity"]), + ); + + const result = await new APIBuilder({ register, logger: mkErrorLogger() }) + .typescript({ inMemoryOnly: true, terminology: { enabled: true, packageVerification } }) + .generate(); + if (!result.success) throw new Error(result.errors.join(", ")); + const files = result.filesGenerated.typescript ?? {}; + const terminologyDirectories = Object.keys(files) + .filter((path) => path.endsWith("/terminology.ts")) + .map((path) => path.slice(0, -"/terminology.ts".length)); + + const versionedDirectories = terminologyDirectories.filter((directory) => + files[`${directory}/terminology.ts`]?.includes('packageId: "versioned.ig"'), + ); + expect(versionedDirectories).toHaveLength(2); + expect( + versionedDirectories.map((directory) => + files[`${directory}/Versioned.ts`]?.includes("http://example.test/versioned.ig/1/Versioned"), + ), + ).toEqual([true, false]); + expect( + versionedDirectories.map((directory) => + files[`${directory}/Versioned.ts`]?.includes("http://example.test/versioned.ig/2/Versioned"), + ), + ).toEqual([false, true]); + + const consumerEntry = Object.entries(files).find(([path]) => path.endsWith("/Consumer.ts")); + expect(consumerEntry).toBeDefined(); + const sharedImport = consumerEntry?.[1].match(/from "(\.\.\/[^"]+\/Shared)"/)?.[1]; + expect(sharedImport).toBeDefined(); + const consumerDirectory = consumerEntry?.[0].slice(0, -"/Consumer.ts".length); + const importedSharedPath = join(consumerDirectory ?? "", `${sharedImport}.ts`); + expect(files[importedSharedPath]).toBeDefined(); + + const compileRoot = await mkdtemp(join(tmpdir(), "codegen-x68-package-dirs-")); + try { + for (const [path, content] of Object.entries(files)) { + const target = join(compileRoot, path); + await mkdir(dirname(target), { recursive: true }); + await Bun.write(target, content); + } + const compile = Bun.spawn( + [ + "bunx", + "tsc", + "--noEmit", + "--target", + "ESNext", + "--module", + "ESNext", + "--moduleResolution", + "Bundler", + "--skipLibCheck", + ...Object.keys(files) + .filter((path) => path.endsWith(".ts")) + .map((path) => join(compileRoot, path)), + ], + { stdout: "pipe", stderr: "pipe" }, + ); + const compileOutput = `${await new Response(compile.stdout).text()}${await new Response(compile.stderr).text()}`; + expect({ exitCode: await compile.exited, output: compileOutput }).toEqual({ exitCode: 0, output: "" }); + } finally { + await rm(compileRoot, { recursive: true, force: true }); + } + }); +}); From a2ed7ab241d23d90dbee021c482085343f1b4f6a Mon Sep 17 00:00:00 2001 From: Malte Sussdorff Date: Fri, 21 Aug 2026 07:34:54 +0200 Subject: [PATCH 2/2] chore: bump brace-expansion to 5.0.9 for bun audit Fixes GHSA-rgw5-rvv9-x895 so the CI security job can pass. --- bun.lock | 6 +++--- package.json | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bun.lock b/bun.lock index b75b43d0..df0d34ce 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ "dependencies": { "@atomic-ehr/fhir-canonical-manager": "0.0.24", "@atomic-ehr/fhirschema": "0.0.14", - "brace-expansion": "^5.0.8", + "brace-expansion": "^5.0.9", "mustache": "^4.2.0", "picocolors": "^1.1.1", "yaml": "^2.9.0", @@ -26,7 +26,7 @@ }, }, "overrides": { - "brace-expansion": "^5.0.8", + "brace-expansion": "^5.0.9", "esbuild": ">=0.28.1", "minimatch": ">=10.2.3", "picomatch": ">=4.0.4", @@ -282,7 +282,7 @@ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="], + "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], diff --git a/package.json b/package.json index 73459a3b..cb13661f 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "dependencies": { "@atomic-ehr/fhir-canonical-manager": "0.0.24", "@atomic-ehr/fhirschema": "0.0.14", - "brace-expansion": "^5.0.8", + "brace-expansion": "^5.0.9", "mustache": "^4.2.0", "picocolors": "^1.1.1", "yaml": "^2.9.0", @@ -69,7 +69,7 @@ "minimatch": ">=10.2.3", "rollup": ">=4.59.0", "smol-toml": ">=1.6.1", - "brace-expansion": "^5.0.8", + "brace-expansion": "^5.0.9", "picomatch": ">=4.0.4", "esbuild": ">=0.28.1" }