Repository navigation
Add version-scoped dependency resolution for multi-version IGs - #1641
GinoCanessa wants to merge 57 commits into
Conversation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Leave sushi-config.yaml name as authored and warn when it violates the FHIR name constraint, instead of silently removing non-alphanumerics.
Keep a FHIR JSON value array of nulls when the companion underscore array still has id or extension data, instead of deleting the all-null value array after cleanup.
* Reuse the CodeSystem or ValueSet StructureDefinition across caret rules * Cache rule lookups for id, url, name, and version
cmoesel
left a comment
There was a problem hiding this comment.
Wow. What a pull request! Thanks for the sizable contribution, @GinoCanessa. I apologize for the delay, but it took me a while to wrap my head around it (well, actually, it took me a while to find the time to wrap my head around it!).
This seems to work well, and once I followed it through the debugger, it made sense to me. I might have been able to understand it quicker if the comments were a bit more human-friendly; I've asked you to reword some of the more egregious AI-speak.
I think it would also be helpful if you could include a brief description of the overall approach somewhere (maybe at the top of src/ig/VersionScopes.ts?). That description should include a link to the best documentation available (which I assume to be github.com/HL7/fhir-ig-publisher/multi-version-IGs.md). I'm asking for this so that others who land in this codebase can get an overall understanding more quickly (including future me when I forget all of this stuff). Again, brief is OK, especially if you point to some external documentation -- but I think something would be helpful. If you'd rather add an .md than code comments, that's OK too.
See my individual comments for additional specific feedback.
Last, please also update the PR description so that somewhere near the top it includes some guidance on how to use this feature (since release notes will link there). Even if it's just a link to the docs, that would be helpful. A link to the subscription backport IG that uses this might also be helpful.
Assuming you make more changes based on my review, I'll want to run another regression on this. If you get the chance to run one, then I'll trust yours. But if you don't get a chance, I'll just run it over here.
Thanks again for the excellent PR. This is a signficant new feature and I'm glad that SUSHI will support it (and that I didn't have to implement it!).
| * FSH definition sets one; no URL is invented otherwise, since getUrlFromFshDefinition's fallback | ||
| * would wrongly claim a StructureDefinition URL for every instance. VersionScopes matches a |
There was a problem hiding this comment.
That's a good point -- and a bug. getUrlFromFshDefinition definitely shouldn't be assuming that an instance without a url rule is a StructureDefinition. My (unverified) guess is that when the definition was first defined, it did not accept an Instance as an argument -- and whenever that was added the fallback to StructureDefinition was not adjusted. I've filed #1647 for this bug.
There was a problem hiding this comment.
Perfect - I left getUrlFromFshDefinition alone in this PR and we can come back to it afterwards. This PR is not relying on the fallback, so I assume we are good in the meantime.
| export function artifactScopeKey( | ||
| resourceType: string, | ||
| fshDefinition: Profile | Extension | Logical | Resource | FshValueSet | FshCodeSystem, | ||
| canonical?: string |
There was a problem hiding this comment.
Consider renaming this to canonicalBase (or something like it) to make it clear that it is the canonical base for the IG, not the canonical of the resource this is scoped to.
There was a problem hiding this comment.
Renamed to igCanonicalBase to be clear it is the IG's base and not the artifact's URL.
| const exportedKeys: ArtifactScopeKey[] = [ | ||
| ...this.pkg.profiles, | ||
| ...this.pkg.extensions, | ||
| ...this.pkg.logicals, | ||
| ...this.pkg.resources | ||
| ].map(sd => ({ resourceType: 'StructureDefinition', id: sd.id, url: sd.url })); | ||
| exportedKeys.push( | ||
| ...this.pkg.valueSets.map(vs => ({ resourceType: 'ValueSet', id: vs.id, url: vs.url })), | ||
| ...this.pkg.codeSystems.map(cs => ({ resourceType: 'CodeSystem', id: cs.id, url: cs.url })), | ||
| ...this.pkg.instances.map(instance => ({ | ||
| resourceType: instance.resourceType, | ||
| id: instance.id, | ||
| url: instance.url | ||
| })) | ||
| ); |
There was a problem hiding this comment.
Maybe I am missing something, but shouldn't you be able to map these all at once using the resourceType in the JSON? E.g.,
| const exportedKeys: ArtifactScopeKey[] = [ | |
| ...this.pkg.profiles, | |
| ...this.pkg.extensions, | |
| ...this.pkg.logicals, | |
| ...this.pkg.resources | |
| ].map(sd => ({ resourceType: 'StructureDefinition', id: sd.id, url: sd.url })); | |
| exportedKeys.push( | |
| ...this.pkg.valueSets.map(vs => ({ resourceType: 'ValueSet', id: vs.id, url: vs.url })), | |
| ...this.pkg.codeSystems.map(cs => ({ resourceType: 'CodeSystem', id: cs.id, url: cs.url })), | |
| ...this.pkg.instances.map(instance => ({ | |
| resourceType: instance.resourceType, | |
| id: instance.id, | |
| url: instance.url | |
| })) | |
| ); | |
| const exportedKeys: ArtifactScopeKey[] = [ | |
| ...this.pkg.profiles, | |
| ...this.pkg.extensions, | |
| ...this.pkg.logicals, | |
| ...this.pkg.resources, | |
| ...this.pkg.valueSets, | |
| ...this.pkg.codeSystems, | |
| ...this.pkg.instances | |
| ].map(r => ({ resourceType: r.resourceType, id: r.id, url: r.url })); |
There was a problem hiding this comment.
Well, one of us missed something (hint: it was not you =). Much simpler - added a test that checks the reported type, id, and URL for all seven kinds of artifact.
| const def = info?.resourcePath | ||
| ? this.findResourceJSON(item, { | ||
| type: normalizeTypes(types), | ||
| scope: scopeForInfo(info), | ||
| sort: DEFAULT_SORT | ||
| }) | ||
| : this.findResourceJSON(item, { | ||
| type: normalizeTypes(types), | ||
| sort: DEFAULT_SORT | ||
| }); |
There was a problem hiding this comment.
This is totally subjective, but consider avoiding the multiline ternary by just adding the scope key if necessary. E.g.,
| const def = info?.resourcePath | |
| ? this.findResourceJSON(item, { | |
| type: normalizeTypes(types), | |
| scope: scopeForInfo(info), | |
| sort: DEFAULT_SORT | |
| }) | |
| : this.findResourceJSON(item, { | |
| type: normalizeTypes(types), | |
| sort: DEFAULT_SORT | |
| }); | |
| const options: FindResourceInfoOptions = { | |
| type: normalizeTypes(types), | |
| sort: DEFAULT_SORT | |
| }; | |
| // if there is an active version scope and a match in scope, use that scope | |
| const info = this.rankedScopedResourceInfos(item, types)?.[0]; | |
| if (info?.resourcePath) { | |
| options.scope = scopeForInfo(info); | |
| } | |
| const def = this.findResourceJSON(item, options); |
There was a problem hiding this comment.
Yep, easier to read.
| /** | ||
| * Builds the multi-version resolution scopes from the same dependency coordinates that were | ||
| * attempted for loading, installs them on the definitions, and reports startup diagnostics. | ||
| * Configs with no version-scoped dependency produce an unconfigured model, which leaves fishing | ||
| * on its existing unscoped path. | ||
| */ |
There was a problem hiding this comment.
Is the phrase "dependency coordinates" commonly used? Or is it AI speak? It feels awkward to me. Is there a better way to phrase it?
There was a problem hiding this comment.
It is AI-speak, but I am 60% bot now so I didn't notice. Since it just meant "package id and version", removed "coordinate" and reworded the comment.
/**
* Builds the version scopes from the same dependency list used for loading (each entry's package
* id and requested version), installs them on the FHIR definitions, and logs configuration
* problems. If no dependency uses the ig-dependency-for-version extension, version scoping stays
* off and definitions resolve exactly as they do for a single-version IG; otherwise it also logs
* a startup summary.
*/| // The override package is reachable only through an ig-dependency-for-version extension, so | ||
| // this fails unless the override coordinates were actually loaded. | ||
| // example.base is loaded after example.broad, so before the authored coordinate was demoted it | ||
| // would win the r5 tie on load order. It must now lose to the genuinely broad package. |
There was a problem hiding this comment.
Again, not the most human-friendly description.
There was a problem hiding this comment.
Updated to describe what is actually checked. Also fixed the duplicate line.
| it('resolves a collision to the package named by a per-version override', () => { | ||
| const inScope = scopedDefs.inVersionScopeOf( | ||
| { resourceType: 'StructureDefinition', id: 'r4-artifact' }, | ||
| () => scopedDefs.fishForFHIR('http://example.org/StructureDefinition/override-collision') | ||
| ); | ||
|
|
||
| expect(inScope.packageTag).toBe('override'); | ||
|
|
||
| const outOfScope = scopedDefs.inVersionScopeOf( | ||
| { resourceType: 'StructureDefinition', id: 'r5-artifact' }, | ||
| () => scopedDefs.fishForFHIR('http://example.org/StructureDefinition/override-collision') | ||
| ); | ||
|
|
||
| expect(outOfScope.packageTag).toBe('broad'); | ||
| }); |
There was a problem hiding this comment.
How is this different than the prefers a per-version override over the package it replaces test on lines 1650-1664?
There was a problem hiding this comment.
It wasn't - removed and updated the tests to check positive and negative paths.
| expect(withInstanceType.packageTag).toBe('broad'); | ||
| }); | ||
|
|
||
| it('narrows the frame for a bare-id key that matches a Type/id inclusion entry', () => { |
There was a problem hiding this comment.
I'm not sure what narrows the frame means, so I don't know what this test is really checking. Especially since it seems to be the same as demotes explicitly out-of-version candidates below untagged packages (1716-1723) and the 2nd half of resolves a collision to the package named by a per-version override (1699-1704) and the second half of prefers a per-version override over the package it replaces (1658-1663).
So, yeah... it seems like we're testing the same thing a lot of times.
There was a problem hiding this comment.
Yep, fair. "Frame" was internal language used during development and the only thing different on this test as the input. Renamed the test for clarity and added comments. To close the loop, this is the merged-winner of the two.
| it('uses the fast path when no version frame is active', () => { | ||
| const result = scopedDefs.fishForFHIR('http://example.org/StructureDefinition/collision'); | ||
|
|
||
| expect(result.packageTag).toBe('r4b'); | ||
| }); |
There was a problem hiding this comment.
This test feels weird to me. I guess it's just saying that if you don't call the inVersionScopeOf function that you can still fish for broad packages?
There was a problem hiding this comment.
Close - it checks that when version scoping is configured but no artifact is being exported, lookups use the normal package order.
Testing to make sure the last-loaded package (example.r4b) wins, not the one that's available to every version (example.broad). It guards against scoping leaking into lookups made outside an export. The old "fast path" name also claimed something the test doesn't measure. Renamed and added comments.
| // No .r4b sibling is invented for the generic dependency | ||
| expect(scopes.packageBandFor('r4b', 'example.plain.r4b', '1.0.0')).toBe('broad'); |
There was a problem hiding this comment.
I don't think I understand what is meant by No .r4b sibling is invented.
There was a problem hiding this comment.
It meant SUSHI can't assume an .r4b version of a package exists just because the IG targets R4B. Reworded, and renamed the test.
Flags aren't allowed on only rules, but the error listener was reporting
"Using '|' to list references is no longer supported" for them, because
the check only looked at whether the previous token started with
'Reference('. A complete Reference(Patient) token matched that too.
Now a flag after 'only' gets a message saying flags aren't allowed there
and to put them in their own rule. The '|' check is also narrowed to an
unclosed Reference( so it stops claiming unrelated errors are pipe
problems.
The existing tests mostly covered log levels and a couple of profile conversions, so this adds tests for what the API actually gives back: each entity type it converts, the canonical/version/snapshot options, invariants and mappings, aliases and rulesets shared across inputs, and what happens when one input has errors. Tests only, nothing in src changed.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Thanks for the detailed review (again =). Pushed changes and replied in each thread (I think).
|
Summary
SUSHI treats every loaded external dependency as one flat canonical namespace. That
breaks R5-authored IGs that use the IG Publisher's multi-version package model
to generate R4 and R4B packages from a single source, because such an IG may
declare different dependency packages for different generated FHIR versions, and
those packages may legitimately publish the same canonical URL with
version-specific content. With a flat namespace one package wins globally by load
order, so the other version's artifacts are validated against the wrong definition.
This branch teaches SUSHI to read the multi-version parameters the IG already
carries —
fhirVersion,generate-version, per-version dependency extensions, andthe
r4-inclusion/r4b-inclusion/r5-inclusionmembership lists — and to usethem to rank dependency candidates during fishing.
There is no author-facing FSH change. No new spelling on any canonical, alias,
or
InstanceOf:, and no overload of|(which already means artifact-versionpinning). An IG that declares no version-scoped dependency takes an unchanged code
path.
Addresses Issue #1510
Using this feature
This applies to IGs that are authored in one FHIR version and also generate packages for other FHIR versions (for example, an R5 IG that also publishes R4 and R4B packages), when those versions need different dependency packages. There is no new FSH syntax and no new SUSHI option. SUSHI reads the multi-version settings the IG Publisher already uses, in
sushi-config.yaml:fhirVersion, plus agenerate-versionparameter for each additional version (e.g.,r4,r4b).http://hl7.org/fhir/tools/StructureDefinition/ig-dependency-for-versionextension on a dependency names the package id and version to use for a target version, or removes the dependency from that version (use: remove). A dependency with these extensions only applies to the versions it names.r4-inclusion,r4b-inclusion, andr5-inclusionparameters list the artifacts (Type/idor canonical URL) that belong to each version. Unlisted artifacts belong to every version.SUSHI only changes how it resolves definitions when at least one dependency uses
ig-dependency-for-version. When it does, SUSHI resolves each artifact's references using the packages selected for the FHIR versions that artifact belongs to. Packages selected only for other versions are used only as a last resort. Otherwise, SUSHI behaves exactly as before.sushi-config.yaml(R5 source generating R4 and R4B packages, on the IG'sticketsbranch)Trimmed excerpt from that configuration:
Motivation
The concrete case is the FHIR Subscriptions R5 Backport IG
(
hl7.fhir.uv.subscriptions-backport), which is authored atfhirVersion: 5.0.0and generates R4 and R4B packages.
hl7.fhir.uv.xver-r5.r4andhl7.fhir.uv.xver-r5.r4bboth publishand the two definitions are structurally different: in the R4 package it is
type: Basic(R4 has noSubscriptionStatusresource), and in the R4B package itis
type: SubscriptionStatus(R4B does). The two packages share 1,880same-named artifacts, 11 of which differ in
typein exactly thisBasic-vs-native way.Today SUSHI binds that URL once. Because
DEFAULT_SORT'sbyLoadOrder(false)means last-loaded wins, and the R4B xver package is declared last, the R4
Basicshape the IG actually authors is validated against the R4B
SubscriptionStatusdefinition — and every R4 status instance fails.
This is a SUSHI capability gap rather than an IG authoring problem: the
disambiguating inputs are already in
sushi-config.yaml, and SUSHI reads none ofthem. The rules implemented here are ported from the Publisher's own
multi-version-IGs.md§3–§4 and itsisDepApplicableForVersion/isIncludedInVersionlogic, so SUSHI and the Publisher agree on membership.Design
Three ideas carry the whole change:
src/ig/VersionScopes.tsis a self-contained, logger-freestatement of the Publisher's rules. It is constructed during dependency loading
and is fully unit-testable without fixtures.
FHIRDefinitions.inVersionScopeOf(key, fn)pushesthe target versions for one artifact, runs
fn, and pops in afinally.Exporters wrap their per-artifact entry points; no exporter learns anything
about packages, and public
Fishablesignatures are unchanged.(typeRank, bandRank)instead of filtering them. An out-of-version candidate isstill reachable, so a reference that resolves today cannot become newly
unresolvable just because scope is active.
typeRankis compared beforebandRank, so existing type precedence is untouched.lodash.sortByis stable, so every tie preserves today's FPL order.Changes
Model
src/ig/VersionScopes.ts(new) — target-version derivation, per-versiondependency applicability and overrides, artifact membership from the inclusion
lists, package banding (
in-scope/broad/out-of-version), and structuredconfiguration diagnostics. Accepts every token in the Publisher's family table
(
r4/4.0/4.0.1,r4b/4.3/4.3.0,r5/5.0/5.0.0) and nothing else,so
4.1,4.2,3.0, and6.0are reported rather than silentlyreinterpreted. Reads
parameter.codeas either astringor an R5Coding, soan
ImplementationGuideresource supplied as input keeps its parameters.Resolution
src/fhirdefs/FHIRDefinitions.ts— the frame stack, scoped candidateranking, and one shared scoped-lookup helper used by
fishForFHIR,fishForMetadata, andfishForMetadatas. The no-scope path early-returns beforeany of it.
inVersionScopeOfcarries a compile-timeSyncOnly<T>constraint:the stack is LIFO and pops when
fnreturns, so anasynccallback would let asynchronous sibling pop the wrong frame. That is now a type error, not a latent
ranking bug.
src/utils/MasterFisher.ts— delegatesinVersionScopeOfwith the sameconstraint.
Loading and reporting
src/utils/Processing.ts— builds and installs the model from the samedependency coordinates that were attempted for loading, and loads the packages
named by per-version overrides, which are otherwise ranked
in-scopebutabsent from the package DB. Override loads are guarded against reordering a
coordinate that a later top-level dependency (or FHIR core) also declares.
Emits the model's configuration diagnostics.
Export
src/export/artifactScopeKeys.ts(new) — builds a scope key from FSHmetadata alone, never by fishing, since entering the scope is what makes
fishing correct in the first place. URLs come from the existing
getUrlFromFshDefinition, so a^urlcaret rule wins.src/export/{StructureDefinition,Instance,ValueSet,CodeSystem,Mapping}Exporter.tsand
FHIRExporter.ts— per-artifact scope frames, plus a startup report forinclusion entries that match no exported artifact and for ids listed under
inconsistent type prefixes.
Diagnostics
Unparseable version-scope configuration is now reported instead of silently
widening a dependency to every version. A partly-bad dependency warns; a dependency
whose version extensions all fail to parse is an
errorand landsout-of-versioneverywhere, because that case changes which package wins acollision and must not ship under a zero exit code.
Testing
npm run checkis clean: 106 suites, 3614 tests,tsc && eslintclean,Prettier clean.
New/extended coverage (~1,340 added test lines):
test/ig/VersionScopes.test.ts— the Publisher token table and itsrejections,
Codingparameter codes, membership byType/id/ bare id /canonical URL, package banding, per-version overrides and
remove, thenon-widening all-failed dependency, patch-wildcard (
1.2.x) versions, and theauthored-coordinate demotion.
test/fhirdefs/FHIRDefinitions.test.ts— in-version preference, demotionbelow untagged packages, type rank ahead of band, an unlisted artifact belonging
to every version, an override winning a collision, no re-query after a scoped
miss, and a
@ts-expect-errorcase proving an async callback is rejected atcompile time.
test/utils/Processing.test.ts— override coordinates reachingloadPackage, load-order preservation, and the emitted warn/error diagnostics.test/export/artifactScopeKeys.test.ts(new) — the canonical default, the^urloverride, ValueSet/CodeSystem types, an assigned instance URL, and thedeliberate absence of an invented one.
test/export/*Exporter.test.ts— frame entry and restoration, includingrestoration after a nested export and after a throw.
Regression
npm run regression(gh:mastervs local): 294 repositories processed, 281compared SAME, 0 DIFF. The 13 without a comparison all aborted on
Request failed with status code 504(package-registry gateway timeouts), notoutput differences.
How to verify against a real IG
The backport IG's xver collision produced a large error cluster (~36 direct plus
cascade failures) before this change. With this branch the collision errors are
gone; the errors that remain are IG-side and unrelated to SUSHI
(
SearchParameter.xpathUsageassignments that R5 removed, and resources definedboth in
input/fsh/andinput/resources/).Compatibility & risk
VersionScopesreports itselfunconfigured when no dependency declares a version extension, and
inVersionScopeOfreturnsfn()before touching the stack. The regressionsample is the evidence.
filtered, so the failure mode is "a wrong answer that was already the answer",
not a new "not found".
unscoped path is unchanged and one previously redundant query was removed.
Fishablesignature changes, no fork offhir-package-loader, and no change toFISHING_ORDER,DEFAULT_SORT, or theunscoped path.
per-version override replaces is now absent from that version instead of
remaining broadly available. It is isolated in its own commit (
dd8db92) and canbe reverted alone.
Related
Builds on the multi-version rules in the IG Publisher's
multi-version-IGs.md(
isDepApplicableForVersion,isIncludedInVersion).