From 5a864dfcc2c1aadbea327ada4a74a3357b4a9e6a Mon Sep 17 00:00:00 2001 From: Malte Sussdorff Date: Mon, 20 Apr 2026 12:59:03 +0200 Subject: [PATCH 01/10] chore(cognovis): add COGNOVIS.md strategy doc + bd init infrastructure Fork-only infrastructure commit. Establishes this fork as a long-lived cognovis maintenance branch of atomic-ehr/codegen, documents branch model (main mirrors upstream, cognovis/next integrates, cognovis/* for consumer snapshots, fix/*/feat/* for upstream PRs), and initialises beads for roadmap tracking. - COGNOVIS.md: strategy, scope, branch model, consumer integration guide, upstream sync runbook - .claude/settings.json: bd prime SessionStart + PreCompact hooks - CLAUDE.md: beads integration block added by bd init - AGENTS.md: bd workflow quick reference added by bd init - .gitignore: ignore .dolt/, *.db, .beads-credential-key per bd init This commit lives on cognovis/next only. Not part of any upstream PR. --- .claude/settings.json | 26 +++++++++++ .gitignore | 5 ++ AGENTS.md | 84 +++++++++++++++++++++++++++++++++ CLAUDE.md | 48 +++++++++++++++++++ COGNOVIS.md | 106 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 269 insertions(+) create mode 100644 .claude/settings.json create mode 100644 AGENTS.md create mode 100644 COGNOVIS.md diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..963a5382 --- /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 44af24e5..60533411 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 00000000..9390d72d --- /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/CLAUDE.md b/CLAUDE.md index 121b230a..008989c4 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 00000000..a5b3be66 --- /dev/null +++ b/COGNOVIS.md @@ -0,0 +1,106 @@ +# cognovis/codegen + +This is a long-lived cognovis fork of [atomic-ehr/codegen](https://github.com/atomic-ehr/codegen) — the FHIR TypeScript/Python/C# code generator. + +## Why we fork + +We consume `@atomic-ehr/codegen` from two production contexts (the Polaris / mira-adapters external integration layer and, longer term, the mira API) and hit regressions on multiple consecutive 0.0.x releases. Rather than pin-and-hand-patch, we maintain this fork as our de-facto upstream until codegen reaches production-quality stability. Every non-trivial change either already exists upstream or has a justified reason to be fork-only. + +## Scope + +This fork covers **FHIR TypeScript 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 (e.g. the duplicate-`meta`-key regression in 0.0.10+). +- 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 (e.g. `meta.min = 1`). + +Out-of-scope: anything that ties the generated output to a specific FHIR server (Aidbox, HAPI, etc.). + +## 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` | Our working / integrating branch. All fork-specific features and infra (this file, `.beads/`) live here on top of `main`. | Rebase onto `main` when syncing with upstream. | +| `cognovis/` | Consumer snapshot branches (e.g. `cognovis/mira-adapters`) — rebase from `cognovis/next` and add consumer-specific scaffolding such as committed `dist/` for git-URL installs. | Rebase from `cognovis/next` before pinning consumers. | +| `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. | + +### Current long-lived branches + +- `main` — upstream mirror, currently at `373dc665` (atomic-ehr@0.0.12 + py-to-json-resource-type fix) +- `cognovis/next` — upstream + `fix/profile-duplicate-meta-key` + this infra +- `cognovis/mira-adapters` — consumer snapshot with committed `dist/` so `bun add github:cognovis/codegen#cognovis/mira-adapters` works +- `fix/profile-duplicate-meta-key` — in-flight upstream PR [atomic-ehr/codegen#138](https://github.com/atomic-ehr/codegen/pull/138) + +## Consumer integration + +Consumers pin via git URL to a stable consumer branch, e.g.: + +```json +{ + "devDependencies": { + "@atomic-ehr/codegen": "github:cognovis/codegen#cognovis/mira-adapters" + } +} +``` + +The consumer branch includes pre-built `dist/` so bun/npm can use the package without running the fork's build step (bun doesn't install a git dep's devDependencies; committing `dist/` avoids a `prepare: tsup` script that would fail for lack of `tsup`). + +When a new cognovis snapshot is needed: +1. On `cognovis/next`, ensure everything builds and tests pass (`bun test test/api && bun run build`). +2. Rebase `cognovis/` onto `cognovis/next`. +3. Run `bun run build` and commit the updated `dist/`. +4. Force-push `cognovis/` (or move its tip forward). +5. Consumer `bun update @atomic-ehr/codegen`. + +## 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. `dist/` on consumer branches, or 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 (monthly suggestion) 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 + build to catch regressions early +bun test test/api/ + +# 4. Rebase each cognovis/ onto updated cognovis/next +git checkout cognovis/mira-adapters && git rebase cognovis/next +bun run build && git add dist/ && git commit --amend --no-edit +git push --force-with-lease + +# 5. Notify consumers to `bun update` +``` + +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: + +- **Ship** upstream PR [#138](https://github.com/atomic-ehr/codegen/pull/138) (duplicate-meta fix) — merge or revise per review. +- **Contribute upstream**: regression test for `meta.min = 1` profiles (independent of #138, preventive). +- **Fork-first feature**: scalar slice setters + input-type flattening. Develop in our fork, let it bake against Polaris's KBV usage, then propose upstream once we're confident the API surface is right. +- **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. From 67bb80f508dbec22953eb4973817c654dfd510c5 Mon Sep 17 00:00:00 2001 From: Malte Sussdorff Date: Mon, 20 Apr 2026 14:45:06 +0200 Subject: [PATCH 02/10] =?UTF-8?q?test(codegen-ejm):=20red=20=E2=80=94=20ad?= =?UTF-8?q?d=20meta.min=3D1=20StructureDefinition=20fixture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../fixtures/org-with-required-meta.json | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 test/api/write-generator/fixtures/org-with-required-meta.json diff --git a/test/api/write-generator/fixtures/org-with-required-meta.json b/test/api/write-generator/fixtures/org-with-required-meta.json new file mode 100644 index 00000000..f3ba564e --- /dev/null +++ b/test/api/write-generator/fixtures/org-with-required-meta.json @@ -0,0 +1,27 @@ +{ + "resourceType": "StructureDefinition", + "id": "org-with-required-meta", + "url": "http://example.org/fhir/StructureDefinition/OrgWithRequiredMeta", + "version": "0.0.1", + "name": "OrgWithRequiredMeta", + "title": "Organization with Required Meta (regression test for duplicate-meta-key bug)", + "status": "draft", + "date": "2024-01-01", + "publisher": "atomic-ehr/codegen test suite", + "description": "Minimal Organization profile pinning meta.min=1, mirroring KBV ITA FOR/ERP/EAU pattern. Used as a regression fixture for the duplicate-meta-key bug (PR #138).", + "fhirVersion": "4.0.1", + "kind": "resource", + "abstract": false, + "type": "Organization", + "baseDefinition": "http://hl7.org/fhir/StructureDefinition/Organization", + "derivation": "constraint", + "differential": { + "element": [ + { + "id": "Organization.meta", + "path": "Organization.meta", + "min": 1 + } + ] + } +} From 2b797d5d3ef6b45d4764d003da54b1df7f03ccef Mon Sep 17 00:00:00 2001 From: Malte Sussdorff Date: Mon, 20 Apr 2026 14:46:14 +0200 Subject: [PATCH 03/10] =?UTF-8?q?test(codegen-ejm):=20green=20=E2=80=94=20?= =?UTF-8?q?regression=20test=20for=20duplicate-meta-key=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Bun test that verifies a profile with meta.min=1 generates exactly one meta: key in createResource (no TS1117), using spread syntax for merge. Co-Authored-By: Claude Sonnet 4.6 --- .../meta-regression.test.ts.snap | 97 +++++++++++++++++++ .../write-generator/meta-regression.test.ts | 80 +++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 test/api/write-generator/__snapshots__/meta-regression.test.ts.snap create mode 100644 test/api/write-generator/meta-regression.test.ts diff --git a/test/api/write-generator/__snapshots__/meta-regression.test.ts.snap b/test/api/write-generator/__snapshots__/meta-regression.test.ts.snap new file mode 100644 index 00000000..6a1622ea --- /dev/null +++ b/test/api/write-generator/__snapshots__/meta-regression.test.ts.snap @@ -0,0 +1,97 @@ +// Bun Snapshot v1, https://goo.gl/fbAQLP + +exports[`Regression: profile with meta.min=1 generates single meta key full profile snapshot 1`] = ` +"// 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. + +import type { Meta } from "../../hl7-fhir-r4-core/Meta"; +import type { Organization } from "../../hl7-fhir-r4-core/Organization"; + +import { + ensureProfile, + validateRequired, + validateExcluded, + validateFixedValue, + validateSliceCardinality, + validateSliceFields, + validateEnum, + validateReference, + validateChoiceRequired, + validateMustSupport, +} from "../../profile-helpers"; + +export type OrgWithRequiredMetaProfileRaw = { + meta: Meta; +} + +// CanonicalURL: http://example.org/fhir/StructureDefinition/OrgWithRequiredMeta (pkg: example.meta.regression#0.0.1) +export class OrgWithRequiredMetaProfile { + static readonly canonicalUrl = "http://example.org/fhir/StructureDefinition/OrgWithRequiredMeta"; + + private resource: Organization; + + constructor (resource: Organization) { + this.resource = resource; + } + + static from (resource: Organization) : OrgWithRequiredMetaProfile { + if (!resource.meta?.profile?.includes(OrgWithRequiredMetaProfile.canonicalUrl)) { + throw new Error(\`OrgWithRequiredMetaProfile: meta.profile must include \${OrgWithRequiredMetaProfile.canonicalUrl}\`) + } + const profile = new OrgWithRequiredMetaProfile(resource); + const { errors } = profile.validate(); + if (errors.length > 0) throw new Error(errors.join("; ")) + return profile; + } + + static apply (resource: Organization) : OrgWithRequiredMetaProfile { + ensureProfile(resource, OrgWithRequiredMetaProfile.canonicalUrl); + return new OrgWithRequiredMetaProfile(resource); + } + + static createResource (args: OrgWithRequiredMetaProfileRaw) : Organization { + const resource: Organization = { + resourceType: "Organization", + meta: { ...args.meta, profile: [...(args.meta?.profile ?? []), OrgWithRequiredMetaProfile.canonicalUrl] }, + } + return resource; + } + + static create (args: OrgWithRequiredMetaProfileRaw) : OrgWithRequiredMetaProfile { + const resource = OrgWithRequiredMetaProfile.createResource(args); + return OrgWithRequiredMetaProfile.apply(resource); + } + + toResource () : Organization { + return this.resource; + } + + // Field accessors + getMeta () : Meta | undefined { + return this.resource.meta as Meta | undefined; + } + + setMeta (value: Meta) : this { + Object.assign(this.resource, { meta: value }); + return this; + } + + // Extensions + // Slices + // Validation + validate(): { errors: string[]; warnings: string[] } { + const profileName = "OrgWithRequiredMeta" + const res = this.resource + return { + errors: [ + ...validateRequired(res, profileName, "meta"), + ], + warnings: [], + } + } + +} + +" +`; diff --git a/test/api/write-generator/meta-regression.test.ts b/test/api/write-generator/meta-regression.test.ts new file mode 100644 index 00000000..c34de1b1 --- /dev/null +++ b/test/api/write-generator/meta-regression.test.ts @@ -0,0 +1,80 @@ +/** + * Regression test for the duplicate-meta-key bug (PR #138). + * + * When a profile pins meta.min=1 (making `meta` a required factory param, + * mirroring KBV ITA FOR/ERP/EAU patterns), the TypeScript profile writer + * previously emitted two `meta:` keys in createResource, causing TS1117 + * and silently dropping caller-supplied meta fields. + * + * This test asserts: + * 1. Generation succeeds + * 2. `meta:` appears exactly once in createResource (no TS1117, no key collision) + * 3. The spread pattern is used: `{ ...args.meta, profile: [...] }` + * 4. Profile.from() and apply() still validate canonicalUrl + */ + +import { describe, expect, it } from "bun:test"; +import * as Path from "node:path"; +import { APIBuilder } from "@root/api/builder"; +import { mkSilentLogger } from "@typeschema-test/utils"; + +const FIXTURES_PATH = Path.join(__dirname, "fixtures"); + +const localPackageConfig = { + package: { name: "example.meta.regression", version: "0.0.1" }, + path: FIXTURES_PATH, + dependencies: [{ name: "hl7.fhir.r4.core", version: "4.0.1" }], +}; + +const treeShakeConfig = { + "example.meta.regression": { + "http://example.org/fhir/StructureDefinition/OrgWithRequiredMeta": {}, + }, + "hl7.fhir.r4.core": { + "http://hl7.org/fhir/StructureDefinition/Organization": {}, + }, +}; + +describe("Regression: profile with meta.min=1 generates single meta key", async () => { + const result = await new APIBuilder({ logger: mkSilentLogger() }) + .localStructureDefinitions(localPackageConfig) + .typeSchema({ treeShake: treeShakeConfig }) + .typescript({ inMemoryOnly: true, generateProfile: true, withDebugComment: false }) + .generate(); + + const PROFILE_KEY = + "generated/types/example-meta-regression/profiles/Organization_OrgWithRequiredMeta.ts"; + + it("generates successfully", () => { + expect(result.success).toBeTrue(); + }); + + it("generates the OrgWithRequiredMeta profile", () => { + expect(result.filesGenerated[PROFILE_KEY]).toBeDefined(); + }); + + it("meta: key appears exactly once in createResource (no TS1117 duplicate key)", () => { + const content = result.filesGenerated[PROFILE_KEY]!; + // Count all `meta:` property assignments in the file. + // Expected: 2 — one in the ProfileRaw type declaration, one in createResource. + // With the bug: 3 (an extra duplicate in createResource). + const metaAssignments = content.match(/^\s+meta[?]?:/gm) ?? []; + expect(metaAssignments.length).toBe(2); + }); + + it("createResource merges args.meta via spread (no silent overwrite)", () => { + const content = result.filesGenerated[PROFILE_KEY]!; + expect(content).toContain("...args.meta"); + }); + + it("Profile.from() validates canonicalUrl (from/apply wiring intact)", () => { + const content = result.filesGenerated[PROFILE_KEY]!; + expect(content).toContain("canonicalUrl"); + expect(content).toContain("static from"); + expect(content).toContain("static apply"); + }); + + it("full profile snapshot", () => { + expect(result.filesGenerated[PROFILE_KEY]).toMatchSnapshot(); + }); +}); From d21cf223cbf78c86fca1f20a040fe4aa472fa4ae Mon Sep 17 00:00:00 2001 From: Malte Sussdorff Date: Mon, 20 Apr 2026 14:52:41 +0200 Subject: [PATCH 04/10] fix(codegen-ejm): address review findings iteration 1 Co-Authored-By: Claude Sonnet 4.6 --- .../write-generator/meta-regression.test.ts | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/test/api/write-generator/meta-regression.test.ts b/test/api/write-generator/meta-regression.test.ts index c34de1b1..1271db54 100644 --- a/test/api/write-generator/meta-regression.test.ts +++ b/test/api/write-generator/meta-regression.test.ts @@ -42,8 +42,7 @@ describe("Regression: profile with meta.min=1 generates single meta key", async .typescript({ inMemoryOnly: true, generateProfile: true, withDebugComment: false }) .generate(); - const PROFILE_KEY = - "generated/types/example-meta-regression/profiles/Organization_OrgWithRequiredMeta.ts"; + const PROFILE_KEY = "generated/types/example-meta-regression/profiles/Organization_OrgWithRequiredMeta.ts"; it("generates successfully", () => { expect(result.success).toBeTrue(); @@ -55,11 +54,13 @@ describe("Regression: profile with meta.min=1 generates single meta key", async it("meta: key appears exactly once in createResource (no TS1117 duplicate key)", () => { const content = result.filesGenerated[PROFILE_KEY]!; - // Count all `meta:` property assignments in the file. - // Expected: 2 — one in the ProfileRaw type declaration, one in createResource. - // With the bug: 3 (an extra duplicate in createResource). - const metaAssignments = content.match(/^\s+meta[?]?:/gm) ?? []; - expect(metaAssignments.length).toBe(2); + // Extract the createResource method body + const createResourceMatch = content.match(/static createResource[\s\S]*?\n {4}\}/); + expect(createResourceMatch).not.toBeNull(); + const createResourceBody = createResourceMatch![0]; + // meta: should appear exactly once inside createResource — not twice (which was the bug) + const metaInCreateResource = createResourceBody.match(/\bmeta:/g) ?? []; + expect(metaInCreateResource.length).toBe(1); }); it("createResource merges args.meta via spread (no silent overwrite)", () => { @@ -67,6 +68,15 @@ describe("Regression: profile with meta.min=1 generates single meta key", async expect(content).toContain("...args.meta"); }); + it("generated createResource has no duplicate property keys (no TS1117)", () => { + const content = result.filesGenerated[PROFILE_KEY]!; + // The bug produced `meta: args.meta,\n...\n meta: { profile: [...] }` — two meta: keys. + // TypeScript TS1117 fires on duplicate object literal keys. + // Verify the duplicate pattern is absent: no two consecutive meta: lines in createResource. + expect(content).not.toContain("meta: args.meta,"); + expect(content).toContain("...args.meta"); + }); + it("Profile.from() validates canonicalUrl (from/apply wiring intact)", () => { const content = result.filesGenerated[PROFILE_KEY]!; expect(content).toContain("canonicalUrl"); From 1ac689e75991ade54c3aff780c2b1e28725d85e2 Mon Sep 17 00:00:00 2001 From: Malte Sussdorff Date: Mon, 20 Apr 2026 15:08:32 +0200 Subject: [PATCH 05/10] chore: bump version to 2026.04.0 --- VERSION | 1 + 1 file changed, 1 insertion(+) create mode 100644 VERSION diff --git a/VERSION b/VERSION new file mode 100644 index 00000000..d7c163aa --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +2026.04.0 From 1f5a9001ba05ec467888e576970295336593b888 Mon Sep 17 00:00:00 2001 From: Malte Sussdorff Date: Tue, 21 Apr 2026 09:06:43 +0200 Subject: [PATCH 06/10] =?UTF-8?q?chore(cognovis):=20hibernate=20fork=20?= =?UTF-8?q?=E2=80=94=20upstream=20v0.0.13=20covers=20all=20fork=20code=20(?= =?UTF-8?q?codegen-zpq)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All production-relevant fork-only code is merged upstream and ships in @atomic-ehr/codegen@0.0.13: - duplicate-meta-key fix (#138) — landed upstream - KBV on-the-fly workarounds (ignorePackageIndex, preprocessPackage) — landed upstream - real-world regression test via examples/on-the-fly/kbv-r4/profile-patient.test.ts Polaris / mira-adapters consumes @atomic-ehr/codegen directly from npm (always did — the standing consumer-snapshot branch was speculative infrastructure). The fork is retained only as a doc anchor, PR staging area, and future baking ground for the scalar-slice-setters epic (codegen-4cw, blocked). Changes: - COGNOVIS.md: rewritten to document hibernate mode and reactivation criteria - test/api/write-generator/meta-regression.test.ts + fixture + snapshot: removed (redundant with upstream examples/on-the-fly/kbv-r4/profile-patient.test.ts; synthetic code-shape assertions were brittle against legitimate generator refactors) - cognovis/mira-adapters branch deleted, state preserved as tag archive/2026-04-mira-adapters for reactivation if needed --- COGNOVIS.md | 100 ++++++++++-------- .../meta-regression.test.ts.snap | 97 ----------------- .../fixtures/org-with-required-meta.json | 27 ----- .../write-generator/meta-regression.test.ts | 90 ---------------- 4 files changed, 53 insertions(+), 261 deletions(-) delete mode 100644 test/api/write-generator/__snapshots__/meta-regression.test.ts.snap delete mode 100644 test/api/write-generator/fixtures/org-with-required-meta.json delete mode 100644 test/api/write-generator/meta-regression.test.ts diff --git a/COGNOVIS.md b/COGNOVIS.md index a5b3be66..9cad5b48 100644 --- a/COGNOVIS.md +++ b/COGNOVIS.md @@ -1,59 +1,70 @@ # cognovis/codegen -This is a long-lived cognovis fork of [atomic-ehr/codegen](https://github.com/atomic-ehr/codegen) — the FHIR TypeScript/Python/C# code generator. +This is a cognovis fork of [atomic-ehr/codegen](https://github.com/atomic-ehr/codegen) — the FHIR TypeScript/Python/C# code generator. -## Why we fork +## Current status: HIBERNATE -We consume `@atomic-ehr/codegen` from two production contexts (the Polaris / mira-adapters external integration layer and, longer term, the mira API) and hit regressions on multiple consecutive 0.0.x releases. Rather than pin-and-hand-patch, we maintain this fork as our de-facto upstream until codegen reaches production-quality stability. Every non-trivial change either already exists upstream or has a justified reason to be fork-only. +**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. -## Scope +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 -This fork covers **FHIR TypeScript 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`. +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 (e.g. the duplicate-`meta`-key regression in 0.0.10+). -- 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 (e.g. `meta.min = 1`). +- 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` | Our working / integrating branch. All fork-specific features and infra (this file, `.beads/`) live here on top of `main`. | Rebase onto `main` when syncing with upstream. | -| `cognovis/` | Consumer snapshot branches (e.g. `cognovis/mira-adapters`) — rebase from `cognovis/next` and add consumer-specific scaffolding such as committed `dist/` for git-URL installs. | Rebase from `cognovis/next` before pinning consumers. | +| `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, currently at `373dc665` (atomic-ehr@0.0.12 + py-to-json-resource-type fix) -- `cognovis/next` — upstream + `fix/profile-duplicate-meta-key` + this infra -- `cognovis/mira-adapters` — consumer snapshot with committed `dist/` so `bun add github:cognovis/codegen#cognovis/mira-adapters` works -- `fix/profile-duplicate-meta-key` — in-flight upstream PR [atomic-ehr/codegen#138](https://github.com/atomic-ehr/codegen/pull/138) - -## Consumer integration - -Consumers pin via git URL to a stable consumer branch, e.g.: +- `main` — upstream mirror at `a7a8dcf9` (atomic-ehr@0.0.13) +- `cognovis/next` — upstream + fork infra (COGNOVIS.md, `.beads/`, CalVer bump) -```json -{ - "devDependencies": { - "@atomic-ehr/codegen": "github:cognovis/codegen#cognovis/mira-adapters" - } -} -``` - -The consumer branch includes pre-built `dist/` so bun/npm can use the package without running the fork's build step (bun doesn't install a git dep's devDependencies; committing `dist/` avoids a `prepare: tsup` script that would fail for lack of `tsup`). +### Archived -When a new cognovis snapshot is needed: -1. On `cognovis/next`, ensure everything builds and tests pass (`bun test test/api && bun run build`). -2. Rebase `cognovis/` onto `cognovis/next`. -3. Run `bun run build` and commit the updated `dist/`. -4. Force-push `cognovis/` (or move its tip forward). -5. Consumer `bun update @atomic-ehr/codegen`. +- `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 @@ -63,11 +74,11 @@ When a new cognovis snapshot is needed: 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. `dist/` on consumer branches, or opinionated API surface we're not ready to propose upstream), document it in the commit message: `fork-only: `. +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 (monthly suggestion) to avoid drift. +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 @@ -76,15 +87,11 @@ git checkout main && git fetch upstream && git reset --hard upstream/main && git # 2. Rebase cognovis/next onto updated main git checkout cognovis/next && git rebase main -# 3. Re-run tests + build to catch regressions early -bun test test/api/ - -# 4. Rebase each cognovis/ onto updated cognovis/next -git checkout cognovis/mira-adapters && git rebase cognovis/next -bun run build && git add dist/ && git commit --amend --no-edit -git push --force-with-lease +# 3. Re-run tests to catch regressions early +bun test -# 5. Notify consumers to `bun update` +# 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). @@ -95,9 +102,8 @@ Tracked in `.beads/` (Dolt-backed). See `bd ready` for currently-actionable work High-level roadmap: -- **Ship** upstream PR [#138](https://github.com/atomic-ehr/codegen/pull/138) (duplicate-meta fix) — merge or revise per review. -- **Contribute upstream**: regression test for `meta.min = 1` profiles (independent of #138, preventive). -- **Fork-first feature**: scalar slice setters + input-type flattening. Develop in our fork, let it bake against Polaris's KBV usage, then propose upstream once we're confident the API surface is right. +- **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 diff --git a/test/api/write-generator/__snapshots__/meta-regression.test.ts.snap b/test/api/write-generator/__snapshots__/meta-regression.test.ts.snap deleted file mode 100644 index 6a1622ea..00000000 --- a/test/api/write-generator/__snapshots__/meta-regression.test.ts.snap +++ /dev/null @@ -1,97 +0,0 @@ -// Bun Snapshot v1, https://goo.gl/fbAQLP - -exports[`Regression: profile with meta.min=1 generates single meta key full profile snapshot 1`] = ` -"// 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. - -import type { Meta } from "../../hl7-fhir-r4-core/Meta"; -import type { Organization } from "../../hl7-fhir-r4-core/Organization"; - -import { - ensureProfile, - validateRequired, - validateExcluded, - validateFixedValue, - validateSliceCardinality, - validateSliceFields, - validateEnum, - validateReference, - validateChoiceRequired, - validateMustSupport, -} from "../../profile-helpers"; - -export type OrgWithRequiredMetaProfileRaw = { - meta: Meta; -} - -// CanonicalURL: http://example.org/fhir/StructureDefinition/OrgWithRequiredMeta (pkg: example.meta.regression#0.0.1) -export class OrgWithRequiredMetaProfile { - static readonly canonicalUrl = "http://example.org/fhir/StructureDefinition/OrgWithRequiredMeta"; - - private resource: Organization; - - constructor (resource: Organization) { - this.resource = resource; - } - - static from (resource: Organization) : OrgWithRequiredMetaProfile { - if (!resource.meta?.profile?.includes(OrgWithRequiredMetaProfile.canonicalUrl)) { - throw new Error(\`OrgWithRequiredMetaProfile: meta.profile must include \${OrgWithRequiredMetaProfile.canonicalUrl}\`) - } - const profile = new OrgWithRequiredMetaProfile(resource); - const { errors } = profile.validate(); - if (errors.length > 0) throw new Error(errors.join("; ")) - return profile; - } - - static apply (resource: Organization) : OrgWithRequiredMetaProfile { - ensureProfile(resource, OrgWithRequiredMetaProfile.canonicalUrl); - return new OrgWithRequiredMetaProfile(resource); - } - - static createResource (args: OrgWithRequiredMetaProfileRaw) : Organization { - const resource: Organization = { - resourceType: "Organization", - meta: { ...args.meta, profile: [...(args.meta?.profile ?? []), OrgWithRequiredMetaProfile.canonicalUrl] }, - } - return resource; - } - - static create (args: OrgWithRequiredMetaProfileRaw) : OrgWithRequiredMetaProfile { - const resource = OrgWithRequiredMetaProfile.createResource(args); - return OrgWithRequiredMetaProfile.apply(resource); - } - - toResource () : Organization { - return this.resource; - } - - // Field accessors - getMeta () : Meta | undefined { - return this.resource.meta as Meta | undefined; - } - - setMeta (value: Meta) : this { - Object.assign(this.resource, { meta: value }); - return this; - } - - // Extensions - // Slices - // Validation - validate(): { errors: string[]; warnings: string[] } { - const profileName = "OrgWithRequiredMeta" - const res = this.resource - return { - errors: [ - ...validateRequired(res, profileName, "meta"), - ], - warnings: [], - } - } - -} - -" -`; diff --git a/test/api/write-generator/fixtures/org-with-required-meta.json b/test/api/write-generator/fixtures/org-with-required-meta.json deleted file mode 100644 index f3ba564e..00000000 --- a/test/api/write-generator/fixtures/org-with-required-meta.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "resourceType": "StructureDefinition", - "id": "org-with-required-meta", - "url": "http://example.org/fhir/StructureDefinition/OrgWithRequiredMeta", - "version": "0.0.1", - "name": "OrgWithRequiredMeta", - "title": "Organization with Required Meta (regression test for duplicate-meta-key bug)", - "status": "draft", - "date": "2024-01-01", - "publisher": "atomic-ehr/codegen test suite", - "description": "Minimal Organization profile pinning meta.min=1, mirroring KBV ITA FOR/ERP/EAU pattern. Used as a regression fixture for the duplicate-meta-key bug (PR #138).", - "fhirVersion": "4.0.1", - "kind": "resource", - "abstract": false, - "type": "Organization", - "baseDefinition": "http://hl7.org/fhir/StructureDefinition/Organization", - "derivation": "constraint", - "differential": { - "element": [ - { - "id": "Organization.meta", - "path": "Organization.meta", - "min": 1 - } - ] - } -} diff --git a/test/api/write-generator/meta-regression.test.ts b/test/api/write-generator/meta-regression.test.ts deleted file mode 100644 index 1271db54..00000000 --- a/test/api/write-generator/meta-regression.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Regression test for the duplicate-meta-key bug (PR #138). - * - * When a profile pins meta.min=1 (making `meta` a required factory param, - * mirroring KBV ITA FOR/ERP/EAU patterns), the TypeScript profile writer - * previously emitted two `meta:` keys in createResource, causing TS1117 - * and silently dropping caller-supplied meta fields. - * - * This test asserts: - * 1. Generation succeeds - * 2. `meta:` appears exactly once in createResource (no TS1117, no key collision) - * 3. The spread pattern is used: `{ ...args.meta, profile: [...] }` - * 4. Profile.from() and apply() still validate canonicalUrl - */ - -import { describe, expect, it } from "bun:test"; -import * as Path from "node:path"; -import { APIBuilder } from "@root/api/builder"; -import { mkSilentLogger } from "@typeschema-test/utils"; - -const FIXTURES_PATH = Path.join(__dirname, "fixtures"); - -const localPackageConfig = { - package: { name: "example.meta.regression", version: "0.0.1" }, - path: FIXTURES_PATH, - dependencies: [{ name: "hl7.fhir.r4.core", version: "4.0.1" }], -}; - -const treeShakeConfig = { - "example.meta.regression": { - "http://example.org/fhir/StructureDefinition/OrgWithRequiredMeta": {}, - }, - "hl7.fhir.r4.core": { - "http://hl7.org/fhir/StructureDefinition/Organization": {}, - }, -}; - -describe("Regression: profile with meta.min=1 generates single meta key", async () => { - const result = await new APIBuilder({ logger: mkSilentLogger() }) - .localStructureDefinitions(localPackageConfig) - .typeSchema({ treeShake: treeShakeConfig }) - .typescript({ inMemoryOnly: true, generateProfile: true, withDebugComment: false }) - .generate(); - - const PROFILE_KEY = "generated/types/example-meta-regression/profiles/Organization_OrgWithRequiredMeta.ts"; - - it("generates successfully", () => { - expect(result.success).toBeTrue(); - }); - - it("generates the OrgWithRequiredMeta profile", () => { - expect(result.filesGenerated[PROFILE_KEY]).toBeDefined(); - }); - - it("meta: key appears exactly once in createResource (no TS1117 duplicate key)", () => { - const content = result.filesGenerated[PROFILE_KEY]!; - // Extract the createResource method body - const createResourceMatch = content.match(/static createResource[\s\S]*?\n {4}\}/); - expect(createResourceMatch).not.toBeNull(); - const createResourceBody = createResourceMatch![0]; - // meta: should appear exactly once inside createResource — not twice (which was the bug) - const metaInCreateResource = createResourceBody.match(/\bmeta:/g) ?? []; - expect(metaInCreateResource.length).toBe(1); - }); - - it("createResource merges args.meta via spread (no silent overwrite)", () => { - const content = result.filesGenerated[PROFILE_KEY]!; - expect(content).toContain("...args.meta"); - }); - - it("generated createResource has no duplicate property keys (no TS1117)", () => { - const content = result.filesGenerated[PROFILE_KEY]!; - // The bug produced `meta: args.meta,\n...\n meta: { profile: [...] }` — two meta: keys. - // TypeScript TS1117 fires on duplicate object literal keys. - // Verify the duplicate pattern is absent: no two consecutive meta: lines in createResource. - expect(content).not.toContain("meta: args.meta,"); - expect(content).toContain("...args.meta"); - }); - - it("Profile.from() validates canonicalUrl (from/apply wiring intact)", () => { - const content = result.filesGenerated[PROFILE_KEY]!; - expect(content).toContain("canonicalUrl"); - expect(content).toContain("static from"); - expect(content).toContain("static apply"); - }); - - it("full profile snapshot", () => { - expect(result.filesGenerated[PROFILE_KEY]).toMatchSnapshot(); - }); -}); From 249f58846da4fb4012a65c3c8d0731e7e490a233 Mon Sep 17 00:00:00 2001 From: Malte Sussdorff Date: Sun, 26 Apr 2026 10:02:30 +0200 Subject: [PATCH 07/10] =?UTF-8?q?test(codegen-vrq):=20red=20=E2=80=94=20ve?= =?UTF-8?q?rsioned=20canonical=20cross-package=20resolution=20fails=20befo?= =?UTF-8?q?re=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression test for de.basisprofil.r4@1.5.4 + kbv.basis@1.8.0 combination where kbv.basis profiles reference de.basisprofil.r4 profiles via versioned canonicals (e.g. |1.5.4 suffix). Before the fix, resolveFs returns undefined for these cross-package base types because de.basisprofil.r4 has 0 indexed resources in the canonical manager due to a broken .index.json. Covers: - resolveFs with clean URL (from cross-package context) - resolveFs after ensureSpecializationCanonicalUrl strips |version suffix - All vitalsign profiles with versioned de.basisprofil.r4 references - Base type chain resolution for KBV Pflegegrad and blood pressure profiles --- .../typeschema/versioned-canonical.test.ts | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 test/unit/typeschema/versioned-canonical.test.ts diff --git a/test/unit/typeschema/versioned-canonical.test.ts b/test/unit/typeschema/versioned-canonical.test.ts new file mode 100644 index 00000000..8c1e481f --- /dev/null +++ b/test/unit/typeschema/versioned-canonical.test.ts @@ -0,0 +1,105 @@ +/** + * 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 computes the canonical manager's node_modules path and + * passes 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 { describe, expect, it } from "bun:test"; +import type { CanonicalUrl } from "@root/typeschema/types"; +import { generateTypeSchemas } from "@root/typeschema/index"; +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)", () => { + describe("resolveFs — cross-package base type lookup", () => { + it("finds de.basisprofil.r4 profile from kbv.basis context using clean URL", async () => { + const register = await registerFromPackageMetas([kbvPkg, basisprofil], {}); + 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", async () => { + const register = await registerFromPackageMetas([kbvPkg, basisprofil], {}); + 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", async () => { + const register = await registerFromPackageMetas([kbvPkg, basisprofil], {}); + // 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)", async () => { + // 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 register = await registerFromPackageMetas([kbvPkg, basisprofil], {}); + + 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", async () => { + const register = await registerFromPackageMetas([kbvPkg, basisprofil], {}); + + // 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(); + }); + }); +}); From 6793d0b3f8472e79cba0f5f3bc275c0b44176ab7 Mon Sep 17 00:00:00 2001 From: Malte Sussdorff Date: Sun, 26 Apr 2026 10:02:44 +0200 Subject: [PATCH 08/10] =?UTF-8?q?fix(codegen-vrq):=20green=20=E2=80=94=20r?= =?UTF-8?q?esolve=20cross-package=20versioned=20canonicals=20via=20node=5F?= =?UTF-8?q?modules=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 in baseDefinition). The canonical manager's parseIndex function treats any .index.json entry with id=null as fatal and rejects the entire index, leaving de.basisprofil.r4 with 0 indexed resources. Consequence: resolveFs returns undefined for all de.basisprofil.r4 profiles, causing 'Base resource not found' errors when transforming KBV profiles. Fix strategy: (a) ensureSpecializationCanonicalUrl already strips |version from canonical URLs before lookup. To address the root cause (0 resources for affected packages), registerFromPackageMetas now computes the canonical manager's node_modules path (using the same SHA-256 cache key algorithm) and passes it as nodeModulesPath to mkPackageAwareResolver. When the canonical manager returns 0 resources for a focused package, mkPackageAwareResolver falls back to scanning the package directory directly — equivalent to the canonical manager's own scanDirectoryForResources, but without the strict id validation that rejects the entire index on any null entry. Changes: - src/typeschema/register.ts: add computeCanonicalManagerCacheKey, scanNodeModulesPackage helpers; extend RegisterConfig with nodeModulesPath; update mkPackageAwareResolver and registerFromPackageMetas to use the fallback scan - src/utils/log.ts: add '#canonicalManagerFallback' to CodegenTag --- src/typeschema/register.ts | 118 +++++++++++++++++++++++++++++++++++-- src/utils/log.ts | 3 +- 2 files changed, 115 insertions(+), 6 deletions(-) diff --git a/src/typeschema/register.ts b/src/typeschema/register.ts index 141dfcca..a6a5ee4f 100644 --- a/src/typeschema/register.ts +++ b/src/typeschema/register.ts @@ -1,5 +1,9 @@ import { CanonicalManager } from "@atomic-ehr/fhir-canonical-manager"; import * as fhirschema from "@atomic-ehr/fhirschema"; +import { createHash } from "node:crypto"; +import { readdir, readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; import { type FHIRSchema, type FHIRSchemaElement, @@ -87,13 +91,28 @@ 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 })) { + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let resources: any[] = await manager.search({ package: pkg }); + + // 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 +124,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 +190,23 @@ 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. + */ + nodeModulesPath?: string; }; export const registerFromManager = async ( manager: ReturnType, - { logger, focusedPackages }: RegisterConfig, + { logger, focusedPackages, nodeModulesPath }: RegisterConfig, ): Promise => { const packages = focusedPackages ?? (await manager.packages()); 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 +368,74 @@ export const registerFromManager = async ( }; }; +/** + * Compute the same cache key as @atomic-ehr/fhir-canonical-manager uses internally. + * The canonical manager uses SHA-256 of the sorted, JSON-stringified package spec list. + */ +const computeCanonicalManagerCacheKey = (packageNames: string[]): string => { + const content = JSON.stringify([...packageNames].sort()); + return createHash("sha256").update(content).digest("hex"); +}; + +/** + * 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 { + 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; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (!(isStructureDefinition(resource as any) || isValueSet(resource as any) || isCodeSystem(resource as any))) + continue; + resources.push(resource as unknown as FocusedResource); + } catch { + // Skip unreadable/malformed files + } + } + + 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 +444,20 @@ 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(); + + // Compute the canonical manager's node_modules path so we can fall back to direct + // directory scanning when the manager fails to index a focused package. + const cacheKey = computeCanonicalManagerCacheKey(packageNames); + const nodeModulesPath = join(process.cwd(), CANONICAL_MANAGER_WORKING_DIR, cacheKey, "node", "node_modules"); + return await registerFromManager(manager, { ...conf, focusedPackages: packageMetas, + nodeModulesPath, }); }; diff --git a/src/utils/log.ts b/src/utils/log.ts index 137240cf..d7a24a49 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; From 1619d6a96caa55ab5e78d021edb40ab806b20649 Mon Sep 17 00:00:00 2001 From: Malte Sussdorff Date: Sun, 26 Apr 2026 10:13:05 +0200 Subject: [PATCH 09/10] fix(codegen-vrq): address review findings iteration 1 - Remove dead eslint-disable comments (project uses Biome, not ESLint) - Fix import organization: node builtins before third-party per Biome organizer - Use FocusedResource[] with explicit cast instead of any[] for manager.search() result - Fix APIBuilder gap: registerFromManager now auto-computes nodeModulesPath from focusedPackages when not explicitly provided, so APIBuilder callers get the fallback without going through registerFromPackageMetas - Extract computeNodeModulesPath helper used by both code paths - Add error logging to empty catch blocks in scanNodeModulesPackage - Add coupling note in registerFromManager comment (SHA-256 algorithm tracked at @atomic-ehr/fhir-canonical-manager@0.0.23) - Remove unused generateTypeSchemas import from test file - Hoist register construction to beforeAll in test (one load for all 5 tests) --- src/typeschema/register.ts | 70 +++++++++++++------ .../typeschema/versioned-canonical.test.ts | 35 +++++----- 2 files changed, 67 insertions(+), 38 deletions(-) diff --git a/src/typeschema/register.ts b/src/typeschema/register.ts index a6a5ee4f..8974d5db 100644 --- a/src/typeschema/register.ts +++ b/src/typeschema/register.ts @@ -1,9 +1,9 @@ -import { CanonicalManager } from "@atomic-ehr/fhir-canonical-manager"; -import * as fhirschema from "@atomic-ehr/fhirschema"; import { createHash } from "node:crypto"; -import { readdir, readFile } from "node:fs/promises"; 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 { type FHIRSchema, type FHIRSchemaElement, @@ -99,8 +99,7 @@ const mkPackageAwareResolver = async ( const index = mkEmptyPkgIndex(pkg); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let resources: any[] = 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 @@ -194,7 +193,9 @@ export type RegisterConfig = { * 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. + * 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; }; @@ -204,6 +205,20 @@ export const registerFromManager = async ( { 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, nodeModulesPath); @@ -369,14 +384,27 @@ export const registerFromManager = async ( }; /** - * Compute the same cache key as @atomic-ehr/fhir-canonical-manager uses internally. - * The canonical manager uses SHA-256 of the sorted, JSON-stringified package spec list. + * 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). @@ -403,7 +431,11 @@ const scanNodeModulesPackage = async ( try { // readdir without withFileTypes returns string[] — avoids Bun's Dirent type mismatch fileNames = await readdir(pkgDir); - } catch { + } catch (err) { + logger?.dryWarn( + "#canonicalManagerFallback", + `Failed to read directory for ${packageMetaToFhir(pkg)} at ${pkgDir}: ${err}`, + ); return []; } @@ -414,12 +446,13 @@ const scanNodeModulesPackage = async ( const content = await readFile(join(pkgDir, name), "utf-8"); const resource = JSON.parse(content) as Record; if (!resource.resourceType || !resource.url) continue; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if (!(isStructureDefinition(resource as any) || isValueSet(resource as any) || isCodeSystem(resource as any))) - continue; + if (!(isStructureDefinition(resource) || isValueSet(resource) || isCodeSystem(resource))) continue; resources.push(resource as unknown as FocusedResource); - } catch { - // Skip unreadable/malformed files + } catch (err) { + logger?.dryWarn( + "#canonicalManagerFallback", + `Skipping ${name} in ${packageMetaToFhir(pkg)}: ${err}`, + ); } } @@ -449,15 +482,12 @@ export const registerFromPackageMetas = async ( }); await manager.init(); - // Compute the canonical manager's node_modules path so we can fall back to direct - // directory scanning when the manager fails to index a focused package. - const cacheKey = computeCanonicalManagerCacheKey(packageNames); - const nodeModulesPath = join(process.cwd(), CANONICAL_MANAGER_WORKING_DIR, cacheKey, "node", "node_modules"); - return await registerFromManager(manager, { ...conf, focusedPackages: packageMetas, - nodeModulesPath, + // 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/test/unit/typeschema/versioned-canonical.test.ts b/test/unit/typeschema/versioned-canonical.test.ts index 8c1e481f..63e4fd2a 100644 --- a/test/unit/typeschema/versioned-canonical.test.ts +++ b/test/unit/typeschema/versioned-canonical.test.ts @@ -9,33 +9,37 @@ * not found" errors when transforming KBV profiles that inherit from de.basisprofil.r4 * profiles via versioned canonical references. * - * Fix: registerFromPackageMetas computes the canonical manager's node_modules path and - * passes 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. + * 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 { describe, expect, it } from "bun:test"; +import { beforeAll, describe, expect, it } from "bun:test"; import type { CanonicalUrl } from "@root/typeschema/types"; -import { generateTypeSchemas } from "@root/typeschema/index"; +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", async () => { - const register = await registerFromPackageMetas([kbvPkg, basisprofil], {}); + 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", async () => { - const register = await registerFromPackageMetas([kbvPkg, basisprofil], {}); + 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; @@ -50,8 +54,7 @@ describe("Versioned canonical resolution (codegen-vrq)", () => { expect(resolved?.url).toBe(clean); }); - it("resolves all vitalsign profiles that kbv.basis pins to de.basisprofil.r4@1.5.4", async () => { - const register = await registerFromPackageMetas([kbvPkg, basisprofil], {}); + 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", @@ -68,12 +71,10 @@ describe("Versioned canonical resolution (codegen-vrq)", () => { }); describe("transformFhirSchema — base type resolution for KBV profiles", () => { - it("resolves base type for KBV_PR_Base_Observation_Care_Level (versioned pflegegrad reference)", async () => { + 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 register = await registerFromPackageMetas([kbvPkg, basisprofil], {}); - 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(); @@ -89,9 +90,7 @@ describe("Versioned canonical resolution (codegen-vrq)", () => { ).toBeDefined(); }); - it("resolves base type chain for KBV vitalsign profiles with versioned de.basisprofil.r4 references", async () => { - const register = await registerFromPackageMetas([kbvPkg, basisprofil], {}); - + 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); From a4e76ba7dd2aa85f43b93468634572944dd386dd Mon Sep 17 00:00:00 2001 From: Malte Sussdorff Date: Sun, 26 Apr 2026 10:18:40 +0200 Subject: [PATCH 10/10] chore(codegen-vrq): update changelog --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..59ee0d68 --- /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