diff --git a/bun.lock b/bun.lock index d3ebce7e..c1953ce3 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@atomic-ehr/codegen", "dependencies": { - "@atomic-ehr/fhir-canonical-manager": "0.0.24", + "@atomic-ehr/fhir-canonical-manager": "0.0.24-canary.20260608091810.e54bf2f", "@atomic-ehr/fhirschema": "0.0.11", "mustache": "^4.2.0", "picocolors": "^1.1.1", @@ -32,7 +32,7 @@ "smol-toml": ">=1.6.1", }, "packages": { - "@atomic-ehr/fhir-canonical-manager": ["@atomic-ehr/fhir-canonical-manager@0.0.24", "", { "peerDependencies": { "typescript": "^5" }, "bin": { "fcm": "dist/cli/index.js" } }, "sha512-3h+uGf3qqxNX2oClVx+Fz1NZ2Jm2h2dXKrbhA77gURmX5TUYYQKxdTh58a7N/tteLLsig5fW8q41wfeUqy7GdQ=="], + "@atomic-ehr/fhir-canonical-manager": ["@atomic-ehr/fhir-canonical-manager@0.0.24-canary.20260608091810.e54bf2f", "", { "peerDependencies": { "typescript": "^5" }, "bin": { "fcm": "dist/cli/index.js" } }, "sha512-FzIsA3tMav0yZEchHsEq77CnwqU9/7o/YWCQuEdaYOiIYDOE2+hqBtDNXfvjzhhZwpP9lixSdvDKbTBTQouRig=="], "@atomic-ehr/fhirschema": ["@atomic-ehr/fhirschema@0.0.11", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-oMNxhncEGspGI+QlK/FPjc7akLbfwMYw/hDfW6SbO8xF1KvSSH7NWqc3CJg/k5/309ZuJ6lKsHkgmgVDxo80sQ=="], diff --git a/examples/on-the-fly/kbv-r4/generate.ts b/examples/on-the-fly/kbv-r4/generate.ts index 286df767..3cdf1afa 100644 --- a/examples/on-the-fly/kbv-r4/generate.ts +++ b/examples/on-the-fly/kbv-r4/generate.ts @@ -1,34 +1,12 @@ -import type { PreprocessContext } from "@atomic-ehr/fhir-canonical-manager"; import { APIBuilder, prettyReport } from "../../../src/api/builder"; - -const preprocessPackage = (ctx: PreprocessContext): PreprocessContext => { - if (ctx.kind !== "package") return ctx; - const json = ctx.packageJson; - const name = json.name as string; - - // de.basisprofil.r4 doesn't declare hl7.fhir.r4.core as a dependency - if (name === "de.basisprofil.r4") { - const deps = (json.dependencies as Record) || {}; - if (!deps["hl7.fhir.r4.core"]) { - return { - ...ctx, - kind: "package", - packageJson: { - ...json, - dependencies: { ...deps, "hl7.fhir.r4.core": "4.0.1" }, - }, - }; - } - } - - return ctx; -}; +import { injectDependency, inPackage } from "../../../src/api/patches"; if (require.main === module) { console.log("Generating KBV R4 types..."); const builder = new APIBuilder({ - preprocessPackage, + // de.basisprofil.r4 references core types without declaring hl7.fhir.r4.core. + patches: { packageJson: [inPackage("de.basisprofil.r4", [injectDependency({ "hl7.fhir.r4.core": "4.0.1" })])] }, registry: "https://packages.simplifier.net", ignorePackageIndex: true, }) diff --git a/examples/on-the-fly/norge-r4/generate.ts b/examples/on-the-fly/norge-r4/generate.ts index e805a21a..a4ffd01f 100644 --- a/examples/on-the-fly/norge-r4/generate.ts +++ b/examples/on-the-fly/norge-r4/generate.ts @@ -1,76 +1,45 @@ -import type { PreprocessContext } from "@atomic-ehr/fhir-canonical-manager"; import { APIBuilder, prettyReport } from "../../../src/api/builder"; +import { + injectDependency, + inPackage, + inResource, + renamePackage, + renameReferenceTarget, +} from "../../../src/api/patches"; -// Fix known package name typos (in-memory transformation) -const packageNameFixes: Record = { - "simplifier.core.r4.rResources": "simplifier.core.r4.resources", -}; +const CORE_PACKAGE = "hl7.fhir.r4.core"; -// Packages that need hl7.fhir.r4.core dependency injected -const needsCoreDependency = (name: string): boolean => { - return ( - name.startsWith("simplifier.core.r4.") || - name === "simplifier.core.r4" || - name.startsWith("hl7.fhir.no.") || - name.startsWith("ehelse.fhir.no.") || - name.startsWith("nhn.fhir.no.") || - name.startsWith("sfm.") - ); -}; - -const preprocessPackage = (ctx: PreprocessContext): PreprocessContext => { - // GdRelatedPerson widens patient reference to include Person, but the - // base R4 RelatedPerson.patient only allows Patient. Drop the Person targets. - if (ctx.kind === "resource") { - const res = ctx.resource as { url?: string }; - if (res.url === "http://ehelse.no/fhir/StructureDefinition/gd-RelatedPerson") { - let str = JSON.stringify(ctx.resource); - str = str.replaceAll( - "http://hl7.org/fhir/StructureDefinition/Person", - "http://hl7.org/fhir/StructureDefinition/Patient", - ); - str = str.replaceAll( - "http://hl7.no/fhir/StructureDefinition/no-basis-Person", - "http://hl7.org/fhir/StructureDefinition/Patient", - ); - str = str.replaceAll( - "http://ehelse.no/fhir/StructureDefinition/gd-Person", - "http://hl7.org/fhir/StructureDefinition/Patient", - ); - return { ...ctx, resource: JSON.parse(str) }; - } - return ctx; - } - let json = ctx.packageJson; - const name = json.name as string; - - // Fix package name typos - const fixedName = packageNameFixes[name]; - if (fixedName) { - console.log(`Fixed package name: ${name} -> ${fixedName}`); - json = { ...json, name: fixedName }; - } - - // Add missing core dependency to packages that don't properly declare it - if (needsCoreDependency(name)) { - const deps = (json.dependencies as Record) || {}; - if (!deps["hl7.fhir.r4.core"]) { - console.log(`Injecting hl7.fhir.r4.core dependency into ${name}`); - json = { - ...json, - dependencies: { ...deps, "hl7.fhir.r4.core": "4.0.1" }, - }; - } - } - - return { ...ctx, kind: "package", packageJson: json }; -}; +// True for every package except core itself — injectDependency is a no-op when the +// dependency is already declared, so this only needs to keep core from depending on itself. +const needsCoreDependency = (name: string): boolean => name !== CORE_PACKAGE; if (require.main === module) { console.log("Generating Norge R4 types..."); const builder = new APIBuilder({ - preprocessPackage, + patches: { + packageJson: [ + // Many Norge packages reference core types without declaring the dependency; + // inject it wherever it's missing. + inPackage((pkg) => needsCoreDependency(pkg.name), [injectDependency({ [CORE_PACKAGE]: "4.0.1" })]), + // Fix known package name typo. + renamePackage({ "simplifier.core.r4.rResources": "simplifier.core.r4.resources" }), + ], + // gd-RelatedPerson widens patient to include Person, but base R4 RelatedPerson.patient + // only allows Patient — narrow the Person targets back to Patient. + fhirResource: [ + inResource("http://ehelse.no/fhir/StructureDefinition/gd-RelatedPerson", [ + renameReferenceTarget({ + "http://hl7.org/fhir/StructureDefinition/Person": + "http://hl7.org/fhir/StructureDefinition/Patient", + "http://hl7.no/fhir/StructureDefinition/no-basis-Person": + "http://hl7.org/fhir/StructureDefinition/Patient", + "http://ehelse.no/fhir/StructureDefinition/gd-Person": + "http://hl7.org/fhir/StructureDefinition/Patient", + }), + ]), + ], + }, registry: "https://packages.simplifier.net", }) .fromPackage("hl7.fhir.r4.core", "4.0.1") diff --git a/examples/typescript-ccda/generate.ts b/examples/typescript-ccda/generate.ts index 4a4f0b8e..5e78788c 100644 --- a/examples/typescript-ccda/generate.ts +++ b/examples/typescript-ccda/generate.ts @@ -1,53 +1,10 @@ // Run this script using Bun CLI with: // bun run scripts/generate-fhir-types.ts -import { CanonicalManager, type PreprocessContext } from "@atomic-ehr/fhir-canonical-manager"; +import { CanonicalManager } from "@atomic-ehr/fhir-canonical-manager"; import { registerFromManager } from "@root/typeschema/register"; import { APIBuilder, prettyReport } from "../../src/api/builder"; - -const preprocessPackage = (ctx: PreprocessContext): PreprocessContext => { - if (ctx.kind !== "resource") return ctx; - if (ctx.package.name === "hl7.cda.uv.core") { - let str = JSON.stringify(ctx.resource); - str = str.replaceAll( - "http://hl7.org/cda/stds/core/StructureDefinition/IVL_TS", - "http://hl7.org/cda/stds/core/StructureDefinition/IVL-TS", - ); - return { ...ctx, resource: JSON.parse(str) }; - } - // CarePlanAct profile binds moodCode to an external NLM ValueSet that - // isn't available in any loaded package. Reuse the base Act binding. - if (ctx.package.name === "hl7.cda.us.ccda") { - const res = ctx.resource as { url?: string }; - if (res.url === "http://hl7.org/cda/us/ccda/StructureDefinition/CarePlanAct") { - let str = JSON.stringify(ctx.resource); - str = str.replaceAll( - "http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113762.1.4.1267.37", - "http://terminology.hl7.org/ValueSet/v3-xDocumentActMood", - ); - return { ...ctx, resource: JSON.parse(str) }; - } - } - // The bundle-type CodeSystem is missing codes "bundle" and - // "subscription-notification" used by BatchBundle and - // SubscriptionNotificationBundle profiles. Patch all instances since - // resolveAny may pick any package's copy of the CodeSystem. - const res = ctx.resource as { url?: string; concept?: { code: string }[] }; - if (res.url === "http://hl7.org/fhir/bundle-type" && res.concept) { - const existing = new Set(res.concept.map((c) => c.code)); - const missing = ["bundle", "subscription-notification"].filter((c) => !existing.has(c)); - if (missing.length > 0) { - return { - ...ctx, - resource: { - ...ctx.resource, - concept: [...res.concept, ...missing.map((code) => ({ code }))], - }, - }; - } - } - return ctx; -}; +import { inPackage, inResource, patchCodeSystem, renameCanonical, swapBinding } from "../../src/api/patches"; if (require.main === module) { console.log("📦 Generating CCDA Types..."); @@ -55,7 +12,30 @@ if (require.main === module) { const manager = CanonicalManager({ packages: [], workingDir: ".codegen-cache/canonical-manager-cache", - preprocessPackage, + patches: { + fhirResource: [ + // IVL_TS is a typo'd canonical in hl7.cda.uv.core (should be IVL-TS). + inPackage("hl7.cda.uv.core", [ + renameCanonical({ + "http://hl7.org/cda/stds/core/StructureDefinition/IVL_TS": + "http://hl7.org/cda/stds/core/StructureDefinition/IVL-TS", + }), + ]), + // CarePlanAct binds moodCode to an external NLM ValueSet absent from every loaded + // package; reuse the base Act binding. + inPackage("hl7.cda.us.ccda", [ + inResource("http://hl7.org/cda/us/ccda/StructureDefinition/CarePlanAct", [ + swapBinding({ + "http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113762.1.4.1267.37": + "http://terminology.hl7.org/ValueSet/v3-xDocumentActMood", + }), + ]), + ]), + // The bundle-type CodeSystem omits codes used by BatchBundle / + // SubscriptionNotificationBundle; patch every copy (resolveAny may pick any). + patchCodeSystem("http://hl7.org/fhir/bundle-type", ["bundle", "subscription-notification"]), + ], + }, }); // Initialize manager with packages to discover CDA resources diff --git a/package.json b/package.json index df77f381..948419a5 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ }, "homepage": "https://github.com/atomic-ehr/codegen#readme", "dependencies": { - "@atomic-ehr/fhir-canonical-manager": "0.0.24", + "@atomic-ehr/fhir-canonical-manager": "0.0.24-canary.20260608091810.e54bf2f", "@atomic-ehr/fhirschema": "0.0.11", "mustache": "^4.2.0", "picocolors": "^1.1.1", diff --git a/src/api/builder.ts b/src/api/builder.ts index e44fd18e..3f7d7b55 100644 --- a/src/api/builder.ts +++ b/src/api/builder.ts @@ -11,6 +11,7 @@ import * as Path from "node:path"; import { CanonicalManager, type LocalPackageConfig, + type Patches, type PreprocessContext, type TgzPackageConfig, } from "@atomic-ehr/fhir-canonical-manager"; @@ -32,6 +33,13 @@ import * as Mustache from "./writer-generator/mustache"; import { TypeScript, type TypeScriptOptions } from "./writer-generator/typescript/writer"; import type { FileBuffer, FileSystemWriter, FileSystemWriterOptions, WriterOptions } from "./writer-generator/writer"; +/** + * Per-phase patch handlers for the `APIBuilder` `patches` option. Each phase is a list of + * handlers (typically `inPackage`/`inResource` combinators), passed straight to the + * CanonicalManager `Patches` config. + */ +export type PatchesInput = Partial; + /** * Configuration options for the API builder */ @@ -171,6 +179,8 @@ export class APIBuilder { userOpts: Partial & { manager?: ReturnType; register?: Register; + /** Per-phase patch handlers passed to the CanonicalManager (package-defect fixes). */ + patches?: PatchesInput; preprocessPackage?: (context: PreprocessContext) => PreprocessContext; ignorePackageIndex?: boolean; logger?: CodegenLogManager; @@ -213,10 +223,16 @@ export class APIBuilder { workingDir: ".codegen-cache/canonical-manager-cache", registry: userOpts.registry, dropCache: userOpts.dropCanonicalManagerCache, + patches: userOpts.patches, preprocessPackage: userOpts.preprocessPackage, ignorePackageIndex: userOpts.ignorePackageIndex, }); this.logger = userOpts.logger ?? mkLogger({ prefix: "api" }); + // `patches` only apply to a CM that this builder constructs; an injected + // manager/register owns its own patch wiring. + if (userOpts.patches && (userOpts.manager || userOpts.register)) { + this.logger.warn("`patches` is ignored when a prebuilt `manager`/`register` is provided."); + } this.options = opts; } diff --git a/src/api/patches/index.ts b/src/api/patches/index.ts new file mode 100644 index 00000000..071e324a --- /dev/null +++ b/src/api/patches/index.ts @@ -0,0 +1,109 @@ +/** + * Codegen patch-helper factories + scoping combinators built on CanonicalManager's `patches` + * runtime. + * + * Helpers are unscoped transforms (`injectDependency`, `renameCanonical`, …); scope them with + * the `inPackage` / `inResource` combinators, which nest. Pass the result via `APIBuilder`'s + * `patches` option (a phase accepts a single handler or a list), e.g. + * `patches: { packageJson: inPackage("de.basisprofil.r4", [injectDependency({ "hl7.fhir.r4.core": "4.0.1" })]) }`. + */ + +import type { PackageId, PackagePatch, PatchReportSink, ResourcePatch } from "@atomic-ehr/fhir-canonical-manager"; +import { matchPackage, type PackageMatch } from "@atomic-ehr/fhir-canonical-manager/patch"; + +/** A non-dropping phase handler — `(pkg, value, report) => value | undefined`. */ +type Handler = (pkg: PackageId, value: V, report: PatchReportSink) => V | undefined; + +/** Run `handlers` left-to-right over `value`; return the result only if something changed. */ +const run = (handlers: Handler[], pkg: PackageId, value: V, report: PatchReportSink): V | undefined => { + let acc = value; + let changed = false; + for (const handler of handlers) { + const result = handler(pkg, acc, report); + if (result !== undefined) { + acc = result; + changed = true; + } + } + return changed ? acc : undefined; +}; + +// ── Scoping combinators ────────────────────────────────────────────────────── + +/** Apply `handlers` only to packages matching `match`. Works for package- and resource-phase + * handlers, and nests with `inResource`. */ +export const inPackage = (match: PackageMatch, handlers: Handler[]): Handler => { + return (pkg, value, report) => (matchPackage(match, pkg) ? run(handlers, pkg, value, report) : undefined); +}; + +/** Apply resource `handlers` only to the resource with the given canonical `url`. */ +export const inResource = (url: string, handlers: ResourcePatch[]): ResourcePatch => { + return (pkg, resource, report) => (resource.url === url ? run(handlers, pkg, resource, report) : undefined); +}; + +// ── Unscoped transform helpers ─────────────────────────────────────────────── + +/** Replace every occurrence of each `from` URL with its `to` throughout the resource body. */ +const replaceUrls = + (renames: Record): ResourcePatch => + (_pkg, resource) => { + let str = JSON.stringify(resource); + let changed = false; + for (const [from, to] of Object.entries(renames)) { + if (str.includes(from)) { + str = str.replaceAll(from, to); + changed = true; + } + } + return changed ? JSON.parse(str) : undefined; + }; + +/** Fix a typo'd canonical URL — the resource's own identity and every reference to it. */ +export const renameCanonical = (renames: Record): ResourcePatch => replaceUrls(renames); + +/** Rewrite reference targets (e.g. a profile that points at the wrong/unavailable type). */ +export const renameReferenceTarget = (renames: Record): ResourcePatch => replaceUrls(renames); + +/** Swap a binding's ValueSet URL for an available one (e.g. an external set not in any package). */ +export const swapBinding = (swaps: Record): ResourcePatch => replaceUrls(swaps); + +/** + * Add missing codes to a CodeSystem (matched by `url`) — for systems that omit codes used by + * profiles. No-op if the resource isn't that CodeSystem or already declares every code. + */ +export const patchCodeSystem = + (url: string, codes: string[]): ResourcePatch => + (_pkg, resource) => { + if (resource.url !== url) return undefined; + const concept = (resource as { concept?: { code: string }[] }).concept; + if (!concept) return undefined; + const existing = new Set(concept.map((c) => c.code)); + const missing = codes.filter((code) => !existing.has(code)); + if (missing.length === 0) return undefined; + return { ...resource, concept: [...concept, ...missing.map((code) => ({ code }))] }; + }; + +/** + * Inject FHIR package dependencies into the manifest when they aren't already declared (a common + * defect: a package references core types without depending on the core package). Scope it with + * `inPackage`. No-op if every dep is already declared. + */ +export const injectDependency = + (deps: Record): PackagePatch => + (_pkg, packageJson) => { + const existing = (packageJson.dependencies as Record | undefined) ?? {}; + const missing = Object.entries(deps).filter(([name]) => !(name in existing)); + if (missing.length === 0) return undefined; + return { ...packageJson, dependencies: { ...existing, ...Object.fromEntries(missing) } }; + }; + +/** + * Rename a package whose manifest name is a typo, via an old-name → new-name map. No-op for + * packages not in the map. + */ +export const renamePackage = + (renames: Record): PackagePatch => + (pkg, packageJson) => { + const renamed = renames[pkg.name]; + return renamed === undefined ? undefined : { ...packageJson, name: renamed }; + };