From c1c6b60cbb7ebaf36169e929ae8d05c0b27350cc Mon Sep 17 00:00:00 2001 From: Gino Canessa Date: Wed, 6 May 2026 12:00:52 -0500 Subject: [PATCH 01/16] ci(docs): bump v4-era action pins off Node.js 20 Bump actions/checkout to v6, actions/setup-dotnet to v5, actions/configure-pages to v6, actions/upload-pages-artifact to v5, and actions/deploy-pages to v5 to clear the Node.js 20 deprecation annotation before GitHub forces v4-era actions onto Node.js 24 on 2026-06-02 and removes Node.js 20 from runners on 2026-09-16. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/docs.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 8e9074f1b..eecbf7017 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -33,8 +33,8 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-dotnet@v5 with: dotnet-version: ${{ env.DOTNET_VERSION }} @@ -55,11 +55,11 @@ jobs: - name: Configure Pages if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 - name: Upload Pages artifact if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: docfx/_site @@ -72,4 +72,4 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 From 73a27d99171a73458feed283a350c8e2a79edf08 Mon Sep 17 00:00:00 2001 From: Gino Canessa Date: Wed, 6 May 2026 12:01:23 -0500 Subject: [PATCH 02/16] ci(build-and-test): bump v4-era action pins off Node.js 20 Bump actions/cache and actions/cache/save to v5 and actions/checkout to v6 in the dormant build-and-test workflow so the Node.js 20 deprecation warning will not resurface when the trigger is restored. The v5 major of actions/cache keeps the same input surface (path, key, enableCrossOsArchive, lookup-only) so no other changes are needed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build-and-test.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 97f1a2bd1..dee30f556 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -19,7 +19,7 @@ jobs: # Try to use a cached set of packages for Unit Testing - name: Check for a FHIR package cache id: cache-fhir-packages-test - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.fhir key: cache-unit-test-fhir-packages-20250219 @@ -47,7 +47,7 @@ jobs: # If there is no cache, save the downloaded packages - name: Cache FHIR packages - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 if: ${{ steps.cache-fhir-packages-test.outputs.cache-hit != 'true' }} continue-on-error: true with: @@ -65,12 +65,12 @@ jobs: os: [ubuntu-latest, windows-latest, macOS-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # Try to use a cached set of packages for Unit Testing - name: Restore FHIR package cache id: cache-fhir-packages - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.fhir key: cache-unit-test-fhir-packages-20250219 From dc79f4be9b62b5d8c3e565702da049921f0941e6 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 14:38:18 -0500 Subject: [PATCH 03/16] fix(config): make default FHIR cache resolution non-fatal ConfigRoot.Parse unconditionally resolved the default FHIR cache path via FileSystemUtils.FindRelativeDir("~/.fhir/packages") with throwIfNotFound=true, which threw on any machine without a populated cache (notably the Docs GitHub Pages runner). Commands like 'docs cli' that never read the cache aborted before reaching their handler. Add a protected virtual GetUserProfileDirectory() seam and rewrite the 'FhirCache' case so: - the default value is computed directly from the user-profile path without requiring it to exist (DiskCacheClient already auto-creates the directory on first use); - explicit relative values use throwIfNotFound:false, fall back to the supplied value, and emit a single Console.WriteLine warning so a typo'd path is at least surfaced; - explicit rooted values continue to flow through unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Configuration/ConfigRoot.cs | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/Fhir.CodeGen.Lib/Configuration/ConfigRoot.cs b/src/Fhir.CodeGen.Lib/Configuration/ConfigRoot.cs index f658b53db..581ee16d5 100644 --- a/src/Fhir.CodeGen.Lib/Configuration/ConfigRoot.cs +++ b/src/Fhir.CodeGen.Lib/Configuration/ConfigRoot.cs @@ -634,6 +634,13 @@ internal HashSet GetOptHash( return values; } + /// Returns the current user's profile directory. + /// Virtual so tests can inject a temporary directory. + protected virtual string GetUserProfileDirectory() + { + return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + } + /// Parses the given parse result. /// The parse result. public virtual void Parse(System.CommandLine.Parsing.ParseResult parseResult) @@ -653,11 +660,24 @@ public virtual void Parse(System.CommandLine.Parsing.ParseResult parseResult) if (string.IsNullOrEmpty(dir)) { - dir = FileSystemUtils.FindRelativeDir(string.Empty, "~/.fhir/packages"); + // Default to the user's FHIR cache. Do NOT require it to exist; + // DiskCacheClient creates it on first use, and commands like + // `docs cli` never read it at all. + dir = Path.Combine(GetUserProfileDirectory(), ".fhir", "packages"); } else if (!Path.IsPathRooted(dir)) { - dir = FileSystemUtils.FindRelativeDir(string.Empty, dir!); + string suppliedDir = dir; + string foundDir = FileSystemUtils.FindRelativeDir(string.Empty, suppliedDir, throwIfNotFound: false); + if (string.IsNullOrEmpty(foundDir)) + { + Console.WriteLine($"Warning: --fhir-cache value '{suppliedDir}' did not resolve; using as-is."); + dir = suppliedDir; + } + else + { + dir = foundDir; + } } FhirCacheDirectory = string.IsNullOrEmpty(dir) ? null : dir; From d51a47e1ac7dcb1e498bd69e886141de74729b77 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 14:41:43 -0500 Subject: [PATCH 04/16] test(config): add regression tests for FhirCache resolution Add four xUnit/Shouldly tests in ConfigTests.cs that pin down the behavior introduced by the previous commit: - Parse_WithMissingDefaultFhirCache_DoesNotThrow_AndUsesUserProfileDefault - Parse_WithExplicitMissingRelativeFhirCache_DoesNotThrow_AndFallsBackAndWarns - Parse_WithExplicitRootedFhirCache_PassesValueThrough - Parse_WithExplicitRelativeFhirCacheThatResolves_DoesNotWarn A private TestConfigRoot subclass overrides GetUserProfileDirectory() so the default-resolution test can use a temp directory without mutating process-global HOME/USERPROFILE env vars (which is unsafe under xUnit parallelism and unreliable on Windows for SpecialFolder lookups). Console.Out is captured via a swap-and-restore helper to verify the warning surfaces only on the fallback branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Fhir.CodeGen.Lib.Tests/ConfigTests.cs | 163 ++++++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/src/Fhir.CodeGen.Lib.Tests/ConfigTests.cs b/src/Fhir.CodeGen.Lib.Tests/ConfigTests.cs index ecc82e279..c25b16784 100644 --- a/src/Fhir.CodeGen.Lib.Tests/ConfigTests.cs +++ b/src/Fhir.CodeGen.Lib.Tests/ConfigTests.cs @@ -188,4 +188,167 @@ public void TestParseCliStringArray() config.AdditionalFhirRegistryUrls.Any(v => v == "http://a.co/").ShouldBe(true); config.AdditionalFhirRegistryUrls.Any(v => v == "http://b.co").ShouldBe(true); } + + /// + /// Builds a parser with all options registered + /// as global options, mirroring the pattern used by the other tests in + /// this file. + /// + private static Parser BuildRootParser() + { + ConfigurationOption[] configurationOptions = (new ConfigRoot()).GetOptions(); + + RootCommand rootCommand = new("Root command for unit testing."); + foreach (ConfigurationOption co in configurationOptions) + { + rootCommand.AddGlobalOption(co.CliOption); + } + + return new CommandLineBuilder(rootCommand).UseDefaults().Build(); + } + + /// + /// Captures everything written to during + /// and returns it. + /// + private static string CaptureConsoleOut(Action action) + { + TextWriter original = Console.Out; + StringWriter capture = new(); + try + { + Console.SetOut(capture); + action(); + } + finally + { + Console.SetOut(original); + } + + return capture.ToString(); + } + + [Fact] + public void Parse_WithMissingDefaultFhirCache_DoesNotThrow_AndUsesUserProfileDefault() + { + string tempProfile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempProfile); + + try + { + Parser parser = BuildRootParser(); + ParseResult pr = parser.Parse([]); + + TestConfigRoot config = new() { ProfileDir = tempProfile }; + + string output = CaptureConsoleOut(() => Should.NotThrow(() => config.Parse(pr))); + + config.FhirCacheDirectory.ShouldBe(Path.Combine(tempProfile, ".fhir", "packages")); + output.ShouldNotContain("Warning: --fhir-cache"); + } + finally + { + Directory.Delete(tempProfile, recursive: true); + } + } + + [Fact] + public void Parse_WithExplicitMissingRelativeFhirCache_DoesNotThrow_AndFallsBackAndWarns() + { + string tempProfile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempProfile); + + const string missing = "definitely-not-a-real-dir-xyz"; + + try + { + Parser parser = BuildRootParser(); + ParseResult pr = parser.Parse(["--fhir-cache", missing]); + + TestConfigRoot config = new() { ProfileDir = tempProfile }; + + string output = CaptureConsoleOut(() => Should.NotThrow(() => config.Parse(pr))); + + config.FhirCacheDirectory.ShouldNotBeNull(); + config.FhirCacheDirectory!.ShouldEndWith(missing); + output.ShouldContain($"Warning: --fhir-cache value '{missing}' did not resolve; using as-is."); + } + finally + { + Directory.Delete(tempProfile, recursive: true); + } + } + + [Fact] + public void Parse_WithExplicitRootedFhirCache_PassesValueThrough() + { + string tempProfile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempProfile); + + // Rooted path that does NOT exist on disk; we never create it. + string rooted = Path.Combine(Path.GetTempPath(), "fhir-codegen-tests-no-such-dir-" + Path.GetRandomFileName()); + + try + { + Parser parser = BuildRootParser(); + ParseResult pr = parser.Parse(["--fhir-cache", rooted]); + + TestConfigRoot config = new() { ProfileDir = tempProfile }; + + string output = CaptureConsoleOut(() => Should.NotThrow(() => config.Parse(pr))); + + config.FhirCacheDirectory.ShouldBe(rooted); + output.ShouldNotContain("Warning: --fhir-cache"); + } + finally + { + Directory.Delete(tempProfile, recursive: true); + } + } + + [Fact] + public void Parse_WithExplicitRelativeFhirCacheThatResolves_DoesNotWarn() + { + string tempProfile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempProfile); + + // Create a relative directory under AppContext.BaseDirectory so that + // FindRelativeDir(string.Empty, leafName) finds it on the first try + // (testDir = AppContext.BaseDirectory + leafName, exists → returned). + string baseDir = Path.GetDirectoryName(AppContext.BaseDirectory) ?? AppContext.BaseDirectory; + string leafName = "fhir-codegen-tests-rel-" + Path.GetRandomFileName(); + string createdDir = Path.Combine(baseDir, leafName); + Directory.CreateDirectory(createdDir); + + try + { + Parser parser = BuildRootParser(); + ParseResult pr = parser.Parse(["--fhir-cache", leafName]); + + TestConfigRoot config = new() { ProfileDir = tempProfile }; + + string output = CaptureConsoleOut(() => Should.NotThrow(() => config.Parse(pr))); + + config.FhirCacheDirectory.ShouldNotBeNull(); + config.FhirCacheDirectory!.ShouldBe(Path.GetFullPath(createdDir)); + output.ShouldNotContain("Warning: --fhir-cache"); + } + finally + { + Directory.Delete(createdDir, recursive: true); + Directory.Delete(tempProfile, recursive: true); + } + } + + /// + /// Test-only subclass that overrides the + /// user-profile lookup so the cache-resolution tests can use a temp + /// directory without mutating process-global env vars. + /// + private sealed class TestConfigRoot : ConfigRoot + { + public string ProfileDir { get; init; } = string.Empty; + + protected override string GetUserProfileDirectory() => ProfileDir; + } } From 3bbb5d36d49a66a43bd5ecf9f60f8f2202e600b8 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 14:44:22 -0500 Subject: [PATCH 05/16] fix(common): expand ~-rooted paths cleanly in FindRelativeDir Previously, ~-rooted inputs to FileSystemUtils.FindRelativeDir 'worked' only by accident via the walk-up loop: the leading ~/ was stripped to 'dirName' and currentDir was set under the user profile, then the loop fell back to walking the filesystem when the joined testDir did not exist. On a fresh machine the walk-up reached the filesystem root and threw with a misleading message ('Could not find directory .fhir/packages!' instead of '~/.fhir/packages'). Short-circuit ~-rooted inputs before the walk-up: expand against the user-profile directory, return Path.GetFullPath when the directory exists, otherwise honor throwIfNotFound and produce an error message that retains the ~/ prefix. Behavior for non-~ inputs is unchanged. Add FileSystemUtilsTests.cs covering the resolved, throwIfNotFound:false empty-return, and throwIfNotFound:true throw paths. The throw test also pins the message-format fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Utils/FileSystemUtils.cs | 38 ++++++++---- .../FileSystemUtilsTests.cs | 58 +++++++++++++++++++ 2 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 src/Fhir.CodeGen.Lib.Tests/FileSystemUtilsTests.cs diff --git a/src/Fhir.CodeGen.Common/Utils/FileSystemUtils.cs b/src/Fhir.CodeGen.Common/Utils/FileSystemUtils.cs index aa23f1a20..7fc83129d 100644 --- a/src/Fhir.CodeGen.Common/Utils/FileSystemUtils.cs +++ b/src/Fhir.CodeGen.Common/Utils/FileSystemUtils.cs @@ -40,6 +40,34 @@ public static string FindRelativeDir( return dirName; } + if (dirName.StartsWith('~')) + { + string profile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrEmpty(profile)) + { + profile = Path.GetDirectoryName(AppContext.BaseDirectory) ?? "."; + } + + string relative = dirName.Length >= 2 && (dirName[1] == '/' || dirName[1] == '\\') + ? dirName.Substring(2) + : dirName.Substring(1); + + string expanded = string.IsNullOrEmpty(relative) + ? profile + : Path.GetFullPath(Path.Combine(profile, relative)); + + if (Directory.Exists(expanded)) + { + return expanded; + } + + if (throwIfNotFound) + { + throw new DirectoryNotFoundException($"Could not find directory {dirName}!"); + } + + return string.Empty; + } string currentDir = startDir switch { @@ -52,16 +80,6 @@ public static string FindRelativeDir( _ => startDir, }; - if (dirName.StartsWith('~')) - { - currentDir = Path.GetDirectoryName(Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? Path.GetDirectoryName(AppContext.BaseDirectory) ?? ".", - dirName[2..])) - ?? currentDir; - - dirName = dirName[2..]; - } - if (currentDir.StartsWith('~')) { currentDir = Path.GetDirectoryName(Path.Combine( diff --git a/src/Fhir.CodeGen.Lib.Tests/FileSystemUtilsTests.cs b/src/Fhir.CodeGen.Lib.Tests/FileSystemUtilsTests.cs new file mode 100644 index 000000000..936bb11f9 --- /dev/null +++ b/src/Fhir.CodeGen.Lib.Tests/FileSystemUtilsTests.cs @@ -0,0 +1,58 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// + +using Shouldly; +using Fhir.CodeGen.Common.Utils; + +namespace Fhir.CodeGen.Lib.Tests; + +public class FileSystemUtilsTests +{ + private static string UserProfile => + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + [Fact] + public void FindRelativeDir_TildePath_ResolvesUnderUserProfile() + { + string leaf = "fhir-codegen-tests-tilde-" + Path.GetRandomFileName(); + string created = Path.Combine(UserProfile, leaf); + Directory.CreateDirectory(created); + + try + { + string result = FileSystemUtils.FindRelativeDir(string.Empty, "~/" + leaf); + + result.ShouldBe(Path.GetFullPath(Path.Combine(UserProfile, leaf))); + } + finally + { + Directory.Delete(created, recursive: true); + } + } + + [Fact] + public void FindRelativeDir_MissingTildePath_ReturnsEmpty_WhenThrowIfNotFoundFalse() + { + string missing = "~/fhir-codegen-tests-missing-" + Path.GetRandomFileName(); + + string result = FileSystemUtils.FindRelativeDir(string.Empty, missing, throwIfNotFound: false); + + result.ShouldBeEmpty(); + } + + [Fact] + public void FindRelativeDir_MissingTildePath_Throws_WhenThrowIfNotFoundTrue() + { + string missing = "~/fhir-codegen-tests-missing-" + Path.GetRandomFileName(); + + DirectoryNotFoundException ex = Should.Throw( + () => FileSystemUtils.FindRelativeDir(string.Empty, missing)); + + // The error message should retain the ~/ prefix instead of dropping it + // (the previous implementation produced "Could not find directory !"). + ex.Message.ShouldContain("~/"); + ex.Message.ShouldContain(missing); + } +} From 51857a0f3573f91f553a35e7d9ea96db76589c31 Mon Sep 17 00:00:00 2001 From: Gino Canessa Date: Wed, 27 May 2026 14:36:58 -0500 Subject: [PATCH 06/16] Prep for work. --- .../Outcomes/StructureOutcomeGenerator.cs | 1 - src/Fhir.CodeGen.Comparison/XVer/XVerProcessor.cs | 8 ++++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Fhir.CodeGen.Comparison/Outcomes/StructureOutcomeGenerator.cs b/src/Fhir.CodeGen.Comparison/Outcomes/StructureOutcomeGenerator.cs index befaeeb29..19b63152b 100644 --- a/src/Fhir.CodeGen.Comparison/Outcomes/StructureOutcomeGenerator.cs +++ b/src/Fhir.CodeGen.Comparison/Outcomes/StructureOutcomeGenerator.cs @@ -61,7 +61,6 @@ public StructureOutcomeGenerator( _edOutcomeTargetCache = new(); } - public void CreateOutcomesForStructures( int? maxStepSize = null, HashSet<(FhirReleases.FhirSequenceCodes s, FhirReleases.FhirSequenceCodes t)>? specificPairs = null) diff --git a/src/Fhir.CodeGen.Comparison/XVer/XVerProcessor.cs b/src/Fhir.CodeGen.Comparison/XVer/XVerProcessor.cs index c90686256..134f83f2c 100644 --- a/src/Fhir.CodeGen.Comparison/XVer/XVerProcessor.cs +++ b/src/Fhir.CodeGen.Comparison/XVer/XVerProcessor.cs @@ -261,13 +261,13 @@ public void ProcessCommand(string? command) HashSet<(FhirReleases.FhirSequenceCodes s, FhirReleases.FhirSequenceCodes t)>? specificPairs = null; specificPairs = [ - (FhirReleases.FhirSequenceCodes.DSTU2, FhirReleases.FhirSequenceCodes.R4), + //(FhirReleases.FhirSequenceCodes.DSTU2, FhirReleases.FhirSequenceCodes.R4), //(FhirReleases.FhirSequenceCodes.DSTU2, FhirReleases.FhirSequenceCodes.STU3), //(FhirReleases.FhirSequenceCodes.R4, FhirReleases.FhirSequenceCodes.R4B), //(FhirReleases.FhirSequenceCodes.R4B, FhirReleases.FhirSequenceCodes.R4), //(FhirReleases.FhirSequenceCodes.R5, FhirReleases.FhirSequenceCodes.R4B), //(FhirReleases.FhirSequenceCodes.R4, FhirReleases.FhirSequenceCodes.R5), - //(FhirReleases.FhirSequenceCodes.R5, FhirReleases.FhirSequenceCodes.R4), + (FhirReleases.FhirSequenceCodes.R5, FhirReleases.FhirSequenceCodes.R4), ]; //UpdateValueSetMaps(); @@ -300,10 +300,10 @@ public void ProcessCommand(string? command) //ExportOutcomes(artifactFilter: FhirArtifactClassEnum.ValueSet, includeIgScripts: false); //ExportOutcomes(artifactFilter: FhirArtifactClassEnum.Resource, maxStepSize: 1, includeIgScripts: false, specificPairs: specificPairs); //ExportOutcomes(artifactFilter: FhirArtifactClassEnum.Resource, includeIgScripts: false, specificPairs: specificPairs); - //ExportOutcomes(includeIgScripts: false, specificPairs: specificPairs); + ExportOutcomes(includeIgScripts: false, specificPairs: specificPairs); //ExportOutcomes(includeIgScripts: true, specificPairs: specificPairs); //ExportOutcomes(includeIgScripts: false); - ExportOutcomes(); + //ExportOutcomes(); break; From 97c24b9c47f5b111b6e83e7308605d9591a322ff Mon Sep 17 00:00:00 2001 From: Gino Canessa Date: Wed, 27 May 2026 14:58:16 -0500 Subject: [PATCH 07/16] refactor(xver): rename SdPageContentFiles to ResourceLookupFiles Renames XVerIgExportTrackingRecord.SdPageContentFiles to ResourceLookupFiles and adds an empty TypeLookupFiles sibling, in preparation for splitting the structure lookup pages into a Resource lane and a Type lane. Pure rename + addition; no behavioural change. Updates all 9 in-tree references in IgExporter.cs and 2 in StructurePageExporter.cs. The new TypeLookupFiles list is intentionally not added to AsPackageContents, mirroring how ResourceLookupFiles is already excluded (lookup pages are pagecontent, not package files). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Exporter/IgExporter.cs | 21 ++++++++++--------- .../Exporter/StructurePageExporter.cs | 4 ++-- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/Fhir.CodeGen.Comparison/Exporter/IgExporter.cs b/src/Fhir.CodeGen.Comparison/Exporter/IgExporter.cs index 408693891..5765841dd 100644 --- a/src/Fhir.CodeGen.Comparison/Exporter/IgExporter.cs +++ b/src/Fhir.CodeGen.Comparison/Exporter/IgExporter.cs @@ -48,7 +48,8 @@ public class XVerIgExportTrackingRecord public XVerIgFileRecord? IgIndexFile { get; set; } = null; public string? PageContentDir { get; set; } = null; - public List SdPageContentFiles { get; set; } = []; + public List ResourceLookupFiles { get; set; } = []; + public List TypeLookupFiles { get; set; } = []; public List VsPageContentFiles { get; set; } = []; public List XVerSourcePageContentFiles { get; set; } = []; @@ -1269,25 +1270,25 @@ private void writeIgJsonR4(XVerIgExportTrackingRecord igTr) pageBuilder.AppendLine(""" { "nameUrl" : "index.html", "title" : "Home", "generation" : "markdown" , "page" : [ """); //pageBuilder.AppendLine(""" { "nameUrl" : "faqs.html", "title" : "FAQs", "generation" : "markdown" },"""); - if (igTr.SdPageContentFiles.Count == 1) + if (igTr.ResourceLookupFiles.Count == 1) { - XVerIgFileRecord sdp = igTr.SdPageContentFiles[0]; + XVerIgFileRecord sdp = igTr.ResourceLookupFiles[0]; pageBuilder.AppendLine( $$$""" { "nameUrl" : "{{{sdp.FileNameWithoutExtension}}}.html", "title" : "{{{sdp.Description}}}", "generation" : "markdown" }, """); } - else if (igTr.SdPageContentFiles.Count > 1) + else if (igTr.ResourceLookupFiles.Count > 1) { - XVerIgFileRecord sdp = igTr.SdPageContentFiles[0]; + XVerIgFileRecord sdp = igTr.ResourceLookupFiles[0]; pageBuilder.AppendLine( $$$""" { "nameUrl" : "{{{sdp.FileNameWithoutExtension}}}.html", "title" : "{{{sdp.Description}}}", "generation" : "markdown" , "page" : [ """); - foreach (XVerIgFileRecord fileRec in igTr.SdPageContentFiles[1..^1]) + foreach (XVerIgFileRecord fileRec in igTr.ResourceLookupFiles[1..^1]) { pageBuilder.AppendLine( $$$""" { "nameUrl" : "{{{fileRec.FileNameWithoutExtension}}}.html", "title" : "Lookup for {{{fileRec.Name}}}", "generation" : "markdown" },"""); } - XVerIgFileRecord last = igTr.SdPageContentFiles[^1]; + XVerIgFileRecord last = igTr.ResourceLookupFiles[^1]; pageBuilder.AppendLine( $$$""" { "nameUrl" : "{{{last.FileNameWithoutExtension}}}.html", "title" : "Lookup for {{{last.Name}}}", "generation" : "markdown" }"""); @@ -1405,7 +1406,7 @@ private void writeIgJsonR5(XVerIgExportTrackingRecord igTr) .Select(d => d.AsIgDependsOn(igTr.PackagePair.TargetFhirSequence)) .ToList(); - if (igTr.SdPageContentFiles.Count < 1) + if (igTr.ResourceLookupFiles.Count < 1) { throw new Exception($"No StructureDefinition page content files found for IG '{igTr.PackageId}'"); } @@ -1418,7 +1419,7 @@ private void writeIgJsonR5(XVerIgExportTrackingRecord igTr) "changelog", ]; - XVerIgFileRecord sdLookupFileRec = igTr.SdPageContentFiles[0]; + XVerIgFileRecord sdLookupFileRec = igTr.ResourceLookupFiles[0]; ImplementationGuide.PageComponent sdLookupPage = new() { @@ -1429,7 +1430,7 @@ private void writeIgJsonR5(XVerIgExportTrackingRecord igTr) Page = [], }; - foreach (XVerIgFileRecord fileRec in igTr.SdPageContentFiles) + foreach (XVerIgFileRecord fileRec in igTr.ResourceLookupFiles) { if (skipPages.Contains(fileRec.FileNameWithoutExtension)) { diff --git a/src/Fhir.CodeGen.Comparison/Exporter/StructurePageExporter.cs b/src/Fhir.CodeGen.Comparison/Exporter/StructurePageExporter.cs index 4409f6909..2a4b2178a 100644 --- a/src/Fhir.CodeGen.Comparison/Exporter/StructurePageExporter.cs +++ b/src/Fhir.CodeGen.Comparison/Exporter/StructurePageExporter.cs @@ -205,7 +205,7 @@ private void exportStructureLookupPages(XVerIgExportTrackingRecord igTr) } _logger.LogInformation($"Wrote {exported.Count} structure lookup pages for `{igTr.PackageId}`"); - igTr.SdPageContentFiles.AddRange(exported); + igTr.ResourceLookupFiles.AddRange(exported); } private void writeElementTable( @@ -665,6 +665,6 @@ private void exportStructureIndexPage(XVerIgExportTrackingRecord igTr) }); _logger.LogInformation($"Wrote {exported.Count} structure index pages for `{igTr.PackageId}`"); - igTr.SdPageContentFiles.AddRange(exported); + igTr.ResourceLookupFiles.AddRange(exported); } } From f7eb0d769427f31fa7ad3a6a42f7312e60fb6490 Mon Sep 17 00:00:00 2001 From: Gino Canessa Date: Wed, 27 May 2026 15:03:04 -0500 Subject: [PATCH 08/16] fix(xver): widen profile export to ComplexType + share exclusion set Phase 2 of widening the xver IG exporter beyond Resources: * Lift the abstract-base exclusion set (Base, BackboneType, BackboneElement, Element) to a shared internal static StructurePageExporter.StructureExportExclusions, and delete the duplicate copy in StructureFhirExporter. * Introduce StructureFhirExporter._xverSourceArtifactClasses = [Resource, ComplexType] so the per-class widening is a single named constant. * exportProfiles: select source structures from both Resource and ComplexType artifact classes (concatenated, since SelectList takes a single ArtifactClass), and skip exclusion-set entries before any output is generated. exportExtensions already iterated DbElementOutcome rows with no ArtifactClass filter and used SelectDict without one for source SDs, so ComplexType extensions are naturally in scope once the exclusion set is shared; only the reference rename was needed there. exportElementMaps is widened in the next phase. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Exporter/StructureFhirExporter.cs | 33 +++++++++++-------- .../Exporter/StructurePageExporter.cs | 10 +++--- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/Fhir.CodeGen.Comparison/Exporter/StructureFhirExporter.cs b/src/Fhir.CodeGen.Comparison/Exporter/StructureFhirExporter.cs index d698c8f11..097a5d262 100644 --- a/src/Fhir.CodeGen.Comparison/Exporter/StructureFhirExporter.cs +++ b/src/Fhir.CodeGen.Comparison/Exporter/StructureFhirExporter.cs @@ -96,12 +96,8 @@ private Dictionary< (FhirReleases.FhirSequenceCodes source, FhirReleases.FhirSequenceCodes target), PackagePairStructureMappingTracker> _resourceReferenceLookup = []; - private static readonly HashSet _exportExclusions = [ - "Base", - "BackboneType", - "BackboneElement", - "Element", - ]; + private static readonly FhirArtifactClassEnum[] _xverSourceArtifactClasses = + [FhirArtifactClassEnum.Resource, FhirArtifactClassEnum.ComplexType]; public StructureFhirExporter( XVerExporter exporter, @@ -811,15 +807,26 @@ private void exportProfiles(XVerIgExportTrackingRecord igTr) List exported = []; - // get the structures defined in the source package - List sourceStructures = DbStructureDefinition.SelectList( - _db, - FhirPackageKey: igTr.PackagePair.SourcePackageKey, - ArtifactClass: FhirArtifactClassEnum.Resource); + // get the structures defined in the source package (Resources + ComplexTypes) + List sourceStructures = []; + foreach (FhirArtifactClassEnum artifactClass in _xverSourceArtifactClasses) + { + sourceStructures.AddRange(DbStructureDefinition.SelectList( + _db, + FhirPackageKey: igTr.PackagePair.SourcePackageKey, + ArtifactClass: artifactClass)); + } // iterate over all structures - each will get a profile foreach (DbStructureDefinition sourceSd in sourceStructures) { + // skip abstract bases that don't get profiled + if (StructurePageExporter.StructureExportExclusions.Contains(sourceSd.Id) || + StructurePageExporter.StructureExportExclusions.Contains(sourceSd.Name)) + { + continue; + } + // get the structure outcomes for this source structure List sdOutcomes = DbStructureOutcome.SelectList( _db, @@ -1750,8 +1757,8 @@ private void exportExtensions(XVerIgExportTrackingRecord igTr) continue; } - if (_exportExclusions.Contains(edOutcome.SourceId) || - _exportExclusions.Contains(sourceSd.Name)) + if (StructurePageExporter.StructureExportExclusions.Contains(edOutcome.SourceId) || + StructurePageExporter.StructureExportExclusions.Contains(sourceSd.Name)) { continue; } diff --git a/src/Fhir.CodeGen.Comparison/Exporter/StructurePageExporter.cs b/src/Fhir.CodeGen.Comparison/Exporter/StructurePageExporter.cs index 2a4b2178a..07009386c 100644 --- a/src/Fhir.CodeGen.Comparison/Exporter/StructurePageExporter.cs +++ b/src/Fhir.CodeGen.Comparison/Exporter/StructurePageExporter.cs @@ -35,7 +35,7 @@ public class StructurePageExporter private ILoggerFactory _loggerFactory; private ILogger _logger; - private static readonly HashSet _exportExclusions = [ + internal static readonly HashSet StructureExportExclusions = [ "Base", "BackboneType", "BackboneElement", @@ -97,8 +97,8 @@ private void exportStructureLookupPages(XVerIgExportTrackingRecord igTr) foreach (DbStructureOutcome sdOutcome in sdOutcomes) { if ((sdOutcome.SourceArtifactClass != FhirArtifactClassEnum.Resource) || - _exportExclusions.Contains(sdOutcome.SourceId) || - _exportExclusions.Contains(sdOutcome.SourceName)) + StructureExportExclusions.Contains(sdOutcome.SourceId) || + StructureExportExclusions.Contains(sdOutcome.SourceName)) { continue; } @@ -618,8 +618,8 @@ private void exportStructureIndexPage(XVerIgExportTrackingRecord igTr) foreach (DbStructureOutcome sdOutcome in sdOutcomes) { if ((sdOutcome.SourceArtifactClass != FhirArtifactClassEnum.Resource) || - _exportExclusions.Contains(sdOutcome.SourceId) || - _exportExclusions.Contains(sdOutcome.SourceName)) + StructureExportExclusions.Contains(sdOutcome.SourceId) || + StructureExportExclusions.Contains(sdOutcome.SourceName)) { continue; } From 018bd7156edc5cd7721c19b3200039b5058d5623 Mon Sep 17 00:00:00 2001 From: Gino Canessa Date: Wed, 27 May 2026 15:05:47 -0500 Subject: [PATCH 09/16] fix(xver): widen element-map export to ComplexType Phase 3 of widening the xver IG exporter beyond Resources. exportElementMaps now selects source structures from both Resource and ComplexType artifact classes, and applies the shared StructureExportExclusions filter so abstract bases never produce per-structure element ConceptMaps. createElementConceptMap and addMappedElementsToElementCm operate from DbStructureOutcome / DbElementOutcome rows (which do not branch on ArtifactClass), so no further changes were needed there. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Exporter/StructureFhirExporter.cs | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/Fhir.CodeGen.Comparison/Exporter/StructureFhirExporter.cs b/src/Fhir.CodeGen.Comparison/Exporter/StructureFhirExporter.cs index 097a5d262..aa6f8083b 100644 --- a/src/Fhir.CodeGen.Comparison/Exporter/StructureFhirExporter.cs +++ b/src/Fhir.CodeGen.Comparison/Exporter/StructureFhirExporter.cs @@ -275,15 +275,26 @@ private void exportElementMaps(XVerIgExportTrackingRecord igTr) List exported = []; - // get the source resources - List sourceSds = DbStructureDefinition.SelectList( - _db, - FhirPackageKey: igTr.PackagePair.SourcePackageKey, - ArtifactClass: FhirArtifactClassEnum.Resource); + // get the source structures (Resources + ComplexTypes) + List sourceSds = []; + foreach (FhirArtifactClassEnum artifactClass in _xverSourceArtifactClasses) + { + sourceSds.AddRange(DbStructureDefinition.SelectList( + _db, + FhirPackageKey: igTr.PackagePair.SourcePackageKey, + ArtifactClass: artifactClass)); + } // iterate over our source structures foreach (DbStructureDefinition sourceSd in sourceSds) { + // skip abstract bases that don't need element-level maps + if (StructurePageExporter.StructureExportExclusions.Contains(sourceSd.Id) || + StructurePageExporter.StructureExportExclusions.Contains(sourceSd.Name)) + { + continue; + } + // get the structure outcomes for this structure List sdOutcomes = DbStructureOutcome.SelectList( _db, From 3fdd93a63e7cadf3a5be19cb758f75e9034cabef Mon Sep 17 00:00:00 2001 From: Gino Canessa Date: Wed, 27 May 2026 15:11:25 -0500 Subject: [PATCH 10/16] fix(xver): emit type-map ConceptMap for ComplexType artifacts Phase 4 of widening the xver IG exporter beyond Resources. * Adds XVerIgExportTrackingRecord.TypeMapFiles next to ResourceMapFiles. * Adds createTypeConceptMap, a sibling of createResourceConceptMap, scoped to data-types value sets instead of resource-types, with id pattern {src}-type-map-to-{tgt}. * Adds exportTypeMaps, mirroring exportResourceMaps but selecting only DbStructureOutcome rows with SourceArtifactClass == ComplexType and applying the shared StructureExportExclusions filter. Writes the ConceptMap into the existing ResourceMapDir lane per plan decision (no new *Dir property). * Wires exportTypeMaps(igTr) into Export() between exportResourceMaps and exportElementMaps, removing the // TODO: decide if we are exporting type maps marker. * AsPackageContents, writeIgJsonR4, and writeIgJsonR5 now include TypeMapFiles next to ResourceMapFiles so the manifest references the new ConceptMap. * exportTypeMaps skips writing the file when no complex-type outcomes were produced, to avoid emitting an empty ConceptMap. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Exporter/IgExporter.cs | 38 ++++ .../Exporter/StructureFhirExporter.cs | 189 +++++++++++++++++- 2 files changed, 225 insertions(+), 2 deletions(-) diff --git a/src/Fhir.CodeGen.Comparison/Exporter/IgExporter.cs b/src/Fhir.CodeGen.Comparison/Exporter/IgExporter.cs index 5765841dd..5af1c6e47 100644 --- a/src/Fhir.CodeGen.Comparison/Exporter/IgExporter.cs +++ b/src/Fhir.CodeGen.Comparison/Exporter/IgExporter.cs @@ -69,6 +69,7 @@ public class XVerIgExportTrackingRecord public string? ResourceMapDir { get; set; } = null; public List ResourceMapFiles { get; set; } = []; + public List TypeMapFiles { get; set; } = []; public string? ElementMapDir { get; set; } = null; public List ElementMapFiles { get; set; } = []; @@ -118,6 +119,7 @@ public PackageContents AsPackageContents() files.AddRange(ExtensionFiles.Select(f => f.AsPackageFile())); files.AddRange(ProfileFiles.Select(f => f.AsPackageFile())); files.AddRange(ResourceMapFiles.Select(f => f.AsPackageFile())); + files.AddRange(TypeMapFiles.Select(f => f.AsPackageFile())); files.AddRange(ElementMapFiles.Select(f => f.AsPackageFile())); return new PackageContents() @@ -1239,6 +1241,24 @@ private void writeIgJsonR4(XVerIgExportTrackingRecord igTr) """); } + // process type concept maps + foreach (XVerIgFileRecord fileRec in igTr.TypeMapFiles) + { + resourceDefinitions.Add($$$""" + { + "extension" : [{ + "url" : "http://hl7.org/fhir/tools/StructureDefinition/resource-information", + "valueString" : "ConceptMap" + }], + "reference" : { + "reference" : "ConceptMap/{{{fileRec.Id}}}" + }, + "name" : "{{{fileRec.Name}}}", + "description" : "{{{FhirSanitizationUtils.SanitizeForJsonValue(fileRec.Description)}}}" + } + """); + } + // process element concept maps foreach (XVerIgFileRecord fileRec in igTr.ElementMapFiles) { @@ -1701,6 +1721,24 @@ private void writeIgJsonR5(XVerIgExportTrackingRecord igTr) }); } + // add our type concept maps + foreach (XVerIgFileRecord fileRec in igTr.TypeMapFiles) + { + ig.Definition.Resource.Add(new() + { + Reference = new ResourceReference($"ConceptMap/{fileRec.Id}"), + Name = fileRec.Name, + Description = FhirSanitizationUtils.SanitizeForJsonValue(fileRec.Description), + Extension = [ + new() + { + Url = "http://hl7.org/fhir/tools/StructureDefinition/resource-information", + Value = new FhirString("ConceptMap"), + }, + ], + }); + } + // add our element concept maps foreach (XVerIgFileRecord fileRec in igTr.ElementMapFiles) { diff --git a/src/Fhir.CodeGen.Comparison/Exporter/StructureFhirExporter.cs b/src/Fhir.CodeGen.Comparison/Exporter/StructureFhirExporter.cs index aa6f8083b..0100f8e85 100644 --- a/src/Fhir.CodeGen.Comparison/Exporter/StructureFhirExporter.cs +++ b/src/Fhir.CodeGen.Comparison/Exporter/StructureFhirExporter.cs @@ -238,11 +238,12 @@ public void Export(XVerExportTrackingRecord tr) // export profiles exportProfiles(igTr); - // TODO: decide if we are exporting type maps - // export resource maps exportResourceMaps(igTr); + // export type maps + exportTypeMaps(igTr); + // export element maps exportElementMaps(igTr); } @@ -795,6 +796,190 @@ private ConceptMap createResourceConceptMap( return vsCm; } + private ConceptMap createTypeConceptMap( + XVerIgExportTrackingRecord igTr) + { + string id = $"{igTr.PackagePair.SourcePackageShortName}-type-map-to-{igTr.PackagePair.TargetPackageShortName}"; + string name = + $"{igTr.PackagePair.SourcePackageShortName.ToPascalCase()}" + + $"TypeMapTo" + + $"{igTr.PackagePair.TargetPackageShortName.ToPascalCase()}"; + + (_, name) = igTr.GetName(name, id); + + ConceptMap vsCm = new() + { + Id = id, + Url = $"{XVerProcessor._canonicalRootCrossVersion}ConceptMap/{id}", + Name = name, + Version = _exporter._crossDefinitionVersion, + DateElement = new FhirDateTime(DateTimeOffset.Now), + Title = $"Cross-version ConceptMap for FHIR {igTr.PackagePair.SourceFhirSequence} types in FHIR {igTr.PackagePair.TargetFhirSequence}", + Description = $"This ConceptMap represents the cross-version mapping of complex types from FHIR {igTr.PackagePair.SourceFhirSequence} for use in FHIR {igTr.PackagePair.TargetFhirSequence}.", + Status = PublicationStatus.Active, + Experimental = false, + SourceScope = new FhirUri($"http://hl7.org/fhir/{igTr.PackagePair.SourceFhirVersionShort}/ValueSet/data-types"), + TargetScope = new FhirUri($"http://hl7.org/fhir/{igTr.PackagePair.TargetFhirVersionShort}/ValueSet/data-types"), + Group = [ + new() + { + Source = $"http://hl7.org/fhir/{igTr.PackagePair.SourceFhirVersionShort}/data-types", + Target = $"http://hl7.org/fhir/{igTr.PackagePair.TargetFhirVersionShort}/data-types", + Element = [], + } + ], + }; + + return vsCm; + } + + private void exportTypeMaps(XVerIgExportTrackingRecord igTr) + { + CrossVersionExporter.ConceptMapToR3? exporterR3 = (_exporter._versionSpecificExport == XVerExporter.VersionSpecificExportCodes.TargetVersion) && + (igTr.PackagePair.TargetFhirSequence < FhirReleases.FhirSequenceCodes.R4) + ? new() + : null; + + CrossVersionExporter.ConceptMapToR4? exporterR4 = (_exporter._versionSpecificExport == XVerExporter.VersionSpecificExportCodes.TargetVersion) && + (igTr.PackagePair.TargetFhirSequence < FhirReleases.FhirSequenceCodes.R5) + ? new() + : null; + + if (igTr.ResourceMapDir is null) + { + throw new Exception("ResourceMapDir is null"); + } + + string dir = igTr.ResourceMapDir; + if (!Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + + _logger.LogInformation($"Writing type maps for `{igTr.PackageId}`..."); + + List exported = []; + + ConceptMap cm = createTypeConceptMap(igTr); + + // get the structure outcomes for this pair, filtered to ComplexType sources + List sdOutcomes = DbStructureOutcome.SelectList( + _db, + SourceFhirPackageKey: igTr.PackagePair.SourcePackageKey, + TargetFhirPackageKey: igTr.PackagePair.TargetPackageKey, + orderByProperties: [nameof(DbStructureOutcome.SourceName), nameof(DbStructureOutcome.TargetName)]); + + string? lastSourceId = null; + + ConceptMap.SourceElementComponent? currentSourceElement = null; + + // iterate over the outcomes + foreach (DbStructureOutcome sdOutcome in sdOutcomes) + { + if ((sdOutcome.SourceArtifactClass != FhirArtifactClassEnum.ComplexType) || + StructurePageExporter.StructureExportExclusions.Contains(sdOutcome.SourceId) || + StructurePageExporter.StructureExportExclusions.Contains(sdOutcome.SourceName)) + { + continue; + } + + // check if we need a new source element + if ((currentSourceElement is null) || + (lastSourceId != sdOutcome.SourceId)) + { + // create a new source element + currentSourceElement = new() + { + Code = sdOutcome.SourceId, + Display = sdOutcome.SourceName, + Target = [], + }; + cm.Group[0].Element.Add(currentSourceElement); + lastSourceId = sdOutcome.SourceId; + } + + CMR relationship; + if (sdOutcome.IsIdentical || sdOutcome.IsEquivalent) + { + relationship = CMR.Equivalent; + } + else if (sdOutcome.IsBroaderThanTarget) + { + relationship = CMR.SourceIsBroaderThanTarget; + } + else if (sdOutcome.IsNarrowerThanTarget) + { + relationship = CMR.SourceIsNarrowerThanTarget; + } + else + { + relationship = CMR.RelatedTo; + } + + // create our target element + ConceptMap.TargetElementComponent targetElement = new() + { + Code = sdOutcome.TargetId ?? "Basic", + Display = sdOutcome.TargetName ?? "Basic", + Relationship = relationship, + Comment = sdOutcome.Comments, + }; + + currentSourceElement.Target.Add(targetElement); + } + + // if there are no mapped types, skip writing the file + if (cm.Group[0].Element.Count == 0) + { + _logger.LogInformation($"No complex-type outcomes for `{igTr.PackageId}`; skipping type map"); + igTr.TypeMapFiles = exported; + return; + } + + // write the type map to a file + string filename; + if (cm.Id.StartsWith("ConceptMap", StringComparison.OrdinalIgnoreCase)) + { + filename = cm.Id; + } + else if (cm.Id.StartsWith("Map", StringComparison.OrdinalIgnoreCase)) + { + filename = "Concept" + cm.Id; + } + else + { + filename = cm.Id; + } + string path = Path.Combine(dir, filename + ".json"); + if (exporterR3 is not null) + { + File.WriteAllText(path, exporterR3.ToJson(cm, new SerializerSettings() { Pretty = true })); + } + else if (exporterR4 is not null) + { + File.WriteAllText(path, exporterR4.ToJson(cm, new SerializerSettings() { Pretty = true })); + } + else + { + File.WriteAllText(path, cm.ToJson(new FhirJsonSerializationSettings() { Pretty = true })); + } + exported.Add(new() + { + FileName = filename + ".json", + FileNameWithoutExtension = filename, + IsPageContentFile = false, + Name = cm.Name, + Id = cm.Id, + Url = cm.Url, + ResourceType = Hl7.Fhir.Model.FHIRAllTypes.ConceptMap.GetLiteral(), + Version = cm.Version, + Description = cm.Description ?? cm.Title ?? $"ConceptMap: {cm.Url}", + }); + + _logger.LogInformation($"Wrote {exported.Count} type maps for `{igTr.PackageId}`"); + igTr.TypeMapFiles = exported; + } + private void exportProfiles(XVerIgExportTrackingRecord igTr) { From 4a62d23c3c71877a29a69640dab981b70b725ab6 Mon Sep 17 00:00:00 2001 From: Gino Canessa Date: Wed, 27 May 2026 15:16:42 -0500 Subject: [PATCH 11/16] fix(xver): split structure lookup into Resource + Type lanes Phase 5 of widening the xver IG exporter beyond Resources. * StructurePageExporter: parameterized exportStructureIndexPage and exportStructureLookupPages into exportLookupIndexPage and exportLookupPages (sourceArtifactClass, indexFileName, indexTitle, trackerList). Export() now runs them once per artifact class: Resource -> lookup-sd.md / 'Resource Lookup' / ResourceLookupFiles, ComplexType -> lookup-sd-types.md / 'Type Lookup' / TypeLookupFiles. Per-structure pages keep the lookup-sd-{id}.md scheme for both lanes (resource and complex-type IDs are disjoint in FHIR). * IgExporter.writeMenuXml: 'Structure Lookup' -> 'Resource Lookup', added 'Type Lookup' nav entry pointing at lookup-sd-types.html. * IgExporter: added 'lookup-sd-types' to all four skipPages sets so the new index page is not duplicated in the per-structure listing. * IgExporter.writeIgJsonR4: emits a parallel type-lookup branch matching the resource-lookup block when TypeLookupFiles is non-empty. * IgExporter.writeIgJsonR5: constructs an optional typeLookupPage PageComponent and inserts it into igPage.Page between sdLookupPage and vsLookupPage when complex-type outcomes were produced. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Exporter/IgExporter.cs | 132 ++++++++++++++---- .../Exporter/StructurePageExporter.cs | 62 +++++--- 2 files changed, 148 insertions(+), 46 deletions(-) diff --git a/src/Fhir.CodeGen.Comparison/Exporter/IgExporter.cs b/src/Fhir.CodeGen.Comparison/Exporter/IgExporter.cs index 5af1c6e47..571600187 100644 --- a/src/Fhir.CodeGen.Comparison/Exporter/IgExporter.cs +++ b/src/Fhir.CodeGen.Comparison/Exporter/IgExporter.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Data; using System.Linq; @@ -796,7 +796,10 @@ private void writeMenuXml(XVerIgExportTrackingRecord igTr) Home
  • - Structure Lookup + Resource Lookup +
  • +
  • + Type Lookup
  • ValueSet Lookup @@ -850,6 +853,7 @@ private void writeIgJsonR4(ValidationIgExportTrackingRecord vTr, XVerExportTrack HashSet skipPages = [ "index", "lookup-sd", + "lookup-sd-types", "lookup-vs", "downloads", "changelog", @@ -990,6 +994,7 @@ private void writeIgJsonR5(ValidationIgExportTrackingRecord vTr, XVerExportTrack HashSet skipPages = [ "index", "lookup-sd", + "lookup-sd-types", "lookup-vs", "downloads", "changelog", @@ -1280,6 +1285,7 @@ private void writeIgJsonR4(XVerIgExportTrackingRecord igTr) HashSet skipPages = [ "index", "lookup-sd", + "lookup-sd-types", "lookup-vs", "downloads", "changelog", @@ -1315,6 +1321,31 @@ private void writeIgJsonR4(XVerIgExportTrackingRecord igTr) pageBuilder.AppendLine("""]},"""); // close lookup } + if (igTr.TypeLookupFiles.Count == 1) + { + XVerIgFileRecord tlp = igTr.TypeLookupFiles[0]; + pageBuilder.AppendLine( + $$$""" { "nameUrl" : "{{{tlp.FileNameWithoutExtension}}}.html", "title" : "{{{tlp.Description}}}", "generation" : "markdown" }, """); + } + else if (igTr.TypeLookupFiles.Count > 1) + { + XVerIgFileRecord tlp = igTr.TypeLookupFiles[0]; + pageBuilder.AppendLine( + $$$""" { "nameUrl" : "{{{tlp.FileNameWithoutExtension}}}.html", "title" : "{{{tlp.Description}}}", "generation" : "markdown" , "page" : [ """); + + foreach (XVerIgFileRecord fileRec in igTr.TypeLookupFiles[1..^1]) + { + pageBuilder.AppendLine( + $$$""" { "nameUrl" : "{{{fileRec.FileNameWithoutExtension}}}.html", "title" : "Lookup for {{{fileRec.Name}}}", "generation" : "markdown" },"""); + } + + XVerIgFileRecord last = igTr.TypeLookupFiles[^1]; + pageBuilder.AppendLine( + $$$""" { "nameUrl" : "{{{last.FileNameWithoutExtension}}}.html", "title" : "Lookup for {{{last.Name}}}", "generation" : "markdown" }"""); + + pageBuilder.AppendLine("""]},"""); // close type lookup + } + if (igTr.VsPageContentFiles.Count == 1) { XVerIgFileRecord sdp = igTr.VsPageContentFiles[0]; @@ -1434,6 +1465,7 @@ private void writeIgJsonR5(XVerIgExportTrackingRecord igTr) HashSet skipPages = [ "index", "lookup-sd", + "lookup-sd-types", "lookup-vs", "downloads", "changelog", @@ -1466,6 +1498,37 @@ private void writeIgJsonR5(XVerIgExportTrackingRecord igTr) }); } + // build the optional type lookup page tree (may be empty if no complex-type outcomes) + ImplementationGuide.PageComponent? typeLookupPage = null; + if (igTr.TypeLookupFiles.Count > 0) + { + XVerIgFileRecord typeLookupIndexFileRec = igTr.TypeLookupFiles[0]; + typeLookupPage = new() + { + Source = new FhirUrl(typeLookupIndexFileRec.FileName), + Name = typeLookupIndexFileRec.FileNameWithoutExtension + ".html", + Title = typeLookupIndexFileRec.Description, + Generation = ImplementationGuide.GuidePageGeneration.Markdown, + Page = [], + }; + + foreach (XVerIgFileRecord fileRec in igTr.TypeLookupFiles) + { + if (skipPages.Contains(fileRec.FileNameWithoutExtension)) + { + continue; + } + + typeLookupPage.Page.Add(new() + { + Source = new FhirUrl(fileRec.FileName), + Name = $"{fileRec.FileNameWithoutExtension}.html", + Title = $"Lookup for {fileRec.Name}", + Generation = ImplementationGuide.GuidePageGeneration.Markdown, + }); + } + } + if (igTr.VsPageContentFiles.Count < 1) { throw new Exception($"No ValueSet page content files found for IG '{igTr.PackageId}'"); @@ -1504,33 +1567,46 @@ private void writeIgJsonR5(XVerIgExportTrackingRecord igTr) Name = "index.html", Title = "Home", Generation = ImplementationGuide.GuidePageGeneration.Markdown, - Page = [ - //new() - //{ - // Source = new FhirUrl("faqs.md"), - // Name = "faqs.html", - // Title = "FAQs", - // Generation = ImplementationGuide.GuidePageGeneration.Markdown, - - //}, - sdLookupPage, - vsLookupPage, - new() - { - Source = new FhirUrl("downloads.md"), - Name = "downloads.html", - Title = "Downloads", - Generation = ImplementationGuide.GuidePageGeneration.Markdown, + Page = typeLookupPage is null + ? [ + sdLookupPage, + vsLookupPage, + new() + { + Source = new FhirUrl("downloads.md"), + Name = "downloads.html", + Title = "Downloads", + Generation = ImplementationGuide.GuidePageGeneration.Markdown, - }, - new() - { - Source = new FhirUrl("changelog.md"), - Name = "changelog.html", - Title = "Change Log", - Generation = ImplementationGuide.GuidePageGeneration.Markdown, - } - ], + }, + new() + { + Source = new FhirUrl("changelog.md"), + Name = "changelog.html", + Title = "Change Log", + Generation = ImplementationGuide.GuidePageGeneration.Markdown, + } + ] + : [ + sdLookupPage, + typeLookupPage, + vsLookupPage, + new() + { + Source = new FhirUrl("downloads.md"), + Name = "downloads.html", + Title = "Downloads", + Generation = ImplementationGuide.GuidePageGeneration.Markdown, + + }, + new() + { + Source = new FhirUrl("changelog.md"), + Name = "changelog.html", + Title = "Change Log", + Generation = ImplementationGuide.GuidePageGeneration.Markdown, + } + ], }; List igParams = _xverIgParameters diff --git a/src/Fhir.CodeGen.Comparison/Exporter/StructurePageExporter.cs b/src/Fhir.CodeGen.Comparison/Exporter/StructurePageExporter.cs index 07009386c..3d876f2f1 100644 --- a/src/Fhir.CodeGen.Comparison/Exporter/StructurePageExporter.cs +++ b/src/Fhir.CodeGen.Comparison/Exporter/StructurePageExporter.cs @@ -58,15 +58,36 @@ public void Export(XVerExportTrackingRecord tr) // iterate over the XVer IGs foreach (XVerIgExportTrackingRecord igTr in tr.XVerIgs) { - // export package structure index page - exportStructureIndexPage(igTr); - - // export individual structure lookup pages - exportStructureLookupPages(igTr); + // export resource lookup index + per-resource pages + exportLookupIndexPage( + igTr, + FhirArtifactClassEnum.Resource, + "lookup-sd.md", + "Resource Lookup", + igTr.ResourceLookupFiles); + exportLookupPages( + igTr, + FhirArtifactClassEnum.Resource, + igTr.ResourceLookupFiles); + + // export type lookup index + per-type pages + exportLookupIndexPage( + igTr, + FhirArtifactClassEnum.ComplexType, + "lookup-sd-types.md", + "Type Lookup", + igTr.TypeLookupFiles); + exportLookupPages( + igTr, + FhirArtifactClassEnum.ComplexType, + igTr.TypeLookupFiles); } } - private void exportStructureLookupPages(XVerIgExportTrackingRecord igTr) + private void exportLookupPages( + XVerIgExportTrackingRecord igTr, + FhirArtifactClassEnum sourceArtifactClass, + List trackerList) { if (igTr.PageContentDir is null) { @@ -79,7 +100,7 @@ private void exportStructureLookupPages(XVerIgExportTrackingRecord igTr) Directory.CreateDirectory(dir); } - _logger.LogInformation($"Writing structure lookup pages for `{igTr.PackageId}`..."); + _logger.LogInformation($"Writing structure lookup pages for `{igTr.PackageId}` ({sourceArtifactClass})..."); List exported = []; @@ -96,7 +117,7 @@ private void exportStructureLookupPages(XVerIgExportTrackingRecord igTr) // iterate over the outcomes to create lookup pages foreach (DbStructureOutcome sdOutcome in sdOutcomes) { - if ((sdOutcome.SourceArtifactClass != FhirArtifactClassEnum.Resource) || + if ((sdOutcome.SourceArtifactClass != sourceArtifactClass) || StructureExportExclusions.Contains(sdOutcome.SourceId) || StructureExportExclusions.Contains(sdOutcome.SourceName)) { @@ -204,8 +225,8 @@ private void exportStructureLookupPages(XVerIgExportTrackingRecord igTr) }); } - _logger.LogInformation($"Wrote {exported.Count} structure lookup pages for `{igTr.PackageId}`"); - igTr.ResourceLookupFiles.AddRange(exported); + _logger.LogInformation($"Wrote {exported.Count} structure lookup pages for `{igTr.PackageId}` ({sourceArtifactClass})"); + trackerList.AddRange(exported); } private void writeElementTable( @@ -558,7 +579,12 @@ private void writeElementTable( targetStructureCount = targetStructureKeys.Count; } - private void exportStructureIndexPage(XVerIgExportTrackingRecord igTr) + private void exportLookupIndexPage( + XVerIgExportTrackingRecord igTr, + FhirArtifactClassEnum sourceArtifactClass, + string indexFileName, + string indexTitle, + List trackerList) { if (igTr.PageContentDir is null) { @@ -571,7 +597,7 @@ private void exportStructureIndexPage(XVerIgExportTrackingRecord igTr) Directory.CreateDirectory(dir); } - _logger.LogInformation($"Writing structure index page for `{igTr.PackageId}`..."); + _logger.LogInformation($"Writing structure index page for `{igTr.PackageId}` ({sourceArtifactClass})..."); List exported = []; @@ -579,7 +605,7 @@ private void exportStructureIndexPage(XVerIgExportTrackingRecord igTr) string targetBaseUrl = igTr.PackagePair.TargetFhirSequence.ToWebUrlRoot(); // create the lookup file - string filename = Path.Combine(dir, "lookup-sd.md"); + string filename = Path.Combine(dir, indexFileName); using ExportStreamWriter mdWriter = createMarkdownWriter(filename); mdWriter.WriteLine($"### FHIR {igTr.PackageId} Cross-Version Artifact Lookup"); @@ -617,7 +643,7 @@ private void exportStructureIndexPage(XVerIgExportTrackingRecord igTr) // iterate over the outcomes foreach (DbStructureOutcome sdOutcome in sdOutcomes) { - if ((sdOutcome.SourceArtifactClass != FhirArtifactClassEnum.Resource) || + if ((sdOutcome.SourceArtifactClass != sourceArtifactClass) || StructureExportExclusions.Contains(sdOutcome.SourceId) || StructureExportExclusions.Contains(sdOutcome.SourceName)) { @@ -656,15 +682,15 @@ private void exportStructureIndexPage(XVerIgExportTrackingRecord igTr) FileName = fn, FileNameWithoutExtension = fn[..^3], IsPageContentFile = true, - Name = "Structure Lookup", + Name = indexTitle, Id = null, Url = null, ResourceType = null, Version = null, - Description = "Structure Lookup", + Description = indexTitle, }); - _logger.LogInformation($"Wrote {exported.Count} structure index pages for `{igTr.PackageId}`"); - igTr.ResourceLookupFiles.AddRange(exported); + _logger.LogInformation($"Wrote {exported.Count} structure index pages for `{igTr.PackageId}` ({sourceArtifactClass})"); + trackerList.AddRange(exported); } } From 8a4c0ccd80dd45215081e7b392c0bf75e7a8fe82 Mon Sep 17 00:00:00 2001 From: Gino Canessa Date: Wed, 27 May 2026 15:21:03 -0500 Subject: [PATCH 12/16] test(xver): add ComplexType artifact emission regression test Adds CrossVersionTests.XVerEmitsComplexTypeArtifacts, a RequiresExternalRepo=true gated Theory that walks an existing out/xver// tree and asserts: * At least one StructureDefinition profile per sentinel complex type (Dosage, Address, HumanName, Identifier, CodeableReference, ContactDetail, Expression, RelatedArtifact). * At least one extension StructureDefinition per sentinel. * A *-type-map-to-* ConceptMap exists under input/resources/. * Both lookup-sd.md and lookup-sd-types.md exist under input/pagecontent/. * No artifacts emitted for the abstract bases (Base, Element, BackboneElement, BackboneType). The test is skipped under the standard CI filter (--filter 'RequiresExternalRepo!=true') so it does not impact the nightly build, but compiles and can be run locally once the xver CLI has been invoked against a populated ~/.fhir cache. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CrossVersionTests.cs | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/src/Fhir.CodeGen.Lib.Tests/CrossVersionTests.cs b/src/Fhir.CodeGen.Lib.Tests/CrossVersionTests.cs index 053bf837b..341bcea9d 100644 --- a/src/Fhir.CodeGen.Lib.Tests/CrossVersionTests.cs +++ b/src/Fhir.CodeGen.Lib.Tests/CrossVersionTests.cs @@ -679,6 +679,89 @@ private void Source_Load(object sender, CachedResolver.LoadResourceEventArgs e) // } //} + [Theory(DisplayName = "XVerEmitsComplexTypeArtifacts", Skip = "Tests require external repo - run manually if desired")] + [Trait("Category", "XVer")] + [Trait("RequiresExternalRepo", "true")] + [Trait("ExternalRepo", "HL7/fhir-cross-version")] + [InlineData("R4", "R5")] + public void XVerEmitsComplexTypeArtifacts(string srcShort, string tgtShort) + { + // This test pins the "widen xver export beyond Resources" fix from + // scratch/0527-01/. It is gated behind RequiresExternalRepo=true so + // CI (filter "RequiresExternalRepo!=true") skips it; run locally + // against a pre-populated ~/.fhir cache after invoking the xver + // pipeline (e.g. + // dotnet run --project src/fhir-codegen/fhir-codegen.csproj -- + // xver --output-path .\out\xver + // ). + // + // Sentinel complex / metadata types: at least one Profile and one + // Extension StructureDefinition should be generated per type, plus + // a top-level type-map ConceptMap, and the Type Lookup page tree. + string[] sentinelTypes = + [ + "Dosage", "Address", "HumanName", "Identifier", + "CodeableReference", "ContactDetail", "Expression", "RelatedArtifact", + ]; + + string outRoot = Path.Combine(FindRelativeDir("out"), "xver"); + Directory.Exists(outRoot).ShouldBeTrue($"Expected xver output at {outRoot}; run the xver CLI first."); + + // Each emitted IG sits under out/xver//, with input/resources + // and input/pagecontent subdirectories. + string[] pkgDirs = Directory.GetDirectories(outRoot, + $"hl7.fhir.uv.xver-{srcShort.ToLowerInvariant()}.{tgtShort.ToLowerInvariant()}*", + SearchOption.TopDirectoryOnly); + pkgDirs.Length.ShouldBeGreaterThan(0, $"No xver package directory found for {srcShort}->{tgtShort}"); + + foreach (string pkgDir in pkgDirs) + { + string resourcesDir = Path.Combine(pkgDir, "input", "resources"); + string pageContentDir = Path.Combine(pkgDir, "input", "pagecontent"); + + Directory.Exists(resourcesDir).ShouldBeTrue($"Missing resources dir under {pkgDir}"); + Directory.Exists(pageContentDir).ShouldBeTrue($"Missing pagecontent dir under {pkgDir}"); + + string[] allResourceFiles = Directory.GetFiles(resourcesDir, "*.json", SearchOption.TopDirectoryOnly); + + // Sentinel complex types must each produce at least one profile + one extension. + foreach (string sentinel in sentinelTypes) + { + allResourceFiles.Any(f => Path.GetFileName(f).Contains(sentinel, StringComparison.OrdinalIgnoreCase) && + Path.GetFileName(f).StartsWith("StructureDefinition-", StringComparison.OrdinalIgnoreCase)) + .ShouldBeTrue($"Expected at least one StructureDefinition profile for complex type {sentinel} under {resourcesDir}"); + + allResourceFiles.Any(f => Path.GetFileName(f).Contains(sentinel, StringComparison.OrdinalIgnoreCase) && + Path.GetFileName(f).Contains("extension", StringComparison.OrdinalIgnoreCase)) + .ShouldBeTrue($"Expected at least one extension StructureDefinition for complex type {sentinel} under {resourcesDir}"); + } + + // Type-map ConceptMap must be present. + allResourceFiles.Any(f => Path.GetFileName(f).Contains("type-map-to", StringComparison.OrdinalIgnoreCase)) + .ShouldBeTrue($"Expected a *-type-map-to-* ConceptMap under {resourcesDir}"); + + // Type lookup page index. + File.Exists(Path.Combine(pageContentDir, "lookup-sd-types.md")) + .ShouldBeTrue($"Expected lookup-sd-types.md under {pageContentDir}"); + + // Resource lookup page index, with the renamed title. + string lookupSdPath = Path.Combine(pageContentDir, "lookup-sd.md"); + File.Exists(lookupSdPath).ShouldBeTrue($"Expected lookup-sd.md under {pageContentDir}"); + + // Exclusion guard: no artifacts for the abstract bases. + string[] excludedRoots = ["Base", "Element", "BackboneElement", "BackboneType"]; + foreach (string excluded in excludedRoots) + { + allResourceFiles.Any(f => + { + string name = Path.GetFileName(f); + return name.StartsWith($"StructureDefinition-{excluded}", StringComparison.OrdinalIgnoreCase) || + name.StartsWith($"StructureDefinition-extension-{excluded}", StringComparison.OrdinalIgnoreCase); + }).ShouldBeFalse($"Expected no artifacts for abstract base type {excluded} under {resourcesDir}"); + } + } + } + } public class TestWriter : TextWriter From 2c9ea9d3ad727efd8794ef40aacaef2d3672a195 Mon Sep 17 00:00:00 2001 From: Gino Canessa Date: Wed, 27 May 2026 15:23:55 -0500 Subject: [PATCH 13/16] refactor(xver): rename legacy StructureLookupFiles to ResourceLookupFiles Cosmetic rename of the dead-code field on the private XverPackageIndexInfo class for name-parity with the live tracker field renamed in Phase 1. Touches: * XVerProcessorDbFhir.cs:154 (declaration) * XVerProcessorOutcomes.cs (3 call sites) * XVerProcessorDbPackage.cs (3 sites including a commented reference) No behavioural change. The XVerProcessor.WriteFhirFromDbOutcomes path this field belongs to is superseded by XVerExporter and is no longer reachable from any current ProcessCommand case, but renaming keeps future maintenance grep-able. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Fhir.CodeGen.Comparison/XVer/XVerProcessorDbFhir.cs | 4 ++-- .../XVer/XVerProcessorDbPackage.cs | 8 ++++---- src/Fhir.CodeGen.Comparison/XVer/XVerProcessorOutcomes.cs | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Fhir.CodeGen.Comparison/XVer/XVerProcessorDbFhir.cs b/src/Fhir.CodeGen.Comparison/XVer/XVerProcessorDbFhir.cs index f5689a585..149340e9f 100644 --- a/src/Fhir.CodeGen.Comparison/XVer/XVerProcessorDbFhir.cs +++ b/src/Fhir.CodeGen.Comparison/XVer/XVerProcessorDbFhir.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Data; @@ -151,7 +151,7 @@ private class XverPackageIndexInfo public List CodeSystemFiles { get; set; } = []; public List ValueSetFiles { get; set; } = []; - public List StructureLookupFiles { get; set; } = []; + public List ResourceLookupFiles { get; set; } = []; public List ValueSetLookupFiles { get; set; } = []; public XVerIgFileRecord? IgIndexFile { get; set; } = null; diff --git a/src/Fhir.CodeGen.Comparison/XVer/XVerProcessorDbPackage.cs b/src/Fhir.CodeGen.Comparison/XVer/XVerProcessorDbPackage.cs index 26b0ecd76..292d7d5d4 100644 --- a/src/Fhir.CodeGen.Comparison/XVer/XVerProcessorDbPackage.cs +++ b/src/Fhir.CodeGen.Comparison/XVer/XVerProcessorDbPackage.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.CommandLine; @@ -2302,7 +2302,7 @@ private string getIgJsonR5( Page = [], }; - foreach (XVerIgFileRecord fileRec in indexInfo.StructureLookupFiles) + foreach (XVerIgFileRecord fileRec in indexInfo.ResourceLookupFiles) { lookupPage.Page.Add(new() { @@ -2684,7 +2684,7 @@ private string getIgJsonR4( pageBuilder.AppendLine(""" { "nameUrl" : "lookup.html", "title" : "Artifact Lookup", "generation" : "markdown" , "page" : [ """); List lookupPages = []; - foreach (XVerIgFileRecord fileRec in indexInfo.StructureLookupFiles) + foreach (XVerIgFileRecord fileRec in indexInfo.ResourceLookupFiles) { lookupPages.Add($$$""" { "nameUrl" : "{{{fileRec.FileNameWithoutExtension}}}.html", "title" : "Lookup for {{{fileRec.Name}}}", "generation" : "markdown" }"""); } @@ -2807,7 +2807,7 @@ private string getCombinedIgJsonR4( pageBuilder.AppendLine(""" { "nameUrl" : "index.html", "title" : "Home", "generation" : "markdown" , "page" : [ """); //pageBuilder.AppendLine(""" { "sourceUrl" : "lookup.md", "name" : "lookup.html", "title" : "Artifact Lookup", "generation" : "markdown" , "page" : [ """); - //foreach (XVerIgFileRecord fileRec in indexInfo.StructureLookupFiles) + //foreach (XVerIgFileRecord fileRec in indexInfo.ResourceLookupFiles) //{ // pageBuilder.AppendLine($$$""" { "sourceUrl" : "{{{fileRec.FileName}}}", "name" : "{{{fileRec.FileNameWithoutExtension}}}.html", "title" : "Lookup for {{{fileRec.Name}}}", "generation" : "markdown" }"""); //} diff --git a/src/Fhir.CodeGen.Comparison/XVer/XVerProcessorOutcomes.cs b/src/Fhir.CodeGen.Comparison/XVer/XVerProcessorOutcomes.cs index cdaf2f822..ea927fdc0 100644 --- a/src/Fhir.CodeGen.Comparison/XVer/XVerProcessorOutcomes.cs +++ b/src/Fhir.CodeGen.Comparison/XVer/XVerProcessorOutcomes.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.CommandLine; @@ -1697,7 +1697,7 @@ private void writeXverOutcomes( // create a filename for this structure's md file string mdFilename = $"Lookup-{sourcePackage.ShortName}-{sourceStructureName}-{targetPackage.ShortName}.md"; - indexInfo.StructureLookupFiles.Add(new() + indexInfo.ResourceLookupFiles.Add(new() { FileName = mdFilename, FileNameWithoutExtension = mdFilename[..^3], @@ -1793,7 +1793,7 @@ private void writeXverOutcomes( // iterate across each of our targets foreach (XverPackageIndexInfo indexInfo in indexInfos) { - if (indexInfo.StructureLookupFiles.Count == 0) + if (indexInfo.ResourceLookupFiles.Count == 0) { continue; // no files for this package, skip it } @@ -1812,7 +1812,7 @@ private void writeXverOutcomes( indexWriter.WriteLine($"| {sourceVersion} Structure | Lookup File |"); indexWriter.WriteLine("| --------- | ----------- |"); - foreach (XVerIgFileRecord fileRec in indexInfo.StructureLookupFiles) + foreach (XVerIgFileRecord fileRec in indexInfo.ResourceLookupFiles) { indexWriter.WriteLine($"| {fileRec.Name} | [{fileRec.FileNameWithoutExtension}]({fileRec.FileNameWithoutExtension}.html) |"); } From e95fa85837b1f8974bbf3f398ef735e389e29b62 Mon Sep 17 00:00:00 2001 From: Gino Canessa Date: Wed, 27 May 2026 17:31:14 -0500 Subject: [PATCH 14/16] fix(xver): separate structure concept maps Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Exporter/StructureFhirExporter.cs | 25 +- .../CrossVersionArtifactSemanticTests.cs | 444 ++++++++++++++++++ .../Fhir.CodeGen.Lib.Tests.csproj | 1 + 3 files changed, 465 insertions(+), 5 deletions(-) create mode 100644 src/Fhir.CodeGen.Lib.Tests/CrossVersionArtifactSemanticTests.cs diff --git a/src/Fhir.CodeGen.Comparison/Exporter/StructureFhirExporter.cs b/src/Fhir.CodeGen.Comparison/Exporter/StructureFhirExporter.cs index 0100f8e85..507d97188 100644 --- a/src/Fhir.CodeGen.Comparison/Exporter/StructureFhirExporter.cs +++ b/src/Fhir.CodeGen.Comparison/Exporter/StructureFhirExporter.cs @@ -99,6 +99,13 @@ private Dictionary< private static readonly FhirArtifactClassEnum[] _xverSourceArtifactClasses = [FhirArtifactClassEnum.Resource, FhirArtifactClassEnum.ComplexType]; + private static bool shouldExportStructureOutcome( + DbStructureOutcome sdOutcome, + FhirArtifactClassEnum sourceArtifactClass) => + (sdOutcome.SourceArtifactClass == sourceArtifactClass) && + !StructurePageExporter.StructureExportExclusions.Contains(sdOutcome.SourceId) && + !StructurePageExporter.StructureExportExclusions.Contains(sdOutcome.SourceName); + public StructureFhirExporter( XVerExporter exporter, IDbConnection db, @@ -670,6 +677,11 @@ private void exportResourceMaps(XVerIgExportTrackingRecord igTr) // iterate over the outcomes foreach (DbStructureOutcome sdOutcome in sdOutcomes) { + if (!shouldExportStructureOutcome(sdOutcome, FhirArtifactClassEnum.Resource)) + { + continue; + } + // check if we need a new source element if ((currentSourceElement is null) || (lastSourceId != sdOutcome.SourceId)) @@ -876,9 +888,7 @@ private void exportTypeMaps(XVerIgExportTrackingRecord igTr) // iterate over the outcomes foreach (DbStructureOutcome sdOutcome in sdOutcomes) { - if ((sdOutcome.SourceArtifactClass != FhirArtifactClassEnum.ComplexType) || - StructurePageExporter.StructureExportExclusions.Contains(sdOutcome.SourceId) || - StructurePageExporter.StructureExportExclusions.Contains(sdOutcome.SourceName)) + if (!shouldExportStructureOutcome(sdOutcome, FhirArtifactClassEnum.ComplexType)) { continue; } @@ -898,6 +908,11 @@ private void exportTypeMaps(XVerIgExportTrackingRecord igTr) lastSourceId = sdOutcome.SourceId; } + if (sdOutcome.TargetId is null) + { + continue; + } + CMR relationship; if (sdOutcome.IsIdentical || sdOutcome.IsEquivalent) { @@ -919,8 +934,8 @@ private void exportTypeMaps(XVerIgExportTrackingRecord igTr) // create our target element ConceptMap.TargetElementComponent targetElement = new() { - Code = sdOutcome.TargetId ?? "Basic", - Display = sdOutcome.TargetName ?? "Basic", + Code = sdOutcome.TargetId, + Display = sdOutcome.TargetName ?? sdOutcome.TargetId, Relationship = relationship, Comment = sdOutcome.Comments, }; diff --git a/src/Fhir.CodeGen.Lib.Tests/CrossVersionArtifactSemanticTests.cs b/src/Fhir.CodeGen.Lib.Tests/CrossVersionArtifactSemanticTests.cs new file mode 100644 index 000000000..f82cc01d9 --- /dev/null +++ b/src/Fhir.CodeGen.Lib.Tests/CrossVersionArtifactSemanticTests.cs @@ -0,0 +1,444 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// + +using System.Data; +using System.Text.Json; +using Fhir.CodeGen.Common.Models; +using Fhir.CodeGen.Common.Packaging; +using Fhir.CodeGen.Comparison.Exporter; +using Fhir.CodeGen.Comparison.Models; +using Fhir.CodeGen.Lib.Configuration; +using Hl7.Fhir.Model; +using Microsoft.Data.Sqlite; +using Shouldly; + +namespace Fhir.CodeGen.Lib.Tests; + +public class CrossVersionArtifactSemanticTests +{ + [Fact] + public void XVerResourceAndTypeConceptMapsSeparateArtifactClasses() + { + using SemanticFixture fixture = SemanticFixture.Create(); + + List resourceSources = GetConceptMapSourceCodes(fixture.ResourceConceptMapPath); + List typeSources = GetConceptMapSourceCodes(fixture.TypeConceptMapPath); + + resourceSources.ShouldContain("Patient"); + resourceSources.ShouldNotContain("Address"); + typeSources.ShouldContain("Address"); + } + + [Fact] + public void XVerTypeMapRepresentsUnmappedComplexTypesWithoutBasic() + { + using SemanticFixture fixture = SemanticFixture.Create(); + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(fixture.TypeConceptMapPath)); + + JsonElement sourceElement = FindConceptMapSourceElement(document, "UnmappedType"); + + if (sourceElement.TryGetProperty("target", out JsonElement targets)) + { + targets.EnumerateArray() + .Select(target => target.GetProperty("code").GetString()) + .ShouldNotContain("Basic"); + } + } + + [Fact] + public void XVerResourceNoMapStillUsesBasic() + { + using SemanticFixture fixture = SemanticFixture.Create(); + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(fixture.ResourceConceptMapPath)); + + JsonElement sourceElement = FindConceptMapSourceElement(document, "UnmappedResource"); + + sourceElement.GetProperty("target") + .EnumerateArray() + .Select(target => target.GetProperty("code").GetString()) + .ShouldContain("Basic"); + } + + private static List GetConceptMapSourceCodes(string path) + { + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(path)); + List sourceCodes = []; + + foreach (JsonElement group in document.RootElement.GetProperty("group").EnumerateArray()) + { + foreach (JsonElement element in group.GetProperty("element").EnumerateArray()) + { + sourceCodes.Add(element.GetProperty("code").GetString()!); + } + } + + return sourceCodes; + } + + private static JsonElement FindConceptMapSourceElement(JsonDocument document, string sourceCode) + { + foreach (JsonElement group in document.RootElement.GetProperty("group").EnumerateArray()) + { + foreach (JsonElement element in group.GetProperty("element").EnumerateArray()) + { + if (element.GetProperty("code").GetString() == sourceCode) + { + return element; + } + } + } + + throw new ShouldAssertException($"ConceptMap source element `{sourceCode}` was not found."); + } + + private sealed class SemanticFixture : IDisposable + { + private const string SourceShortName = "R4"; + private const string TargetShortName = "R5"; + private readonly SqliteConnection _connection; + + private SemanticFixture(SqliteConnection connection, string outputRoot) + { + _connection = connection; + OutputRoot = outputRoot; + } + + public string OutputRoot { get; } + + public string PackageRoot => Path.Combine(OutputRoot, "hl7.fhir.uv.xver-r4.r5"); + + public string ResourceDirectory => Path.Combine(PackageRoot, "input", "resources"); + + public string ResourceConceptMapPath => Path.Combine(ResourceDirectory, $"{SourceShortName}-resource-map-to-{TargetShortName}.json"); + + public string TypeConceptMapPath => Path.Combine(ResourceDirectory, $"{SourceShortName}-type-map-to-{TargetShortName}.json"); + + public static SemanticFixture Create() + { + SqliteConnection connection = new("Data Source=:memory:"); + connection.Open(); + + DbContentClasses.CreateTables(connection); + DbComparisonClasses.CreateTables(connection); + DbOutcomeClasses.CreateTables(connection); + + string outputRoot = Path.Combine(Path.GetTempPath(), $"fhir-codegen-xver-semantic-{Guid.NewGuid():N}"); + Directory.CreateDirectory(outputRoot); + + SeedDatabase(connection); + + ConfigXVer config = new() + { + OutputDirectory = outputRoot, + XverArtifactVersion = "0.1.0-test", + }; + + XVerExporter exporter = new(connection, config); + exporter.Export( + includeIgScripts: false, + processVocabulary: true, + processStructures: true, + specificPairs: [(FhirReleases.FhirSequenceCodes.R4, FhirReleases.FhirSequenceCodes.R5)]); + + return new(connection, outputRoot); + } + + public void Dispose() + { + _connection.Dispose(); + + if (Directory.Exists(OutputRoot)) + { + Directory.Delete(OutputRoot, recursive: true); + } + } + + private static void SeedDatabase(IDbConnection connection) + { + DbFhirPackage sourcePackage = InsertPackage( + connection, + FhirReleases.FhirSequenceCodes.R4, + SourceShortName, + "hl7.fhir.r4.core", + "4.0.1"); + DbFhirPackage targetPackage = InsertPackage( + connection, + FhirReleases.FhirSequenceCodes.R5, + TargetShortName, + "hl7.fhir.r5.core", + "5.0.0"); + + DbStructureDefinition sourcePatient = InsertStructure(connection, sourcePackage, "Patient", FhirArtifactClassEnum.Resource); + DbStructureDefinition sourceAddress = InsertStructure(connection, sourcePackage, "Address", FhirArtifactClassEnum.ComplexType); + DbStructureDefinition sourceUnmappedType = InsertStructure(connection, sourcePackage, "UnmappedType", FhirArtifactClassEnum.ComplexType); + DbStructureDefinition sourceUnmappedResource = InsertStructure(connection, sourcePackage, "UnmappedResource", FhirArtifactClassEnum.Resource); + + InsertRootElement(connection, sourcePackage, sourcePatient); + InsertRootElement(connection, sourcePackage, sourceAddress); + InsertRootElement(connection, sourcePackage, sourceUnmappedType); + InsertRootElement(connection, sourcePackage, sourceUnmappedResource); + + DbStructureDefinition targetPatient = InsertStructure(connection, targetPackage, "Patient", FhirArtifactClassEnum.Resource); + DbStructureDefinition targetAddress = InsertStructure(connection, targetPackage, "Address", FhirArtifactClassEnum.ComplexType); + DbStructureDefinition targetBasic = InsertStructure(connection, targetPackage, "Basic", FhirArtifactClassEnum.Resource); + DbStructureDefinition targetElement = InsertStructure(connection, targetPackage, "Element", FhirArtifactClassEnum.ComplexType); + DbStructureDefinition targetExtension = InsertStructure(connection, targetPackage, "Extension", FhirArtifactClassEnum.ComplexType); + + InsertRootElement(connection, targetPackage, targetPatient); + InsertUrlElement(connection, targetPackage, targetPatient); + InsertRootElement(connection, targetPackage, targetAddress); + InsertRootElement(connection, targetPackage, targetBasic); + InsertUrlElement(connection, targetPackage, targetBasic); + InsertRootElement(connection, targetPackage, targetElement); + InsertRootElement(connection, targetPackage, targetExtension); + DbElement extensionValue = InsertElement(connection, targetPackage, targetExtension, "Extension.value[x]", "value[x]", 1, isChoiceType: true); + InsertElementType(connection, targetPackage, targetExtension, extensionValue, "string", null); + + InsertStructureOutcome(connection, sourcePackage, targetPackage, sourcePatient, targetPatient); + InsertStructureOutcome(connection, sourcePackage, targetPackage, sourceAddress, targetAddress); + InsertStructureOutcome(connection, sourcePackage, targetPackage, sourceUnmappedType, null); + InsertStructureOutcome(connection, sourcePackage, targetPackage, sourceUnmappedResource, null); + } + + private static DbFhirPackage InsertPackage( + IDbConnection connection, + FhirReleases.FhirSequenceCodes sequence, + string shortName, + string packageId, + string packageVersion) + { + DbFhirPackage package = new() + { + Name = $"FHIR {shortName}", + PackageId = packageId, + PackageVersion = packageVersion, + FhirVersionShort = sequence.ToShortVersion(), + CanonicalUrl = "http://hl7.org/fhir", + ShortName = shortName, + Dependencies = null, + DefinitionFhirSequence = sequence, + }; + + package.Key = DbFhirPackage.Insert(connection, package); + return package; + } + + private static DbStructureDefinition InsertStructure( + IDbConnection connection, + DbFhirPackage package, + string id, + FhirArtifactClassEnum artifactClass) + { + string unversionedUrl = $"http://hl7.org/fhir/StructureDefinition/{id}"; + DbStructureDefinition structure = new() + { + FhirPackageKey = package.Key, + Id = id, + VersionedUrl = $"{unversionedUrl}|{package.PackageVersion}", + UnversionedUrl = unversionedUrl, + Name = id, + Version = package.PackageVersion, + VersionAlgorithmString = null, + VersionAlgorithmCoding = null, + Status = PublicationStatus.Active, + Title = id, + Description = null, + Purpose = null, + Narrative = null, + StandardStatus = null, + WorkGroup = "fhir", + FhirMaturity = null, + IsExperimental = false, + LastChangedDate = null, + Publisher = "HL7", + Copyright = null, + CopyrightLabel = null, + ApprovalDate = null, + LastReviewDate = null, + EffectivePeriodStart = null, + EffectivePeriodEnd = null, + Topic = [], + RelatedArtifacts = [], + Jurisdictions = [], + UseContexts = [], + Contacts = [], + Authors = [], + Editors = [], + Reviewers = [], + Endorsers = [], + RootExtensions = [], + SourcePackageMoniker = null, + Comment = null, + Message = null, + ArtifactClass = artifactClass, + SnapshotCount = 1, + DifferentialCount = 0, + Implements = null, + }; + + structure.Key = DbStructureDefinition.Insert(connection, structure); + return structure; + } + + private static DbElement InsertRootElement( + IDbConnection connection, + DbFhirPackage package, + DbStructureDefinition structure) => + InsertElement(connection, package, structure, structure.Id, structure.Name, 0); + + private static DbElement InsertUrlElement( + IDbConnection connection, + DbFhirPackage package, + DbStructureDefinition structure) => + InsertElement(connection, package, structure, $"{structure.Id}.url", "url", 1, typeLiteral: "uri"); + + private static DbElement InsertElement( + IDbConnection connection, + DbFhirPackage package, + DbStructureDefinition structure, + string id, + string name, + int order, + string typeLiteral = "", + bool isChoiceType = false) + { + DbElement element = new() + { + FhirPackageKey = package.Key, + StructureKey = structure.Key, + ParentElementKey = null, + ResourceFieldOrder = order, + ComponentFieldOrder = order, + Id = id, + Path = id, + ChildElementCount = 0, + Name = name, + Short = null, + Definition = null, + Comments = null, + Requirements = null, + MinCardinality = 0, + MaxCardinality = 1, + MaxCardinalityString = "1", + SliceName = null, + FullCollatedTypeLiteral = typeLiteral, + DistinctTypeCount = string.IsNullOrEmpty(typeLiteral) ? 0 : 1, + DistinctTypeLiterals = typeLiteral, + ValueSetBindingStrength = null, + BindingValueSet = null, + BindingValueSetKey = null, + AdditionalBindingCount = 0, + BindingDescription = null, + IsInherited = false, + BasePath = null, + BaseElementKey = null, + BaseStructureKey = null, + DefinedAsContentReference = false, + ContentReferenceSourceKey = null, + ContentReferenceSourceId = null, + UsedAsContentReference = false, + IsSimpleType = false, + IsChoiceType = isChoiceType, + IsModifier = false, + IsModifierReason = null, + IsDeprecated = false, + }; + + element.Key = DbElement.Insert(connection, element); + return element; + } + + private static void InsertElementType( + IDbConnection connection, + DbFhirPackage package, + DbStructureDefinition structure, + DbElement element, + string typeName, + DbStructureDefinition? typeStructure) + { + DbElementType elementType = new() + { + FhirPackageKey = package.Key, + StructureKey = structure.Key, + ElementKey = element.Key, + TypeName = typeName, + TypeStructureKey = typeStructure?.Key, + TypeProfile = null, + TypeProfileStructureKey = null, + TargetProfile = null, + TargetProfileStructureKey = null, + }; + + elementType.Key = DbElementType.Insert(connection, elementType); + } + + private static void InsertStructureOutcome( + IDbConnection connection, + DbFhirPackage sourcePackage, + DbFhirPackage targetPackage, + DbStructureDefinition sourceStructure, + DbStructureDefinition? targetStructure) + { + string targetSuffix = targetStructure?.Id ?? "NoMap"; + string profileId = $"{sourcePackage.ShortName.ToLowerInvariant()}-{sourceStructure.Id.ToLowerInvariant()}-to-{targetPackage.ShortName.ToLowerInvariant()}-{targetSuffix.ToLowerInvariant()}"; + string elementMapId = $"{sourcePackage.ShortName}-{sourceStructure.Id}-elements-for-{targetPackage.ShortName}-{targetSuffix}"; + bool hasTarget = targetStructure is not null; + + DbStructureOutcome outcome = new() + { + SourceFhirPackageKey = sourcePackage.Key, + SourceFhirSequence = sourcePackage.DefinitionFhirSequence, + TargetFhirPackageKey = targetPackage.Key, + TargetFhirSequence = targetPackage.DefinitionFhirSequence, + RequiresXVerDefinition = true, + TotalTargetCount = hasTarget ? 1 : 0, + TotalSourceCount = 1, + IsRenamed = false, + IsUnmapped = !hasTarget, + IsIdentical = hasTarget && (sourceStructure.Id == targetStructure!.Id), + IsEquivalent = hasTarget && (sourceStructure.Id != targetStructure!.Id), + IsBroaderThanTarget = false, + IsNarrowerThanTarget = false, + FullyMapsToThisTarget = hasTarget, + FullyMapsAcrossAllTargets = hasTarget, + Comments = hasTarget ? "Equivalent test mapping" : "No direct target mapping", + GenLongId = profileId, + GenShortId = profileId, + GenUrl = $"http://hl7.org/fhir/uv/xver/StructureDefinition/{profileId}", + GenName = ToPascalName(profileId), + GenFileName = $"StructureDefinition-{profileId}", + GenArtifactShort = null, + GenArtifactDescription = null, + GenArtifactComment = null, + GenMappingComment = null, + SourceCanonicalVersioned = sourceStructure.VersionedUrl, + SourceCanonicalUnversioned = sourceStructure.UnversionedUrl, + SourceId = sourceStructure.Id, + SourceName = sourceStructure.Name, + SourceVersion = sourceStructure.Version, + TargetCanonicalVersioned = targetStructure?.VersionedUrl, + TargetCanonicalUnversioned = targetStructure?.UnversionedUrl, + TargetId = targetStructure?.Id, + TargetName = targetStructure?.Name, + TargetVersion = targetStructure?.Version, + StructureComparisonKey = null, + SourceStructureKey = sourceStructure.Key, + SourceArtifactClass = sourceStructure.ArtifactClass, + TargetStructureKey = targetStructure?.Key, + TargetArtifactClass = targetStructure?.ArtifactClass, + ElementConceptMapLongId = elementMapId, + ElementConceptMapShortId = elementMapId, + ElementConceptMapUrl = $"http://hl7.org/fhir/uv/xver/ConceptMap/{elementMapId}", + ElementConceptMapName = ToPascalName(elementMapId), + ElementConceptMapFileName = elementMapId, + }; + + outcome.Key = DbStructureOutcome.Insert(connection, outcome); + } + + private static string ToPascalName(string value) => + string.Concat(value.Split('-', StringSplitOptions.RemoveEmptyEntries).Select(part => part[..1].ToUpperInvariant() + part[1..])); + } +} diff --git a/src/Fhir.CodeGen.Lib.Tests/Fhir.CodeGen.Lib.Tests.csproj b/src/Fhir.CodeGen.Lib.Tests/Fhir.CodeGen.Lib.Tests.csproj index 7715ef8ae..7050b1358 100644 --- a/src/Fhir.CodeGen.Lib.Tests/Fhir.CodeGen.Lib.Tests.csproj +++ b/src/Fhir.CodeGen.Lib.Tests/Fhir.CodeGen.Lib.Tests.csproj @@ -28,6 +28,7 @@ + From 42fb09567f9857ebb4f0e4aa94389bdf0c8dc20f Mon Sep 17 00:00:00 2001 From: Gino Canessa Date: Wed, 27 May 2026 17:35:06 -0500 Subject: [PATCH 15/16] fix(xver): emit complex type profiles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Exporter/StructureFhirExporter.cs | 175 ++++++++++++++---- .../CrossVersionArtifactSemanticTests.cs | 45 +++++ 2 files changed, 183 insertions(+), 37 deletions(-) diff --git a/src/Fhir.CodeGen.Comparison/Exporter/StructureFhirExporter.cs b/src/Fhir.CodeGen.Comparison/Exporter/StructureFhirExporter.cs index 507d97188..76d7c49ef 100644 --- a/src/Fhir.CodeGen.Comparison/Exporter/StructureFhirExporter.cs +++ b/src/Fhir.CodeGen.Comparison/Exporter/StructureFhirExporter.cs @@ -106,6 +106,21 @@ private static bool shouldExportStructureOutcome( !StructurePageExporter.StructureExportExclusions.Contains(sdOutcome.SourceId) && !StructurePageExporter.StructureExportExclusions.Contains(sdOutcome.SourceName); + private static bool hasDirectTargetForSource(DbStructureOutcome sdOutcome) + { + if (sdOutcome.TargetId is null) + { + return false; + } + + if (sdOutcome.SourceArtifactClass == FhirArtifactClassEnum.ComplexType) + { + return sdOutcome.TargetArtifactClass == FhirArtifactClassEnum.ComplexType; + } + + return true; + } + public StructureFhirExporter( XVerExporter exporter, IDbConnection db, @@ -189,29 +204,46 @@ public void Export(XVerExportTrackingRecord tr) foreach (DbStructureOutcome sdOutcome in structureOutcomes) { - string sdTargetName = sdOutcome.TargetName ?? "Basic"; - string sdTargetUrl = sdOutcome.TargetCanonicalUnversioned ?? "http://hl7.org/fhir/StructureDefinition/Basic"; + bool hasDirectTarget = hasDirectTargetForSource(sdOutcome); + string? sdTargetName = hasDirectTarget + ? sdOutcome.TargetName ?? sdOutcome.TargetId + : null; + string? sdTargetUrl = hasDirectTarget + ? sdOutcome.TargetCanonicalUnversioned + : null; - if (!structureMappingTracker.TargetStructuresByName.TryGetValue(sdOutcome.SourceName, out List? sdNameTargets)) + if (sdOutcome.SourceArtifactClass == FhirArtifactClassEnum.Resource) { - sdNameTargets = []; - structureMappingTracker.TargetStructuresByName[sdOutcome.SourceName] = sdNameTargets; + sdTargetName ??= "Basic"; + sdTargetUrl ??= "http://hl7.org/fhir/StructureDefinition/Basic"; } - if (!sdNameTargets.Contains(sdTargetName)) + if (sdTargetName is not null) { - sdNameTargets.Add(sdTargetName); - } + if (!structureMappingTracker.TargetStructuresByName.TryGetValue(sdOutcome.SourceName, out List? sdNameTargets)) + { + sdNameTargets = []; + structureMappingTracker.TargetStructuresByName[sdOutcome.SourceName] = sdNameTargets; + } - if (!structureMappingTracker.TargetStructuresByUrl.TryGetValue(sdOutcome.SourceCanonicalUnversioned, out List? sdUrlTargets)) - { - sdUrlTargets = []; - structureMappingTracker.TargetStructuresByName[sdOutcome.SourceCanonicalUnversioned] = sdUrlTargets; + if (!sdNameTargets.Contains(sdTargetName)) + { + sdNameTargets.Add(sdTargetName); + } } - if (!sdUrlTargets.Contains(sdTargetUrl)) + if (sdTargetUrl is not null) { - sdUrlTargets.Add(sdTargetUrl); + if (!structureMappingTracker.TargetStructuresByUrl.TryGetValue(sdOutcome.SourceCanonicalUnversioned, out List? sdUrlTargets)) + { + sdUrlTargets = []; + structureMappingTracker.TargetStructuresByName[sdOutcome.SourceCanonicalUnversioned] = sdUrlTargets; + } + + if (!sdUrlTargets.Contains(sdTargetUrl)) + { + sdUrlTargets.Add(sdTargetUrl); + } } string profileTarget = sdOutcome.GenUrl!; @@ -609,7 +641,13 @@ private ConceptMap createElementConceptMap( XVerIgExportTrackingRecord igTr, DbStructureOutcome sdOutcome) { - string targetId = sdOutcome.TargetId ?? "Basic"; + string? directTargetId = hasDirectTargetForSource(sdOutcome) + ? sdOutcome.TargetId + : null; + string targetId = directTargetId + ?? (sdOutcome.SourceArtifactClass == FhirArtifactClassEnum.Resource + ? "Basic" + : "no direct target type"); string targetVersion = sdOutcome.TargetVersion ?? igTr.PackagePair.TargetPackage.PackageVersion; (_, string name) = igTr.GetName(sdOutcome.ElementConceptMapName!, sdOutcome.ElementConceptMapLongId!); @@ -1048,13 +1086,10 @@ private void exportProfiles(XVerIgExportTrackingRecord igTr) // iterate over the outcomes and create profiles for each target foreach (DbStructureOutcome sdOutcome in sdOutcomes) { - // resolve the target structure (if any) - DbStructureDefinition? targetSd = sdOutcome.TargetStructureKey is null - ? null - : DbStructureDefinition.SelectSingle( - _db, - FhirPackageKey: igTr.PackagePair.TargetPackageKey, - Key: sdOutcome.TargetStructureKey.Value); + DbStructureDefinition targetSd = resolveProfileTargetSd( + igTr, + sourceSd, + sdOutcome); // build the initial structure definition for the extension StructureDefinition profileSd = createProfileSd( @@ -1129,10 +1164,74 @@ private void exportProfiles(XVerIgExportTrackingRecord igTr) igTr.ProfileFiles = exported; } + private DbStructureDefinition resolveProfileTargetSd( + XVerIgExportTrackingRecord igTr, + DbStructureDefinition sourceSd, + DbStructureOutcome sdOutcome) + { + DbStructureDefinition? targetSd = sdOutcome.TargetStructureKey is null + ? null + : DbStructureDefinition.SelectSingle( + _db, + FhirPackageKey: igTr.PackagePair.TargetPackageKey, + Key: sdOutcome.TargetStructureKey.Value); + + if (sdOutcome.SourceArtifactClass == FhirArtifactClassEnum.Resource) + { + return targetSd ?? resolveRequiredProfileBase( + igTr, + "Basic", + FhirArtifactClassEnum.Resource, + sourceSd); + } + + if (sdOutcome.SourceArtifactClass == FhirArtifactClassEnum.ComplexType) + { + if (targetSd?.ArtifactClass == FhirArtifactClassEnum.ComplexType) + { + return targetSd; + } + + return resolveRequiredProfileBase( + igTr, + "Element", + FhirArtifactClassEnum.ComplexType, + sourceSd); + } + + if (targetSd is not null) + { + return targetSd; + } + + throw new Exception($"No target profile base could be resolved for `{sourceSd.Name}` in `{igTr.PackageId}`."); + } + + private DbStructureDefinition resolveRequiredProfileBase( + XVerIgExportTrackingRecord igTr, + string targetName, + FhirArtifactClassEnum artifactClass, + DbStructureDefinition sourceSd) + { + DbStructureDefinition? targetSd = DbStructureDefinition.SelectSingle( + _db, + FhirPackageKey: igTr.PackagePair.TargetPackageKey, + Name: targetName, + ArtifactClass: artifactClass); + + if (targetSd is null) + { + throw new Exception( + $"Required `{targetName}` {artifactClass} profile base was not found in target package `{igTr.PackagePair.TargetPackage.PackageId}` for source `{sourceSd.Name}`."); + } + + return targetSd; + } + private void addContentForMappedProfile( XVerIgExportTrackingRecord igTr, DbStructureDefinition sourceSd, - DbStructureDefinition? targetSd, + DbStructureDefinition targetSd, DbStructureOutcome sdOutcome, StructureDefinition profileSd) { @@ -1149,13 +1248,7 @@ private void addContentForMappedProfile( .ToLookup(x => x.Context, x => x.Outcome); // we need to traverse the elements in the order of the target structure - List targetElements = targetSd is null - ? DbElement.SelectList( - _db, - FhirPackageKey: igTr.PackagePair.TargetPackageKey, - Id: "Basic", - orderByProperties: [nameof(DbElement.ResourceFieldOrder)]) - : DbElement.SelectList( + List targetElements = DbElement.SelectList( _db, StructureKey: targetSd.Key, orderByProperties: [nameof(DbElement.ResourceFieldOrder)]); @@ -1832,11 +1925,19 @@ private void addContentForBasicProfile( private StructureDefinition createProfileSd( XVerIgExportTrackingRecord igTr, DbStructureDefinition sourceSd, - DbStructureDefinition? targetSd, + DbStructureDefinition targetSd, DbStructureOutcome sdOutcome) { - string targetStructureName = targetSd?.Name ?? "Basic"; + string targetStructureName = targetSd.Name; string profileId = sdOutcome.GenShortId!; + bool isResourceProfile = sdOutcome.SourceArtifactClass == FhirArtifactClassEnum.Resource; + StructureDefinition.StructureDefinitionKind profileKind = isResourceProfile + ? StructureDefinition.StructureDefinitionKind.Resource + : StructureDefinition.StructureDefinitionKind.ComplexType; + string sourceArtifactLabel = isResourceProfile ? "resource" : "complex type"; + string representationLabel = isResourceProfile + ? $"via FHIR {igTr.PackagePair.TargetFhirSequence} {targetStructureName} resources." + : $"via a FHIR {igTr.PackagePair.TargetFhirSequence} {targetStructureName} complex-type profile."; (_, string name) = igTr.GetName(sdOutcome.GenName!, profileId); @@ -1848,16 +1949,16 @@ private StructureDefinition createProfileSd( Version = _exporter._crossDefinitionVersion, FhirVersion = EnumUtility.ParseLiteral(igTr.PackagePair.TargetPackage.PackageVersion) ?? FHIRVersion.N5_0_0, DateElement = new FhirDateTime(DateTimeOffset.Now), - Title = $"Cross-version Profile for {igTr.PackagePair.SourceFhirSequence}.{sourceSd.Name} for use in FHIR {igTr.PackagePair.TargetFhirSequence}", + Title = $"Cross-version Profile for {igTr.PackagePair.SourceFhirSequence}.{sourceSd.Name} {sourceArtifactLabel} for use in FHIR {igTr.PackagePair.TargetFhirSequence}", Description = $"This cross-version profile allows" + - $" {igTr.PackagePair.SourceFhirSequence} {sourceSd.Name} content to be represented" + - $" via FHIR {igTr.PackagePair.TargetFhirSequence} {targetStructureName} resources.", + $" {igTr.PackagePair.SourceFhirSequence} {sourceSd.Name} {sourceArtifactLabel} content to be represented " + + representationLabel, Status = PublicationStatus.Active, Experimental = false, - Kind = StructureDefinition.StructureDefinitionKind.Resource, + Kind = profileKind, Abstract = false, Type = targetStructureName, - BaseDefinition = $"http://hl7.org/fhir/StructureDefinition/{targetStructureName}", + BaseDefinition = targetSd.UnversionedUrl, Derivation = StructureDefinition.TypeDerivationRule.Constraint, Differential = [], }; diff --git a/src/Fhir.CodeGen.Lib.Tests/CrossVersionArtifactSemanticTests.cs b/src/Fhir.CodeGen.Lib.Tests/CrossVersionArtifactSemanticTests.cs index f82cc01d9..720126f3d 100644 --- a/src/Fhir.CodeGen.Lib.Tests/CrossVersionArtifactSemanticTests.cs +++ b/src/Fhir.CodeGen.Lib.Tests/CrossVersionArtifactSemanticTests.cs @@ -61,6 +61,45 @@ public void XVerResourceNoMapStillUsesBasic() .ShouldContain("Basic"); } + [Fact] + public void XVerComplexTypeProfilesUseComplexTypeKind() + { + using SemanticFixture fixture = SemanticFixture.Create(); + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(fixture.AddressProfilePath)); + + document.RootElement.GetProperty("kind").GetString().ShouldBe("complex-type"); + document.RootElement.GetProperty("type").GetString().ShouldBe("Address"); + document.RootElement.GetProperty("baseDefinition").GetString().ShouldEndWith("/Address"); + } + + [Fact] + public void XVerUnmappedComplexTypeProfileDoesNotUseBasic() + { + using SemanticFixture fixture = SemanticFixture.Create(); + string profileJson = File.ReadAllText(fixture.UnmappedTypeProfilePath); + using JsonDocument document = JsonDocument.Parse(profileJson); + + document.RootElement.GetProperty("kind").GetString().ShouldBe("complex-type"); + document.RootElement.GetProperty("type").GetString().ShouldBe("Element"); + document.RootElement.GetProperty("baseDefinition").GetString().ShouldEndWith("/Element"); + profileJson.ShouldNotContain("StructureDefinition/Basic"); + profileJson.ShouldNotContain("\"type\": \"Basic\""); + } + + [Fact] + public void XVerUnmappedComplexTypeElementMapDoesNotTargetBasic() + { + using SemanticFixture fixture = SemanticFixture.Create(); + + if (!File.Exists(fixture.UnmappedTypeElementMapPath)) + { + return; + } + + string elementMapJson = File.ReadAllText(fixture.UnmappedTypeElementMapPath); + elementMapJson.ShouldNotContain("Basic"); + } + private static List GetConceptMapSourceCodes(string path) { using JsonDocument document = JsonDocument.Parse(File.ReadAllText(path)); @@ -115,6 +154,12 @@ private SemanticFixture(SqliteConnection connection, string outputRoot) public string TypeConceptMapPath => Path.Combine(ResourceDirectory, $"{SourceShortName}-type-map-to-{TargetShortName}.json"); + public string AddressProfilePath => Path.Combine(ResourceDirectory, "StructureDefinition-r4-address-to-r5-address.json"); + + public string UnmappedTypeProfilePath => Path.Combine(ResourceDirectory, "StructureDefinition-r4-unmappedtype-to-r5-nomap.json"); + + public string UnmappedTypeElementMapPath => Path.Combine(ResourceDirectory, $"{SourceShortName}-UnmappedType-elements-for-{TargetShortName}-NoMap.json"); + public static SemanticFixture Create() { SqliteConnection connection = new("Data Source=:memory:"); From 703d85d3d49761cb727195df7bf37a8e1316aa5c Mon Sep 17 00:00:00 2001 From: Gino Canessa Date: Wed, 27 May 2026 17:38:03 -0500 Subject: [PATCH 16/16] fix(xver): correct type lookup pages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Exporter/StructurePageExporter.cs | 162 ++++++++++++------ .../CrossVersionArtifactSemanticTests.cs | 43 +++++ 2 files changed, 154 insertions(+), 51 deletions(-) diff --git a/src/Fhir.CodeGen.Comparison/Exporter/StructurePageExporter.cs b/src/Fhir.CodeGen.Comparison/Exporter/StructurePageExporter.cs index 3d876f2f1..41a9cc59a 100644 --- a/src/Fhir.CodeGen.Comparison/Exporter/StructurePageExporter.cs +++ b/src/Fhir.CodeGen.Comparison/Exporter/StructurePageExporter.cs @@ -84,6 +84,29 @@ public void Export(XVerExportTrackingRecord tr) } } + private static string getLookupId(DbStructureOutcome sdOutcome) + { + string id = sdOutcome.GenShortId!; + id = id.StartsWith("profile-", StringComparison.OrdinalIgnoreCase) + ? id["profile-".Length..] + : id.StartsWith("profile", StringComparison.OrdinalIgnoreCase) + ? id["profile".Length..] + : id.StartsWith("prfl-", StringComparison.OrdinalIgnoreCase) + ? id["prfl-".Length..] + : id.StartsWith("prfl", StringComparison.OrdinalIgnoreCase) + ? id["prfl".Length..] + : id; + + return id; + } + + private static bool hasDirectTarget( + DbStructureOutcome sdOutcome, + FhirArtifactClassEnum sourceArtifactClass) => + (sdOutcome.TargetId is not null) && + ((sourceArtifactClass != FhirArtifactClassEnum.ComplexType) || + (sdOutcome.TargetArtifactClass == FhirArtifactClassEnum.ComplexType)); + private void exportLookupPages( XVerIgExportTrackingRecord igTr, FhirArtifactClassEnum sourceArtifactClass, @@ -124,44 +147,72 @@ private void exportLookupPages( continue; } - string id = sdOutcome.GenShortId!; - id = id.StartsWith("profile-", StringComparison.OrdinalIgnoreCase) - ? id["profile-".Length..] - : id.StartsWith("profile", StringComparison.OrdinalIgnoreCase) - ? id["profile".Length..] - : id.StartsWith("prfl-", StringComparison.OrdinalIgnoreCase) - ? id["prfl-".Length..] - : id.StartsWith("prfl", StringComparison.OrdinalIgnoreCase) - ? id["prfl".Length..] - : id; + string id = getLookupId(sdOutcome); + bool isResourceLookup = sourceArtifactClass == FhirArtifactClassEnum.Resource; + string sourceArtifactLabel = isResourceLookup ? "resource" : "complex type"; + string targetArtifactLabel = isResourceLookup ? "resource" : "complex type"; + bool hasDirectTargetStructure = hasDirectTarget(sdOutcome, sourceArtifactClass); + string targetId = hasDirectTargetStructure + ? sdOutcome.TargetId! + : isResourceLookup + ? "Basic" + : "no direct target type"; // create the lookup file string filename = Path.Combine(dir, $"lookup-sd-{id}.md"); using ExportStreamWriter mdWriter = createMarkdownWriter(filename); - string targetId = sdOutcome.TargetId ?? "Basic"; - //string elementCmId = $"ConceptMap-{igTr.PackagePair.SourcePackageShortName}-{sdOutcome.SourceId}-elements-for-{igTr.PackagePair.TargetPackageShortName}-{targetId}"; - //string elementCmId = $"conceptmap-{igTr.PackagePair.SourcePackageShortName}-{sdOutcome.SourceId}-elements-for-{igTr.PackagePair.TargetPackageShortName}-{targetId}"; - // write a header mdWriter.WriteLine( $"### Lookup for [FHIR {igTr.PackagePair.SourceFhirSequence}]({sourceBaseUrl})" + $" [{sdOutcome.SourceName}]({sourceBaseUrl}{sdOutcome.SourceName}.html)" + $" for use in [FHIR {igTr.PackagePair.TargetFhirSequence}]({targetBaseUrl})"); mdWriter.WriteLine(); - mdWriter.WriteLine( - $"The FHIR {igTr.PackagePair.SourceFhirSequence} resource is represented in" + - $" FHIR {igTr.PackagePair.TargetFhirSequence} via the {targetId} resource."); + + if (isResourceLookup) + { + mdWriter.WriteLine( + $"The FHIR {igTr.PackagePair.SourceFhirSequence} resource is represented in" + + $" FHIR {igTr.PackagePair.TargetFhirSequence} via the {targetId} resource."); + } + else if (hasDirectTargetStructure) + { + mdWriter.WriteLine( + $"The FHIR {igTr.PackagePair.SourceFhirSequence} complex type is represented in" + + $" FHIR {igTr.PackagePair.TargetFhirSequence} via the {targetId} complex type."); + } + else + { + mdWriter.WriteLine( + $"The FHIR {igTr.PackagePair.SourceFhirSequence} complex type has no direct target type" + + $" in FHIR {igTr.PackagePair.TargetFhirSequence}; use the generated profile and extension representation instead."); + } mdWriter.WriteLine(); - mdWriter.WriteLine( - $"Note that there is a profile defined to simplify use of this cross-version resource representation:" + - $"[Profile: {id}]({sdOutcome.GenFileName}.html)"); + if (sdOutcome.GenFileName is not null) + { + mdWriter.WriteLine( + $"Note that there is a profile defined to simplify use of this cross-version {sourceArtifactLabel} representation:" + + $"[Profile: {id}]({sdOutcome.GenFileName}.html)"); + } + else + { + mdWriter.WriteLine($"No profile generated for this cross-version {sourceArtifactLabel} representation."); + } mdWriter.WriteLine(); - mdWriter.WriteLine( - $"A computable version of the following element information is available in:" + - $" [{sdOutcome.ElementConceptMapName}]({sdOutcome.ElementConceptMapFileName!}.html)"); + bool hasElementConceptMap = (sdOutcome.ElementConceptMapFileName is not null) && + igTr.ElementMapFiles.Any(file => file.FileNameWithoutExtension == sdOutcome.ElementConceptMapFileName); + if (hasElementConceptMap) + { + mdWriter.WriteLine( + $"A computable version of the following element information is available in:" + + $" [{sdOutcome.ElementConceptMapName}]({sdOutcome.ElementConceptMapFileName}.html)"); + } + else + { + mdWriter.WriteLine($"No element-level ConceptMap was generated for this {sourceArtifactLabel}."); + } mdWriter.WriteLine(); mdWriter.WriteLine($"| Source Element (FHIR {igTr.PackagePair.SourceFhirSequence}) | Target(s) | Comments |"); mdWriter.WriteLine("| -------------- | ---- | -------- |"); @@ -190,7 +241,7 @@ private void exportLookupPages( mdWriter.WriteLine(); mdWriter.WriteLine( $"Note that the FHIR {igTr.PackagePair.SourceFhirSequence} {sdOutcome.SourceId}" + - $" maps to multiple resources in FHIR {igTr.PackagePair.TargetFhirSequence}." + + $" maps to multiple {targetArtifactLabel}s in FHIR {igTr.PackagePair.TargetFhirSequence}." + $" The following table contains the the combined lookup information for reference."); mdWriter.WriteLine(); mdWriter.WriteLine($"| Source Element (FHIR {igTr.PackagePair.SourceFhirSequence}) | Target(s) | Comments |"); @@ -608,26 +659,36 @@ private void exportLookupIndexPage( string filename = Path.Combine(dir, indexFileName); using ExportStreamWriter mdWriter = createMarkdownWriter(filename); + bool isResourceLookup = sourceArtifactClass == FhirArtifactClassEnum.Resource; + string sourceArtifactLabel = isResourceLookup ? "resource" : "complex type"; + string sourceArtifactPlural = isResourceLookup ? "resources" : "complex types"; + string sourceColumnLabel = isResourceLookup ? "Resource" : "Type"; + string targetColumnLabel = isResourceLookup ? "Resource" : "Type"; + List conceptMapFiles = isResourceLookup + ? igTr.ResourceMapFiles + : igTr.TypeMapFiles; + mdWriter.WriteLine($"### FHIR {igTr.PackageId} Cross-Version Artifact Lookup"); mdWriter.WriteLine(); - mdWriter.WriteLine("The following table links to documentation for the source version of FHIR, for implementers to understand if there is an extension for the element they are trying to use."); - mdWriter.WriteLine($"These are structures defined in FHIR {igTr.PackagePair.SourceFhirSequence} (the source package), with applicable usage as mapped into FHIR {igTr.PackagePair.TargetFhirSequence} (the target package)."); + mdWriter.WriteLine($"The following table links to documentation for source FHIR {sourceArtifactPlural}, for implementers to understand cross-version representation details."); + mdWriter.WriteLine($"These are {sourceArtifactPlural} defined in FHIR {igTr.PackagePair.SourceFhirSequence} (the source package), with applicable usage as mapped into FHIR {igTr.PackagePair.TargetFhirSequence} (the target package)."); mdWriter.WriteLine(); - string cmId = $"ConceptMap-{igTr.PackagePair.SourcePackageShortName}-resources-for-{igTr.PackagePair.TargetPackageShortName}"; - string cmName = - $"ConceptMap" + - $"{igTr.PackagePair.SourcePackageShortName.ToPascalCase()}" + - $"ResourcesFor" + - $"{igTr.PackagePair.TargetPackageShortName.ToPascalCase()}"; - - mdWriter.WriteLine( - $"A computable version of the following element information is available in:" + - $" [{cmName}](ConceptMap-{cmId}.html)"); + XVerIgFileRecord? conceptMapFile = conceptMapFiles.FirstOrDefault(); + if (conceptMapFile?.Id is not null) + { + mdWriter.WriteLine( + $"A computable version of the following {sourceArtifactLabel} mapping information is available in:" + + $" [{conceptMapFile.Name}](ConceptMap-{conceptMapFile.Id}.html)"); + } + else + { + mdWriter.WriteLine($"No computable {sourceArtifactLabel} ConceptMap was generated."); + } mdWriter.WriteLine(); mdWriter.WriteLine( - $"| {igTr.PackagePair.SourceFhirSequence} Structure" + - $" | {igTr.PackagePair.TargetFhirSequence} Structure" + + $"| {igTr.PackagePair.SourceFhirSequence} {sourceColumnLabel}" + + $" | {igTr.PackagePair.TargetFhirSequence} {targetColumnLabel}" + $" | Lookup File" + $" | Profile" + $" |"); @@ -650,24 +711,23 @@ private void exportLookupIndexPage( continue; } - string targetId = sdOutcome.TargetId ?? "Basic"; + bool hasDirectTargetStructure = hasDirectTarget(sdOutcome, sourceArtifactClass); + string targetMarkdown = hasDirectTargetStructure + ? $"[{igTr.PackagePair.TargetFhirSequence} {sdOutcome.TargetId}]({targetBaseUrl}{sdOutcome.TargetId}.html)" + : isResourceLookup + ? $"[{igTr.PackagePair.TargetFhirSequence} Basic]({targetBaseUrl}Basic.html)" + : "No direct target type"; + string profileMarkdown = sdOutcome.GenFileName is null + ? "No profile generated" + : $"[XVer Profile: {getLookupId(sdOutcome)}]({sdOutcome.GenFileName}.html)"; - string id = sdOutcome.GenShortId!; - id = id.StartsWith("profile-", StringComparison.OrdinalIgnoreCase) - ? id["profile-".Length..] - : id.StartsWith("profile", StringComparison.OrdinalIgnoreCase) - ? id["profile".Length..] - : id.StartsWith("prfl-", StringComparison.OrdinalIgnoreCase) - ? id["prfl-".Length..] - : id.StartsWith("prfl", StringComparison.OrdinalIgnoreCase) - ? id["prfl".Length..] - : id; + string id = getLookupId(sdOutcome); mdWriter.WriteLine( $"| [{igTr.PackagePair.SourceFhirSequence} {sdOutcome.SourceName}]({sourceBaseUrl}{sdOutcome.SourceId}.html)" + - $" | [{igTr.PackagePair.TargetFhirSequence} {targetId}]({targetBaseUrl}{targetId}.html)" + + $" | {targetMarkdown}" + $" | [XVer Lookup: {id}](lookup-sd-{id}.html)" + - $" | [XVer Profile: {id}](StructureDefinition-{sdOutcome.GenShortId}.html)" + + $" | {profileMarkdown}" + $" |"); } diff --git a/src/Fhir.CodeGen.Lib.Tests/CrossVersionArtifactSemanticTests.cs b/src/Fhir.CodeGen.Lib.Tests/CrossVersionArtifactSemanticTests.cs index 720126f3d..d8f7f45d0 100644 --- a/src/Fhir.CodeGen.Lib.Tests/CrossVersionArtifactSemanticTests.cs +++ b/src/Fhir.CodeGen.Lib.Tests/CrossVersionArtifactSemanticTests.cs @@ -100,6 +100,41 @@ public void XVerUnmappedComplexTypeElementMapDoesNotTargetBasic() elementMapJson.ShouldNotContain("Basic"); } + [Fact] + public void XVerTypeLookupIndexLinksTypeMap() + { + using SemanticFixture fixture = SemanticFixture.Create(); + + string lookupIndex = File.ReadAllText(fixture.TypeLookupIndexPath); + + lookupIndex.ShouldContain("ConceptMap-R4-type-map-to-R5.html"); + lookupIndex.ShouldNotContain("resource-map"); + lookupIndex.ShouldNotContain("ConceptMap-ConceptMap-"); + } + + [Fact] + public void XVerTypeLookupPagesUseTypeWording() + { + using SemanticFixture fixture = SemanticFixture.Create(); + + string lookupPage = File.ReadAllText(fixture.AddressTypeLookupPath); + + lookupPage.ShouldContain("complex type"); + lookupPage.ShouldNotContain("resource is represented"); + } + + [Fact] + public void XVerUnmappedTypeLookupDoesNotLinkBasic() + { + using SemanticFixture fixture = SemanticFixture.Create(); + + string lookupPage = File.ReadAllText(fixture.UnmappedTypeLookupPath); + + lookupPage.ShouldNotContain("Basic.html"); + lookupPage.ShouldNotContain("Basic resource"); + lookupPage.ShouldContain("no direct target type"); + } + private static List GetConceptMapSourceCodes(string path) { using JsonDocument document = JsonDocument.Parse(File.ReadAllText(path)); @@ -150,6 +185,8 @@ private SemanticFixture(SqliteConnection connection, string outputRoot) public string ResourceDirectory => Path.Combine(PackageRoot, "input", "resources"); + public string PageContentDirectory => Path.Combine(PackageRoot, "input", "pagecontent"); + public string ResourceConceptMapPath => Path.Combine(ResourceDirectory, $"{SourceShortName}-resource-map-to-{TargetShortName}.json"); public string TypeConceptMapPath => Path.Combine(ResourceDirectory, $"{SourceShortName}-type-map-to-{TargetShortName}.json"); @@ -160,6 +197,12 @@ private SemanticFixture(SqliteConnection connection, string outputRoot) public string UnmappedTypeElementMapPath => Path.Combine(ResourceDirectory, $"{SourceShortName}-UnmappedType-elements-for-{TargetShortName}-NoMap.json"); + public string TypeLookupIndexPath => Path.Combine(PageContentDirectory, "lookup-sd-types.md"); + + public string AddressTypeLookupPath => Path.Combine(PageContentDirectory, "lookup-sd-r4-address-to-r5-address.md"); + + public string UnmappedTypeLookupPath => Path.Combine(PageContentDirectory, "lookup-sd-r4-unmappedtype-to-r5-nomap.md"); + public static SemanticFixture Create() { SqliteConnection connection = new("Data Source=:memory:");