diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..963a53824 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,26 @@ +{ + "hooks": { + "PreCompact": [ + { + "hooks": [ + { + "command": "bd prime", + "type": "command" + } + ], + "matcher": "" + } + ], + "SessionStart": [ + { + "hooks": [ + { + "command": "bd prime", + "type": "command" + } + ], + "matcher": "" + } + ] + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 44af24e52..60533411c 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,8 @@ test-output /generated .typeschema-cache *.cpuprofile + +# Beads / Dolt files (added by bd init) +.dolt/ +*.db +.beads-credential-key diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..9390d72db --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,84 @@ +# Agent Instructions + +This project uses **bd** (beads) for issue tracking. Run `bd prime` for full workflow context. + +## Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --claim # Claim work atomically +bd close # Complete work +bd dolt push # Push beads data to remote +``` + +## Non-Interactive Shell Commands + +**ALWAYS use non-interactive flags** with file operations to avoid hanging on confirmation prompts. + +Shell commands like `cp`, `mv`, and `rm` may be aliased to include `-i` (interactive) mode on some systems, causing the agent to hang indefinitely waiting for y/n input. + +**Use these forms instead:** +```bash +# Force overwrite without prompting +cp -f source dest # NOT: cp source dest +mv -f source dest # NOT: mv source dest +rm -f file # NOT: rm file + +# For recursive operations +rm -rf directory # NOT: rm -r directory +cp -rf source dest # NOT: cp -r source dest +``` + +**Other commands that may prompt:** +- `scp` - use `-o BatchMode=yes` for non-interactive +- `ssh` - use `-o BatchMode=yes` to fail instead of prompting +- `apt-get` - use `-y` flag +- `brew` - use `HOMEBREW_NO_AUTO_UPDATE=1` env var + + +## Beads Issue Tracker + +This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands. + +### Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --claim # Claim work +bd close # Complete work +``` + +### Rules + +- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists +- Run `bd prime` for detailed command reference and session close protocol +- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files + +## Session Completion + +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. + +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **PUSH TO REMOTE** - This is MANDATORY: + ```bash + git pull --rebase + bd dolt push + git push + git status # MUST show "up to date with origin" + ``` +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..59ee0d685 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Fixed + +- **Versioned canonical resolution** for cross-package base types — packages with broken `.index.json` (e.g. `de.basisprofil.r4@1.5.4`) now fall back to direct directory scan via node_modules, enabling KBV profiles with versioned base type references to resolve correctly diff --git a/CLAUDE.md b/CLAUDE.md index 121b230ac..008989c44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -369,3 +369,51 @@ Detection uses `mkIsFamilyType(tsIndex)` which checks `schema.typeFamily.resourc - [Canonical Manager](https://github.com/atomic-ehr/canonical-manager) - [FHIR Schema](https://github.com/fhir-schema/fhir-schema) - [TypeSchema Spec](https://www.health-samurai.io/articles/type-schema-a-pragmatic-approach-to-build-fhir-sdk) + + + +## Beads Issue Tracker + +This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands. + +### Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --claim # Claim work +bd close # Complete work +``` + +### Rules + +- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists +- Run `bd prime` for detailed command reference and session close protocol +- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files + +## Session Completion + +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. + +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **PUSH TO REMOTE** - This is MANDATORY: + ```bash + git pull --rebase + bd dolt push + git push + git status # MUST show "up to date with origin" + ``` +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds + diff --git a/COGNOVIS.md b/COGNOVIS.md new file mode 100644 index 000000000..9cad5b48c --- /dev/null +++ b/COGNOVIS.md @@ -0,0 +1,112 @@ +# cognovis/codegen + +This is a cognovis fork of [atomic-ehr/codegen](https://github.com/atomic-ehr/codegen) — the FHIR TypeScript/Python/C# code generator. + +## Current status: HIBERNATE + +**As of 2026-04-21:** The fork has no production-relevant fork-only code. All of our previous patches (the duplicate-`meta`-key fix, KBV on-the-fly workarounds) are merged upstream and ship in `@atomic-ehr/codegen@0.0.13`. Polaris / mira-adapters consumes `@atomic-ehr/codegen` directly from npm. + +The fork is retained for three narrow purposes: +1. **Doc anchor** — this file and the roadmap in `.beads/` +2. **PR staging** — clean `fix/` and `feat/` branches cut from `main` for upstream PRs +3. **Future baking** — a place to develop and production-bake fork-only features before proposing them upstream + +When a fork-only feature is actively in development (see "Reactivation" below), we create a fresh consumer-snapshot branch on demand. We deliberately do **not** maintain a standing consumer branch, because that infrastructure (CI builds, committed `dist/`, force-push discipline) has real costs and only earns its keep when there is actual fork-only code to ship. + +## Reactivation criteria + +Leave hibernate mode when **all** of these hold: +- A fork-only change is developed on `cognovis/next` (starts with the [FORK] epic `codegen-4cw`: scalar slice setters + input-type flattening, once unblocked) +- Polaris / mira-adapters needs to production-bake that change before it lands upstream +- The upstream PR is not yet merged (after merge, we return to hibernate and Polaris goes back to the npm release) + +Reactivation steps: +1. Create a fresh `cognovis/mira-adapters` (or analogous) snapshot branch from `cognovis/next` +2. Build `dist/`, commit it on the snapshot branch, push +3. Polaris pins to the git URL: `github:cognovis/codegen#cognovis/mira-adapters` +4. Automate rebuilds if the baking period is non-trivial (GHA on push to `cognovis/next`) +5. After upstream merge and npm release, revert Polaris to the npm dep, archive the snapshot branch as `archive/-` tag, delete the branch + +## Scope (when active) + +This fork covers **FHIR codegen extensions only** — vendor-neutral work that makes sense in a FHIR code generator. Aidbox-specific client code, persistence, validation-at-runtime etc. do **not** live here; they belong in `cognovis/aidbox-ts-sdk`. + +In-scope examples: +- Bug fixes in the TypeScript profile writer +- Scalar slice setters (`setBsnr("12345")` for slices with one primitive leaf after pattern-omit) +- Input-type flattening (`Profile.fromInput({bsnr, ik})`) +- Regression tests for profile patterns our IGs exercise + +Out-of-scope: anything that ties the generated output to a specific FHIR server (Aidbox, HAPI, etc.). + +## Release-gate philosophy + +Upstream `examples/on-the-fly/kbv-r4/` is the real release gate for anything that touches KBV: it pulls `kbv.ita.for@1.3.1` from Simplifier, generates types, and runs Runtime profile assertions. We rely on it instead of maintaining synthetic companion tests, because: +- Synthetic tests that assert on emitted code structure (regex matches, snapshots) are brittle against legitimate generator refactors +- The on-the-fly test catches the real-world failure modes (package index issues, dependency injection, KBV profile shapes) that synthetic tests miss +- Running `bun test examples/on-the-fly/kbv-r4/` before any cognovis snapshot release is sufficient + +If we need offline/CI-resilient smoke coverage in the future, we add it then — not speculatively. + +## Branch model + +| Branch | Purpose | Sync | +|---|---|---| +| `main` | Pure mirror of [atomic-ehr/codegen `main`](https://github.com/atomic-ehr/codegen/tree/main). Never commit to this directly; always fast-forward from upstream. | `git fetch upstream && git reset --hard upstream/main && git push origin main` | +| `cognovis/next` | Working / integrating branch. Fork-specific features and infra (this file, `.beads/`) live here on top of `main`. | Rebase onto `main` when syncing with upstream. | +| `fix/`, `feat/` | Short-lived branches cut from pristine `main` for upstream PRs. Never base these on `cognovis/next` — keep them clean so the PR diff only shows the feature. | Delete after upstream merge or close. | +| `cognovis/` | **Not standing.** Created on-demand during reactivation (see above). | Created fresh each time; archived as tag after use. | + +### Current long-lived branches + +- `main` — upstream mirror at `a7a8dcf9` (atomic-ehr@0.0.13) +- `cognovis/next` — upstream + fork infra (COGNOVIS.md, `.beads/`, CalVer bump) + +### Archived + +- `archive/2026-04-mira-adapters` — tag preserving the dismantled standing consumer branch from the pre-hibernate era. Restore with `git checkout -b cognovis/mira-adapters archive/2026-04-mira-adapters` if a similar reactivation is needed. + +## Upstream PR workflow + +1. Branch `fix/` or `feat/` from `main` — **not** `cognovis/next`. Upstream must see a clean, focused diff. +2. Implement + test. Commit on the `fix/` branch with a conventional-commit message. +3. Rebase `cognovis/next` on top to pick up the fix locally. +4. `gh pr create --repo atomic-ehr/codegen --head cognovis:` to open the upstream PR. +5. When upstream merges, delete the branch. The equivalent commit lands in `main` on the next upstream sync; `cognovis/next` rebases cleanly and our version of the commit drops out. + +If a change is inherently fork-only (e.g. infra, documentation, opinionated API surface we're not ready to propose upstream), document it in the commit message: `fork-only: `. + +## Upstream sync + +Cadence: on demand when (a) upstream ships a fix we want, (b) one of our open upstream PRs merges, or (c) periodically to avoid drift. + +```bash +# 1. Sync main to upstream +git checkout main && git fetch upstream && git reset --hard upstream/main && git push origin main + +# 2. Rebase cognovis/next onto updated main +git checkout cognovis/next && git rebase main + +# 3. Re-run tests to catch regressions early +bun test + +# 4. If a consumer-snapshot branch is live (reactivation phase), rebase it as well +# Otherwise (hibernate), we are done. +``` + +See `.beads/` for the "Upstream sync runbook" bead with scripted tooling (in progress). + +## Project state & roadmap + +Tracked in `.beads/` (Dolt-backed). See `bd ready` for currently-actionable work. + +High-level roadmap: + +- **Hibernate maintenance** — keep `cognovis/next` rebased onto upstream; minimal ongoing cost. +- **Fork-first feature: scalar slice setters + input-type flattening** (`codegen-4cw`). Currently blocked on fhir-de builder stabilization in Polaris. Reactivates the consumer-snapshot flow when development starts. +- **Stabilise**: when/if atomic-ehr/codegen reaches 0.1.0 / 1.0.0 with a clear API contract, re-evaluate whether continuing to fork is still warranted. + +## Contact + +Technical: Malte Sussdorff (malte.sussdorff@cognovis.de) +Upstream maintainer: [ryukzak](https://github.com/ryukzak) — responsive, open to PRs. diff --git a/VERSION b/VERSION new file mode 100644 index 000000000..d7c163aa9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +2026.04.0 diff --git a/src/typeschema/register.ts b/src/typeschema/register.ts index 141dfccac..8974d5db0 100644 --- a/src/typeschema/register.ts +++ b/src/typeschema/register.ts @@ -1,3 +1,7 @@ +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; import { CanonicalManager } from "@atomic-ehr/fhir-canonical-manager"; import * as fhirschema from "@atomic-ehr/fhirschema"; import { @@ -87,13 +91,27 @@ const mkPackageAwareResolver = async ( deep: number, acc: PackageAwareResolver, logger?: CodegenLog, + nodeModulesPath?: string, ): Promise => { const pkgId = packageMetaToFhir(pkg); logger?.info(`${" ".repeat(deep * 2)}+ ${pkgId}`); if (acc[pkgId]) return acc[pkgId]; const index = mkEmptyPkgIndex(pkg); - for (const resource of await manager.search({ package: pkg })) { + + let resources: FocusedResource[] = (await manager.search({ package: pkg })) as unknown as FocusedResource[]; + + // Fallback: some FHIR packages (e.g. de.basisprofil.r4@1.5.4) ship a .index.json with + // entries that have null `id` fields (e.g. ImplementationGuide resources). The canonical + // manager's strict parseIndex validation rejects the entire .index.json in this case, + // leaving the package with 0 indexed resources. When that happens, we fall back to + // reading the package files directly from the canonical manager's node_modules cache. + // This is equivalent to the canonical manager's own scanDirectoryForResources fallback. + if (resources.length === 0 && nodeModulesPath) { + resources = await scanNodeModulesPackage(nodeModulesPath, pkg, logger); + } + + for (const resource of resources) { const rawUrl = resource.url; if (!rawUrl) continue; if (!(isStructureDefinition(resource) || isValueSet(resource) || isCodeSystem(resource))) continue; @@ -105,7 +123,14 @@ const mkPackageAwareResolver = async ( const deps = await readPackageDependencies(manager, pkg); for (const depPkg of deps) { - const { canonicalResolution } = await mkPackageAwareResolver(manager, depPkg, deep + 1, acc, logger); + const { canonicalResolution } = await mkPackageAwareResolver( + manager, + depPkg, + deep + 1, + acc, + logger, + nodeModulesPath, + ); for (const [surl, resolutions] of Object.entries(canonicalResolution)) { const url = surl as CanonicalUrl; index.canonicalResolution[url] = [...(index.canonicalResolution[url] || []), ...resolutions]; @@ -164,16 +189,39 @@ export type RegisterConfig = { focusedPackages?: PackageMeta[]; /** Custom FHIR package registry URL */ registry?: string; + /** + * Path to the canonical manager's node_modules directory. + * Used as a fallback when the canonical manager reports 0 resources for a package + * (which happens when the package's .index.json has invalid entries). + * Computed automatically in registerFromPackageMetas and registerFromManager. + * Can be overridden explicitly if the canonical manager is configured with a custom + * workingDir or a non-standard package layout. + */ + nodeModulesPath?: string; }; export const registerFromManager = async ( manager: ReturnType, - { logger, focusedPackages }: RegisterConfig, + { logger, focusedPackages, nodeModulesPath }: RegisterConfig, ): Promise => { const packages = focusedPackages ?? (await manager.packages()); + + // Compute the node_modules fallback path if not supplied by the caller. + // This covers APIBuilder callers that invoke registerFromManager directly without + // going through registerFromPackageMetas. Both code paths use the same hardcoded + // workingDir, so the cache-key derivation produces the correct path. + // NOTE: computeCanonicalManagerCacheKey mirrors the SHA-256 algorithm inside + // @atomic-ehr/fhir-canonical-manager@0.0.23 (dist/cache.js#computeCacheKey). + // If the canonical manager changes its hash strategy, this fallback will silently + // stop working — update both together. + if (!nodeModulesPath && focusedPackages) { + const pkgNames = focusedPackages.map(packageMetaToNpm); + nodeModulesPath = computeNodeModulesPath(pkgNames, CANONICAL_MANAGER_WORKING_DIR); + } + const resolver: PackageAwareResolver = {}; for (const pkg of packages) { - await mkPackageAwareResolver(manager, pkg, 0, resolver, logger); + await mkPackageAwareResolver(manager, pkg, 0, resolver, logger, nodeModulesPath); } enrichResolver(resolver, logger); @@ -335,6 +383,92 @@ export const registerFromManager = async ( }; }; +/** + * Compute the same cache key as @atomic-ehr/fhir-canonical-manager uses internally + * (mirrors computeCacheKey in dist/cache.js — tracked at @0.0.23). + * Key: SHA-256 of the sorted, JSON-stringified package spec list (e.g. ["kbv.basis@1.8.0", ...]). + * NOTE: Only the explicitly requested packages go into the key; transitive dependencies + * are installed into the same node_modules but do not affect the hash. + */ +const computeCanonicalManagerCacheKey = (packageNames: string[]): string => { + const content = JSON.stringify([...packageNames].sort()); + return createHash("sha256").update(content).digest("hex"); +}; + +/** + * Returns the path to the canonical manager's node_modules directory for a given + * set of package names and working directory. Both this function and process.cwd() + * must stay in sync with @atomic-ehr/fhir-canonical-manager's cacheRecordPaths logic. + */ +const computeNodeModulesPath = (packageNames: string[], workingDir: string): string => { + const cacheKey = computeCanonicalManagerCacheKey(packageNames); + return join(process.cwd(), workingDir, cacheKey, "node", "node_modules"); +}; + +/** + * Some FHIR packages (e.g. de.basisprofil.r4@1.5.4) ship an .index.json that contains + * entries where the `id` field is null (e.g. ImplementationGuide resources without an id). + * The canonical manager's parseIndex function treats ANY such entry as fatal — it returns + * null and silently skips ALL resources from that package. This means `manager.search()` + * returns 0 resources for the affected package, so nothing gets added to the canonical + * resolution and cross-package base-type lookups fail at transform time. + * + * Rather than trying to patch the canonical manager's cache (which gets regenerated on + * reinstall), we scan the package directory directly from the canonical manager's + * node_modules when the manager reports 0 resources for a focused package. + * This mirrors what the canonical manager's own `scanDirectoryForResources` does. + */ +const scanNodeModulesPackage = async ( + nodeModulesPath: string, + pkg: PackageMeta, + logger?: CodegenLog, +): Promise => { + const pkgDir = join(nodeModulesPath, pkg.name); + if (!existsSync(pkgDir)) return []; + + const resources: FocusedResource[] = []; + let fileNames: string[]; + try { + // readdir without withFileTypes returns string[] — avoids Bun's Dirent type mismatch + fileNames = await readdir(pkgDir); + } catch (err) { + logger?.dryWarn( + "#canonicalManagerFallback", + `Failed to read directory for ${packageMetaToFhir(pkg)} at ${pkgDir}: ${err}`, + ); + return []; + } + + for (const name of fileNames) { + if (!name.endsWith(".json")) continue; + if (name === "package.json" || name === ".index.json") continue; + try { + const content = await readFile(join(pkgDir, name), "utf-8"); + const resource = JSON.parse(content) as Record; + if (!resource.resourceType || !resource.url) continue; + if (!(isStructureDefinition(resource) || isValueSet(resource) || isCodeSystem(resource))) continue; + resources.push(resource as unknown as FocusedResource); + } catch (err) { + logger?.dryWarn( + "#canonicalManagerFallback", + `Skipping ${name} in ${packageMetaToFhir(pkg)}: ${err}`, + ); + } + } + + if (resources.length > 0) { + logger?.warn( + "#canonicalManagerFallback", + `Package ${packageMetaToFhir(pkg)} had 0 resources in canonical manager ` + + `(likely due to invalid .index.json entries). ` + + `Falling back to direct directory scan: ${resources.length} resources found.`, + ); + } + return resources; +}; + +const CANONICAL_MANAGER_WORKING_DIR = ".codegen-cache/canonical-manager-cache" as const; + export const registerFromPackageMetas = async ( packageMetas: PackageMeta[], conf: RegisterConfig, @@ -343,13 +477,17 @@ export const registerFromPackageMetas = async ( conf?.logger?.info(`Loading FHIR packages: ${packageNames.join(", ")}`); const manager = CanonicalManager({ packages: packageNames, - workingDir: ".codegen-cache/canonical-manager-cache", + workingDir: CANONICAL_MANAGER_WORKING_DIR, registry: conf.registry || undefined, }); await manager.init(); + return await registerFromManager(manager, { ...conf, focusedPackages: packageMetas, + // Provide nodeModulesPath explicitly so registerFromManager doesn't have to + // recompute it from focusedPackages (both produce the same result here). + nodeModulesPath: computeNodeModulesPath(packageNames, CANONICAL_MANAGER_WORKING_DIR), }); }; diff --git a/src/utils/log.ts b/src/utils/log.ts index 137240cf2..d7a24a497 100644 --- a/src/utils/log.ts +++ b/src/utils/log.ts @@ -18,7 +18,8 @@ export type CodegenTag = | "#duplicateSchema" | "#duplicateCanonical" | "#resolveBase" - | "#resolveCollisionMiss"; + | "#resolveCollisionMiss" + | "#canonicalManagerFallback"; export type CodegenLog = Log; export type CodegenLogManager = LogManager; diff --git a/test/unit/typeschema/versioned-canonical.test.ts b/test/unit/typeschema/versioned-canonical.test.ts new file mode 100644 index 000000000..63e4fd2a1 --- /dev/null +++ b/test/unit/typeschema/versioned-canonical.test.ts @@ -0,0 +1,104 @@ +/** + * Regression tests for versioned canonical resolution across packages. + * + * Root cause: kbv.basis@1.8.0 profiles reference de.basisprofil.r4@1.5.4 profiles + * using versioned canonical URLs (e.g. |1.5.4 suffix). de.basisprofil.r4@1.5.4 ships + * an .index.json that contains an ImplementationGuide entry with id=null. The canonical + * manager's parseIndex rejects the entire .index.json when any entry has a null id, + * silently leaving de.basisprofil.r4 with 0 indexed resources. This caused "Base resource + * not found" errors when transforming KBV profiles that inherit from de.basisprofil.r4 + * profiles via versioned canonical references. + * + * Fix: registerFromPackageMetas and registerFromManager compute the canonical manager's + * node_modules path and pass it as nodeModulesPath. mkPackageAwareResolver uses it as a + * fallback when the canonical manager returns 0 resources for a focused package — scanning + * the directory directly, which has no id-null restriction. + * + * See: codegen-vrq + */ +import { beforeAll, describe, expect, it } from "bun:test"; +import type { CanonicalUrl } from "@root/typeschema/types"; +import type { Register } from "@typeschema/register"; +import { registerFromPackageMetas } from "@typeschema/register"; + +const kbvPkg = { name: "kbv.basis", version: "1.8.0" }; +const basisprofil = { name: "de.basisprofil.r4", version: "1.5.4" }; + +describe("Versioned canonical resolution (codegen-vrq)", () => { + let register: Register; + + beforeAll(async () => { + register = await registerFromPackageMetas([kbvPkg, basisprofil], {}); + }); + + describe("resolveFs — cross-package base type lookup", () => { + it("finds de.basisprofil.r4 profile from kbv.basis context using clean URL", () => { + const url = "http://fhir.de/StructureDefinition/observation-de-pflegegrad" as CanonicalUrl; + const resolved = register.resolveFs(kbvPkg, url); + expect(resolved).toBeDefined(); + expect(resolved?.url).toBe(url); + }); + + it("strips |version suffix before lookup — versioned canonical resolves to the same schema", () => { + const versioned = + "http://fhir.de/StructureDefinition/observation-de-pflegegrad|1.5.4" as CanonicalUrl; + const clean = "http://fhir.de/StructureDefinition/observation-de-pflegegrad" as CanonicalUrl; + + // ensureSpecializationCanonicalUrl must strip the |version suffix + const stripped = register.ensureSpecializationCanonicalUrl(versioned); + expect(stripped).toBe(clean); + + // resolveFs with the stripped URL must find the schema + const resolved = register.resolveFs(kbvPkg, stripped); + expect(resolved).toBeDefined(); + expect(resolved?.url).toBe(clean); + }); + + it("resolves all vitalsign profiles that kbv.basis pins to de.basisprofil.r4@1.5.4", () => { + // These are the profiles that kbv.basis@1.8.0 uses with |1.5.4 suffix in baseDefinition + const vitalsignUrls: CanonicalUrl[] = [ + "http://fhir.de/StructureDefinition/observation-de-vitalsign-blutdruck", + "http://fhir.de/StructureDefinition/observation-de-vitalsign-koerpergroesse", + "http://fhir.de/StructureDefinition/observation-de-vitalsign-koerpergewicht", + "http://fhir.de/StructureDefinition/observation-de-vitalsign-koerpertemperatur", + ].map((u) => u as CanonicalUrl); + + for (const url of vitalsignUrls) { + const resolved = register.resolveFs(kbvPkg, url); + expect(resolved, `Expected ${url} to resolve`).toBeDefined(); + } + }); + }); + + describe("transformFhirSchema — base type resolution for KBV profiles", () => { + it("resolves base type for KBV_PR_Base_Observation_Care_Level (versioned pflegegrad reference)", () => { + // KBV_PR_Base_Observation_Care_Level has baseDefinition pointing to pflegegrad|1.5.4. + // Before the fix, transformFhirSchema would throw "Base resource not found '...pflegegrad|1.5.4'" + // because de.basisprofil.r4 had 0 indexed resources in the canonical manager. + const careLevelUrl = "https://fhir.kbv.de/StructureDefinition/KBV_PR_Base_Observation_Care_Level"; + const careLevel = register.resolveFs(kbvPkg, careLevelUrl as CanonicalUrl); + expect(careLevel, "KBV_PR_Base_Observation_Care_Level must be resolvable").toBeDefined(); + + // The base type of care level is observation-de-pflegegrad|1.5.4. + // After stripping the version suffix, it must be resolvable. + expect(careLevel!.base, "care level must have a base definition").toBeDefined(); + const strippedBase = register.ensureSpecializationCanonicalUrl(careLevel!.base!); + const baseResolved = register.resolveFs(kbvPkg, strippedBase); + expect( + baseResolved, + `Base type '${strippedBase}' (from '${careLevel!.base}') must resolve`, + ).toBeDefined(); + }); + + it("resolves base type chain for KBV vitalsign profiles with versioned de.basisprofil.r4 references", () => { + // KBV blood pressure profile: baseDefinition = ...observation-de-vitalsign-blutdruck|1.5.4 + const bpUrl = "https://fhir.kbv.de/StructureDefinition/KBV_PR_Base_Observation_Blood_Pressure"; + const bp = register.resolveFs(kbvPkg, bpUrl as CanonicalUrl); + expect(bp, "KBV_PR_Base_Observation_Blood_Pressure must be resolvable").toBeDefined(); + + const strippedBase = register.ensureSpecializationCanonicalUrl(bp!.base!); + const baseResolved = register.resolveFs(kbvPkg, strippedBase); + expect(baseResolved, `Base type '${strippedBase}' must resolve`).toBeDefined(); + }); + }); +});