diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 99986fabc..000000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -* @jafeltra @cmoesel @mint-thompson \ No newline at end of file diff --git a/DEPENDENCY-NOTES.md b/DEPENDENCY-NOTES.md index 9763e37a5..865b7309b 100644 --- a/DEPENDENCY-NOTES.md +++ b/DEPENDENCY-NOTES.md @@ -1,4 +1,4 @@ -As of 2024 June 17: +As of 2024 August 27: The `npm outdated` command reports some dependencies as outdated. They are not being updated at this time for the reasons given below: diff --git a/README.md b/README.md index f3700cd76..00ac658b3 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,16 @@ For more information about the evolving FSH syntax see the [FHIR Shorthand Refer ## FHIR Foundation Project Statement -* Maintainers: This project is maintained by The MITRE Corporation. +* Maintainers: This project is maintained by the HL7 community. * Issues / Discussion: For SUSHI issues, such as bug reports, comments, suggestions, questions, and feature requests, visit [SUSHI GitHub Issues](https://github.com/FHIR/sushi/issues). For discussion of FHIR Shorthand and its associated projects, visit the FHIR Community Chat @ https://chat.fhir.org. The [#shorthand stream](https://chat.fhir.org/#narrow/stream/215610-shorthand) is used for all FHIR Shorthand questions and discussion. * License: All contributions to this project will be released under the Apache 2.0 License, and a copy of this license can be found in [LICENSE](LICENSE). * Contribution Policy: The SUSHI Contribution Policy can be found in [CONTRIBUTING.md](CONTRIBUTING.md). * Security Information: The SUSHI Security Information can be found in [SECURITY.md](SECURITY.md). * Compliance Information: SUSHI supports creating Implementation Guides for FHIR R4, FHIR R4B, and FHIR R5. While SUSHI performs basic validation to help users author FSH that will produce valid FHIR artifacts, it is not intended to be a full-featured validator. For example, SUSHI does validate paths and cardinalities, but does not validate author-provided FHIRPath expressions, terminological compliance, or slice membership. Authors are encouraged to use a full-featured validator, such as the one found in the IG Publisher, to test their final FHIR outputs. The SUSHI source code includes a comprehensive suite of unit tests to test SUSHI's own behavior and compliance with FHIR, which can be found in the [test](test) directory. -# Installation for SUSHI Users +# SUSHI User Instructions + +## Installation for SUSHI Users SUSHI requires [Node.js](https://nodejs.org/) to be installed on the user's system. Users should install Node.js 18. Although previous versions of Node.js may work, they are not officially supported. @@ -70,13 +72,20 @@ Options: See the [SUSHI documentation](https://fshschool.org/docs/sushi/) for detailed information on using SUSHI. -# IG Generation +## IG Generation SUSHI supports publishing implementation guides via the new template-based IG Publisher. The template-based publisher is still being developed by the FHIR community. See the [Guidance for HL7 IG Creation](https://build.fhir.org/ig/FHIR/ig-guidance/) for more details. Based on the inputs in FSH files, **sushi-config.yaml**, and the IG project directory, SUSHI populates the output directory. See the [documentation on IG Project with SUSHI](https://fshschool.org/docs/sushi/project/#ig-projects) for more information on using SUSHI to generate IGs. -# Installation for Developers +# SUSHI Developer Instructions + +## Intro to SUSHI Development + +To learn more about SUSHI, watch the Knowledge Sharing Sessions for [Developing FSH Tools](https://vimeo.com/990594228/056b5c075f) (view the slides [here](https://confluence.hl7.org/display/FHIR/FSH+Knowledge+Sharing+Sessions?preview=/256509612/256514908/KSS%203%20-%20Developing%20FSH%20Tools.pdf)) and [Developing SUSHI](https://vimeo.com/1001309394/2d69558341) (view the slides [here](https://confluence.hl7.org/display/FHIR/FSH+Knowledge+Sharing+Sessions?preview=/256509612/256517402/KSS%204%20-%20Developing%20SUSHI.pdf)). +These sessions provide a technical overview of the codebase and summarize key concepts for developers. + +## Installation for Developers SUSHI is a [TypeScript](https://www.typescriptlang.org/) project. At a minimum, SUSHI requires [Node.js](https://nodejs.org/) to build, test, and run the CLI. Developers should install Node.js 18. @@ -86,7 +95,7 @@ Once Node.js is installed, run the following command from this project's root fo $ npm install ``` -# NPM tasks +## NPM tasks The following NPM tasks are useful in development: @@ -112,7 +121,7 @@ To run any of these tasks, use `npm run`. For example: $ npm run check ``` -# Regression +## Regression The `regression/cli.ts` script can be used to run regression on a set of repos. It's default command, `run` supports the following options: @@ -159,7 +168,7 @@ The regression script first installs the `-a` and `-b` SUSHIs to temporary folde When the script is complete, it will generate and launch a top-level index file with links to the reports and logs for each repo. -# Recommended Development Environment +## Recommended Development Environment For the best experience, developers should use [Visual Studio Code](https://code.visualstudio.com/) with the following plugins: @@ -175,7 +184,7 @@ For the best experience, developers should use [Visual Studio Code](https://code # License -Copyright 2019-2022 Health Level Seven International +Copyright 2019-2024 Health Level Seven International Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index d61f0975b..3ea6c2563 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,12 +1,12 @@ { "name": "fsh-sushi", - "version": "3.11.1", + "version": "3.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fsh-sushi", - "version": "3.11.1", + "version": "3.12.0", "license": "Apache-2.0", "dependencies": { "ajv": "^8.17.1", diff --git a/package.json b/package.json index 0cf843680..9dba3ebd9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fsh-sushi", - "version": "3.11.1", + "version": "3.12.0", "description": "Sushi Unshortens Short Hand Inputs (FSH Compiler)", "scripts": { "build": "del-cli dist && tsc && copyfiles -u 3 \"src/utils/init-project/*\" dist/utils/init-project", diff --git a/regression/find.ts b/regression/find.ts index 06c6f536f..8d174a983 100644 --- a/regression/find.ts +++ b/regression/find.ts @@ -2,7 +2,7 @@ import axios from 'axios'; import { remove, uniqBy, padEnd } from 'lodash'; import { axiosGet } from '../src/utils/axiosUtils'; -const FSH_FINDER_URL = 'https://fshschool.org/fsh-finder/fshy_repos.json'; +const FSH_FINDER_URL = 'https://fshschool.github.io/fsh-finder/fshy_repos.json'; const BUILD_URL_RE = /^([^/]+)\/([^/]+)\/branches\/([^/]+)\/qa\.json$/; const FSHY_PATHS = ['sushi-config.yaml', 'input/fsh', 'fsh']; const ORGANIZATIONS = [ @@ -27,9 +27,15 @@ const ORGANIZATIONS = [ export async function findReposUsingFSHFinder( options: { count?: number; lookback?: number } = {} ): Promise { - const res = await axiosGet(FSH_FINDER_URL); - const lines = [`# FSH Finder last Updated: ${res?.data?.updated}`]; - let repoData: any[] = res?.data?.repos ?? []; + const lines: string[] = []; + let repoData: any[]; + try { + const res = await axiosGet(FSH_FINDER_URL); + lines.push(`# FSH Finder last Updated: ${res?.data?.updated}`); + repoData = res?.data?.repos ?? []; + } catch (e) { + throw new Error(`Failed to load repo data from ${FSH_FINDER_URL}: ${e.message}`); + } if (options.lookback != null || options.count != null) { lines.push( `# Limited to${options.count ? ` last ${options.count}` : ''} repositories${ diff --git a/src/app.ts b/src/app.ts index a8632b3ea..f08ffe48c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -36,7 +36,7 @@ import { updateConfig } from './utils'; -const FSH_VERSION = '3.0.0-ballot'; +const FSH_VERSION = '3.0.0'; function logUnexpectedError(e: Error) { logger.error(`SUSHI encountered the following unexpected error: ${e.message}`); diff --git a/src/export/ValueSetExporter.ts b/src/export/ValueSetExporter.ts index e27ca9ed7..27ef54633 100644 --- a/src/export/ValueSetExporter.ts +++ b/src/export/ValueSetExporter.ts @@ -76,6 +76,30 @@ export class ValueSetExporter { .replace(/^([^|]+)/, csMetadata?.url ?? '$1') .split('|'); composeElement.system = foundSystem[0]; + // if the code system is also a contained resource, add the valueset-system extension + // this zulip thread contains a discussion of the issue and an example using this extension: + // https://chat.fhir.org/#narrow/stream/215610-shorthand/topic/Contained.20code.20system.20in.20the.20value.20set/near/424938537 + // additionally, if it's not a contained resource, and the system we found is an inline instance, that's a problem + const containedSystem = valueSet.contained?.find((resource: any) => { + return resource?.id === csMetadata?.id && resource.resourceType === 'CodeSystem'; + }); + if (containedSystem != null) { + composeElement._system = { + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/valueset-system', + valueCanonical: `#${csMetadata.id}` + } + ] + }; + } else if (csMetadata?.instanceUsage === 'Inline') { + logger.error( + `Can not reference CodeSystem ${component.from.system}: this CodeSystem is an inline instance, but it is not present in the list of contained resources.`, + component.sourceInfo + ); + return; + } + // if the rule specified a version, use that version. composeElement.version = systemParts.slice(1).join('|') || undefined; if (!isUri(composeElement.system)) { @@ -86,6 +110,14 @@ export class ValueSetExporter { composeElement.valueSet = component.from.valueSets.map(vs => { return this.fisher.fishForMetadata(vs, Type.ValueSet)?.url ?? vs; }); + composeElement.valueSet = composeElement.valueSet.filter(vs => { + if (vs == valueSet.url) { + logger.error( + `Value set with id ${valueSet.id} has component rule with self-referencing value set (by id, value set name, or url). Removing self-reference.` + ); + } + return vs != valueSet.url; + }); composeElement.valueSet.forEach(vs => { // Canonical URI may include | to specify version: https://www.hl7.org/fhir/references.html#canonical if (!isUri(vs.split('|')[0])) { @@ -176,7 +208,9 @@ export class ValueSetExporter { this.addConceptComposeElement(composeElement, valueSet.compose.include); } } else { - valueSet.compose.include.push(composeElement); + if (composeElement.valueSet?.length !== 0 || composeElement.system != undefined) { + valueSet.compose.include.push(composeElement); + } } } else { this.addConceptComposeElement(composeElement, valueSet.compose.exclude); diff --git a/src/fhirtypes/ValueSet.ts b/src/fhirtypes/ValueSet.ts index 57305788f..555b7d9e7 100644 --- a/src/fhirtypes/ValueSet.ts +++ b/src/fhirtypes/ValueSet.ts @@ -1,7 +1,14 @@ import sanitize from 'sanitize-filename'; import { Meta } from './specialTypes'; -import { Extension } from '../fshtypes'; -import { Narrative, Resource, Identifier, CodeableConcept, Coding } from './dataTypes'; +import { + Narrative, + Resource, + Identifier, + CodeableConcept, + Coding, + Extension, + Element +} from './dataTypes'; import { ContactDetail, UsageContext } from './metaDataTypes'; import { HasName, HasId } from './mixins'; import { applyMixins } from '../utils/Mixin'; @@ -83,6 +90,7 @@ export type ValueSetCompose = { export type ValueSetComposeIncludeOrExclude = { system?: string; + _system?: Element; version?: string; valueSet?: string[]; concept?: ValueSetComposeConcept[]; diff --git a/src/fshtypes/FshCodeSystem.ts b/src/fshtypes/FshCodeSystem.ts index f6a6e5200..690f8ed1a 100644 --- a/src/fshtypes/FshCodeSystem.ts +++ b/src/fshtypes/FshCodeSystem.ts @@ -25,8 +25,8 @@ export class FshCodeSystem extends FshEntity { get id() { const assignedId = getNonInstanceValueFromRules(this, 'id', '', 'id'); - if (assignedId) { - return assignedId.toString(); + if (typeof assignedId === 'string') { + return assignedId; } return this._id; } diff --git a/src/fshtypes/FshStructure.ts b/src/fshtypes/FshStructure.ts index 65f2ddefe..d785af03e 100644 --- a/src/fshtypes/FshStructure.ts +++ b/src/fshtypes/FshStructure.ts @@ -22,8 +22,8 @@ export abstract class FshStructure extends FshEntity { get id() { const assignedId = getNonInstanceValueFromRules(this, 'id', '', 'id'); - if (assignedId) { - return assignedId.toString(); + if (typeof assignedId === 'string') { + return assignedId; } return this._id; } diff --git a/src/fshtypes/FshValueSet.ts b/src/fshtypes/FshValueSet.ts index 80cf7e295..7a534b6bd 100644 --- a/src/fshtypes/FshValueSet.ts +++ b/src/fshtypes/FshValueSet.ts @@ -25,8 +25,8 @@ export class FshValueSet extends FshEntity { get id() { const assignedId = getNonInstanceValueFromRules(this, 'id', '', 'id'); - if (assignedId) { - return assignedId.toString(); + if (typeof assignedId === 'string') { + return assignedId; } return this._id; } diff --git a/src/fshtypes/Instance.ts b/src/fshtypes/Instance.ts index a9d5aaab9..4d1464568 100644 --- a/src/fshtypes/Instance.ts +++ b/src/fshtypes/Instance.ts @@ -25,8 +25,8 @@ export class Instance extends FshEntity { get id() { const assignedId = getNonInstanceValueFromRules(this, 'id', '', 'id'); - if (assignedId) { - return assignedId.toString(); + if (typeof assignedId === 'string') { + return assignedId; } return this._id; } diff --git a/src/import/FSHTank.ts b/src/import/FSHTank.ts index 8755d67e6..8f9b1ffe7 100644 --- a/src/import/FSHTank.ts +++ b/src/import/FSHTank.ts @@ -356,7 +356,7 @@ export class FSHTank implements Fishable { result = this.getAllInstances().find( csInstance => csInstance?.instanceOf === 'CodeSystem' && - csInstance?.usage === 'Definition' && + (csInstance?.usage === 'Definition' || csInstance?.usage === 'Inline') && (csInstance?.name === base || csInstance.id === base || getUrlFromFshDefinition(csInstance, this.config.canonical) === base || diff --git a/src/utils/Processing.ts b/src/utils/Processing.ts index c3a9822f7..0f8083e52 100644 --- a/src/utils/Processing.ts +++ b/src/utils/Processing.ts @@ -85,9 +85,9 @@ export const AUTOMATIC_DEPENDENCIES: AutomaticDependency[] = [ ]; export function isSupportedFHIRVersion(version: string): boolean { - // For now, allow current or any 4.x/5.x version of FHIR except 4.0.0. This is a quick check; not a guarantee. If a user passes + // For now, allow current or any 4.x/5.x/6.x version of FHIR except 4.0.0. This is a quick check; not a guarantee. If a user passes // in an invalid version that passes this test (e.g., 4.99.0), it is still expected to fail when we load dependencies. - return version !== '4.0.0' && /^(current|[45]\.\d+.\d+(-.+)?)$/.test(version); + return version !== '4.0.0' && /^(current|[456]\.\d+.\d+(-.+)?)$/.test(version); } export function ensureInputDir(input: string): string { diff --git a/test/export/FHIRExporter.test.ts b/test/export/FHIRExporter.test.ts index af036d362..676feee9e 100644 --- a/test/export/FHIRExporter.test.ts +++ b/test/export/FHIRExporter.test.ts @@ -4,8 +4,13 @@ import { exportFHIR, Package, FHIRExporter } from '../../src/export'; import { FSHTank, FSHDocument } from '../../src/import'; import { FHIRDefinitions } from '../../src/fhirdefs'; import { minimalConfig } from '../utils/minimalConfig'; -import { FshValueSet, Instance, Profile } from '../../src/fshtypes'; -import { AssignmentRule, BindingRule, CaretValueRule } from '../../src/fshtypes/rules'; +import { FshCodeSystem, FshValueSet, Instance, Profile } from '../../src/fshtypes'; +import { + AssignmentRule, + BindingRule, + CaretValueRule, + ValueSetConceptComponentRule +} from '../../src/fshtypes/rules'; import { TestFisher, loggerSpy } from '../testhelpers'; describe('FHIRExporter', () => { @@ -507,6 +512,62 @@ describe('FHIRExporter', () => { }); }); + it('should export a value set that includes a component from a contained FSH code system and add the valueset-system extension', () => { + // CodeSystem: FoodCS + // Id: food + const foodCS = new FshCodeSystem('FoodCS'); + foodCS.id = 'food'; + doc.codeSystems.set(foodCS.name, foodCS); + // ValueSet: DinnerVS + // * ^contained[0] = FoodCS + // * include codes from system food + const valueSet = new FshValueSet('DinnerVS'); + const containedCS = new CaretValueRule(''); + containedCS.caretPath = 'contained[0]'; + containedCS.value = 'FoodCS'; + containedCS.isInstance = true; + const component = new ValueSetConceptComponentRule(true); + component.from = { system: 'FoodCS' }; + valueSet.rules.push(containedCS, component); + doc.valueSets.set(valueSet.name, valueSet); + + const exported = exporter.export(); + expect(exported.valueSets.length).toBe(1); + expect(exported.valueSets[0]).toEqual({ + resourceType: 'ValueSet', + name: 'DinnerVS', + id: 'DinnerVS', + status: 'draft', + url: 'http://hl7.org/fhir/us/minimal/ValueSet/DinnerVS', + contained: [ + { + content: 'complete', + id: 'food', + name: 'FoodCS', + resourceType: 'CodeSystem', + status: 'draft', + url: 'http://hl7.org/fhir/us/minimal/CodeSystem/food' + } + ], + compose: { + include: [ + { + system: 'http://hl7.org/fhir/us/minimal/CodeSystem/food', + _system: { + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/valueset-system', + valueCanonical: '#food' + } + ] + } + } + ] + } + }); + expect(loggerSpy.getAllMessages('error')).toHaveLength(0); + }); + it('should log a message when trying to assign a value that is numeric and refers to an Instance, but both types are wrong', () => { // Profile: MyObservation // Parent: Observation diff --git a/test/export/ValueSetExporter.test.ts b/test/export/ValueSetExporter.test.ts index b6f9a35a4..34c348c2d 100644 --- a/test/export/ValueSetExporter.test.ts +++ b/test/export/ValueSetExporter.test.ts @@ -380,6 +380,187 @@ describe('ValueSetExporter', () => { }); }); + it('should export a value set that includes a component from a contained inline instance of code system and add the valueset-system extension', () => { + // Instance: example-codesystem + // InstanceOf: CodeSystem + // Usage: #inline + // * url = "http://example.org/codesystem" + // * version = "1.0.0" + // * status = #active + // * content = #complete + const inlineCodeSystem = new Instance('example-codesystem'); + inlineCodeSystem.instanceOf = 'CodeSystem'; + inlineCodeSystem.usage = 'Inline'; + const urlRule = new AssignmentRule('url'); + urlRule.value = 'http://example.org/codesystem'; + const versionRule = new AssignmentRule('version'); + versionRule.value = '1.0.0'; + const statusRule = new AssignmentRule('status'); + statusRule.value = new FshCode('active'); + const contentRule = new AssignmentRule('content'); + contentRule.value = new FshCode('complete'); + inlineCodeSystem.rules.push(urlRule, versionRule, statusRule, contentRule); + doc.instances.set(inlineCodeSystem.name, inlineCodeSystem); + // ValueSet: ExampleValueset + // Id: example-valueset + // * ^contained = example-codesystem + // * include codes from system example-codesystem + const valueSet = new FshValueSet('ExampleValueset'); + valueSet.id = 'example-valueset'; + const containedSystem = new CaretValueRule(''); + containedSystem.caretPath = 'contained'; + containedSystem.value = 'example-codesystem'; + containedSystem.isInstance = true; + const component = new ValueSetConceptComponentRule(true); + component.from = { system: 'example-codesystem' }; + valueSet.rules.push(containedSystem, component); + doc.valueSets.set(valueSet.name, valueSet); + + const exported = exporter.export().valueSets; + expect(exported.length).toBe(1); + expect(exported[0]).toEqual({ + resourceType: 'ValueSet', + name: 'ExampleValueset', + id: 'example-valueset', + status: 'draft', + url: 'http://hl7.org/fhir/us/minimal/ValueSet/example-valueset', + contained: [ + { + resourceType: 'CodeSystem', + id: 'example-codesystem', + url: 'http://example.org/codesystem', + version: '1.0.0', + status: 'active', + content: 'complete' + } + ], + compose: { + include: [ + { + system: 'http://example.org/codesystem', + _system: { + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/valueset-system', + valueCanonical: '#example-codesystem' + } + ] + } + } + ] + } + }); + expect(loggerSpy.getAllMessages('error')).toHaveLength(0); + }); + + it('should log an error and not add the component when attempting to reference an inline instance of code system that is not contained', () => { + // Instance: example-codesystem + // InstanceOf: CodeSystem + // Usage: #inline + // * url = "http://example.org/codesystem" + // * version = "1.0.0" + // * status = #active + // * content = #complete + const inlineCodeSystem = new Instance('example-codesystem'); + inlineCodeSystem.instanceOf = 'CodeSystem'; + inlineCodeSystem.usage = 'Inline'; + const urlRule = new AssignmentRule('url'); + urlRule.value = 'http://example.org/codesystem'; + const versionRule = new AssignmentRule('version'); + versionRule.value = '1.0.0'; + const statusRule = new AssignmentRule('status'); + statusRule.value = new FshCode('active'); + const contentRule = new AssignmentRule('content'); + contentRule.value = new FshCode('complete'); + inlineCodeSystem.rules.push(urlRule, versionRule, statusRule, contentRule); + doc.instances.set(inlineCodeSystem.name, inlineCodeSystem); + // ValueSet: ExampleValueset + // Id: example-valueset + // * include codes from system example-codesystem + // * include codes from system http://hl7.org/fhir/us/minimal/CodeSystem/food + const valueSet = new FshValueSet('ExampleValueset'); + valueSet.id = 'example-valueset'; + const exampleComponent = new ValueSetConceptComponentRule(true) + .withFile('ExampleVS.fsh') + .withLocation([5, 3, 5, 48]); + exampleComponent.from = { system: 'example-codesystem' }; + const foodComponent = new ValueSetConceptComponentRule(true); + foodComponent.from = { system: 'http://hl7.org/fhir/us/minimal/CodeSystem/food' }; + valueSet.rules.push(exampleComponent, foodComponent); + doc.valueSets.set(valueSet.name, valueSet); + + const exported = exporter.export().valueSets; + expect(exported.length).toBe(1); + expect(exported[0]).toEqual({ + resourceType: 'ValueSet', + name: 'ExampleValueset', + id: 'example-valueset', + status: 'draft', + url: 'http://hl7.org/fhir/us/minimal/ValueSet/example-valueset', + compose: { + include: [ + { + system: 'http://hl7.org/fhir/us/minimal/CodeSystem/food' + } + ] + } + }); + expect(loggerSpy.getAllMessages('error')).toHaveLength(1); + expect(loggerSpy.getLastMessage('error')).toMatch( + /Can not reference CodeSystem example-codesystem/s + ); + expect(loggerSpy.getLastMessage('error')).toMatch(/File: ExampleVS\.fsh.*Line: 5\D*/s); + }); + + it('should log an error and not export the value set when attempting to reference a contained example instance of code system', () => { + // Instance: example-codesystem + // InstanceOf: CodeSystem + // Usage: #example + // * url = "http://example.org/codesystem" + // * version = "1.0.0" + // * status = #active + // * content = #complete + const inlineCodeSystem = new Instance('example-codesystem'); + inlineCodeSystem.instanceOf = 'CodeSystem'; + inlineCodeSystem.usage = 'Example'; + const urlRule = new AssignmentRule('url'); + urlRule.value = 'http://example.org/codesystem'; + const versionRule = new AssignmentRule('version'); + versionRule.value = '1.0.0'; + const statusRule = new AssignmentRule('status'); + statusRule.value = new FshCode('active'); + const contentRule = new AssignmentRule('content'); + contentRule.value = new FshCode('complete'); + inlineCodeSystem.rules.push(urlRule, versionRule, statusRule, contentRule); + doc.instances.set(inlineCodeSystem.name, inlineCodeSystem); + + // ValueSet: ExampleValueset + // Id: example-valueset + // * ^contained = example-codesystem + // * include codes from system example-codesystem + const valueSet = new FshValueSet('ExampleValueset') + .withFile('ExampleVS.fsh') + .withLocation([2, 3, 7, 48]); + valueSet.id = 'example-valueset'; + const containedSystem = new CaretValueRule(''); + containedSystem.caretPath = 'contained'; + containedSystem.value = 'example-codesystem'; + containedSystem.isInstance = true; + const exampleComponent = new ValueSetConceptComponentRule(true); + + exampleComponent.from = { system: 'example-codesystem' }; + valueSet.rules.push(containedSystem, exampleComponent); + doc.valueSets.set(valueSet.name, valueSet); + + const exported = exporter.export().valueSets; + expect(exported.length).toBe(0); + expect(loggerSpy.getAllMessages('error')).toHaveLength(1); + expect(loggerSpy.getLastMessage('error')).toMatch( + /Resolved value "example-codesystem" is not a valid URI/s + ); + expect(loggerSpy.getLastMessage('error')).toMatch(/File: ExampleVS\.fsh.*Line: 2 - 7\D*/s); + }); + it('should export a value set that includes a component from a value set', () => { const valueSet = new FshValueSet('DinnerVS'); const component = new ValueSetConceptComponentRule(true); @@ -473,6 +654,126 @@ describe('ValueSetExporter', () => { }); }); + // TODO: as part of a later task, confirm that this is in fact correct. it seems to be what the IG publisher expects, + // but doesn't quite fit the spec for ValueSet.compose.include.valueSet + it.skip('should export a value set that includes a component from a contained inline instance of value set', () => { + // Instance: inline-valueset + // InstanceOf: ValueSet + // Usage: #inline + // * url = "http://example.org/inline-value-set" + // * status = #draft + // * compose.include[0].system = "http://example.org/SomeCS" + const inlineValueSet = new Instance('inline-valueset'); + inlineValueSet.instanceOf = 'ValueSet'; + inlineValueSet.usage = 'Inline'; + const urlRule = new AssignmentRule('url'); + urlRule.value = 'http://example.org/inline-value-set'; + const statusRule = new AssignmentRule('status'); + statusRule.value = new FshCode('draft'); + const systemRule = new AssignmentRule('compose.include[0].system'); + systemRule.value = 'http://example.org/SomeCS'; + inlineValueSet.rules.push(urlRule, statusRule, systemRule); + doc.instances.set(inlineValueSet.name, inlineValueSet); + + // ValueSet: ExampleValueset + // Id: example-valueset + // * ^contained = inline-valueset + // * include codes from valueset inline-valueset + const valueSet = new FshValueSet('ExampleValueset'); + valueSet.id = 'example-valueset'; + const containedVS = new CaretValueRule(''); + containedVS.caretPath = 'contained'; + containedVS.value = 'inline-valueset'; + containedVS.isInstance = true; + const component = new ValueSetConceptComponentRule(true); + component.from = { valueSets: ['inline-valueset'] }; + valueSet.rules.push(containedVS, component); + doc.valueSets.set(valueSet.name, valueSet); + + const exported = exporter.export().valueSets; + expect(exported.length).toBe(1); + expect(exported[0]).toEqual({ + resourceType: 'ValueSet', + id: 'example-valueset', + name: 'ExampleValueset', + url: 'http://hl7.org/fhir/us/minimal/ValueSet/example-valueset', + status: 'draft', + contained: [ + { + resourceType: 'ValueSet', + id: 'inline-valueset', + url: 'http://example.org/inline-value-set', + status: 'draft', + compose: { + include: [ + { + system: 'http://example.org/SomeCS' + } + ] + } + } + ], + compose: { + include: [ + { + valueSet: ['#inline-valueset'] + } + ] + } + }); + }); + + it('should remove and log error when exporting a value set that includes a component from a self referencing value set', () => { + const valueSet = new FshValueSet('DinnerVS'); + valueSet.id = 'dinner-vs'; + const component = new ValueSetConceptComponentRule(true); + component.from = { + system: 'http://food.org/food1', + valueSets: [ + 'http://food.org/food/ValueSet/hot-food', + 'http://food.org/food/ValueSet/cold-food', + 'DinnerVS', + 'http://hl7.org/fhir/us/minimal/ValueSet/dinner-vs', + 'dinner-vs' + ] + }; + const component2 = new ValueSetConceptComponentRule(true); + component2.from = { + system: 'http://food.org/food2', + valueSets: ['DinnerVS', 'http://hl7.org/fhir/us/minimal/ValueSet/dinner-vs', 'dinner-vs'] + }; + valueSet.rules.push(component); + valueSet.rules.push(component2); + doc.valueSets.set(valueSet.name, valueSet); + const exported = exporter.export().valueSets; + expect(exported.length).toBe(1); + expect(exported[0]).toEqual({ + resourceType: 'ValueSet', + id: 'dinner-vs', + name: 'DinnerVS', + url: 'http://hl7.org/fhir/us/minimal/ValueSet/dinner-vs', + status: 'draft', + compose: { + include: [ + { + system: 'http://food.org/food1', + valueSet: [ + 'http://food.org/food/ValueSet/hot-food', + 'http://food.org/food/ValueSet/cold-food' + ] + }, + { + system: 'http://food.org/food2' + } + ] + } + }); + expect(loggerSpy.getAllMessages('error')).toHaveLength(6); + expect(loggerSpy.getLastMessage('error')).toBe( + 'Value set with id dinner-vs has component rule with self-referencing value set (by id, value set name, or url). Removing self-reference.' + ); + }); + it('should export a value set that includes a concept component with at least one concept', () => { const valueSet = new FshValueSet('DinnerVS'); const component = new ValueSetConceptComponentRule(true); diff --git a/test/fshtypes/CodeSystem.test.ts b/test/fshtypes/FshCodeSystem.test.ts similarity index 72% rename from test/fshtypes/CodeSystem.test.ts rename to test/fshtypes/FshCodeSystem.test.ts index 66822ecda..1d009a3b6 100644 --- a/test/fshtypes/CodeSystem.test.ts +++ b/test/fshtypes/FshCodeSystem.test.ts @@ -1,9 +1,9 @@ import 'jest-extended'; import { FshCodeSystem } from '../../src/fshtypes/FshCodeSystem'; -import { ConceptRule } from '../../src/fshtypes/rules'; +import { CaretValueRule, ConceptRule } from '../../src/fshtypes/rules'; import { EOL } from 'os'; -describe('CodeSystem', () => { +describe('FshCodeSystem', () => { describe('#constructor', () => { it('should set initial properties correctly', () => { const cs = new FshCodeSystem('MyCodeSystem'); @@ -15,6 +15,37 @@ describe('CodeSystem', () => { }); }); + describe('#id', () => { + it('should return an id set by a string caret rule', () => { + const p = new FshCodeSystem('MyCodeSystem'); + const idRule = new CaretValueRule(''); + idRule.caretPath = 'id'; + idRule.value = 'different-id'; + p.rules.push(idRule); + expect(p.id).toBe('different-id'); + }); + + it('should not return an id set by a non-string caret rule', () => { + const p = new FshCodeSystem('MyCodeSystem'); + const idRule = new CaretValueRule(''); + idRule.caretPath = 'id'; + idRule.value = BigInt(23456); + idRule.rawValue = '23456'; + p.rules.push(idRule); + expect(p.id).toBe('MyCodeSystem'); + }); + + it('should not return an id set by an instance caret rule', () => { + const p = new FshCodeSystem('MyCodeSystem'); + const idRule = new CaretValueRule(''); + idRule.caretPath = 'id'; + idRule.value = 'different-id'; + idRule.isInstance = true; + p.rules.push(idRule); + expect(p.id).toBe('MyCodeSystem'); + }); + }); + describe('#toFSH', () => { it('should produce FSH for the simplest CodeSystem', () => { const input = new FshCodeSystem('SimpleCodeSystem'); diff --git a/test/fshtypes/FshValueSet.test.ts b/test/fshtypes/FshValueSet.test.ts index 3e84d933d..4c42fc1e6 100644 --- a/test/fshtypes/FshValueSet.test.ts +++ b/test/fshtypes/FshValueSet.test.ts @@ -2,6 +2,7 @@ import 'jest-extended'; import { FshValueSet } from '../../src/fshtypes/FshValueSet'; import { EOL } from 'os'; import { + CaretValueRule, ValueSetConceptComponentRule, ValueSetFilterComponentRule } from '../../src/fshtypes/rules'; @@ -19,6 +20,37 @@ describe('ValueSet', () => { }); }); + describe('#id', () => { + it('should return an id set by a string caret rule', () => { + const p = new FshValueSet('MyValueSet'); + const idRule = new CaretValueRule(''); + idRule.caretPath = 'id'; + idRule.value = 'different-id'; + p.rules.push(idRule); + expect(p.id).toBe('different-id'); + }); + + it('should not return an id set by a non-string caret rule', () => { + const p = new FshValueSet('MyValueSet'); + const idRule = new CaretValueRule(''); + idRule.caretPath = 'id'; + idRule.value = BigInt(23456); + idRule.rawValue = '23456'; + p.rules.push(idRule); + expect(p.id).toBe('MyValueSet'); + }); + + it('should not return an id set by an instance caret rule', () => { + const p = new FshValueSet('MyValueSet'); + const idRule = new CaretValueRule(''); + idRule.caretPath = 'id'; + idRule.value = 'different-id'; + idRule.isInstance = true; + p.rules.push(idRule); + expect(p.id).toBe('MyValueSet'); + }); + }); + describe('#toFSH', () => { it('should produce FSH for the simplest ValueSet', () => { const vs = new FshValueSet('MyValueSet'); diff --git a/test/fshtypes/Instance.test.ts b/test/fshtypes/Instance.test.ts index d256c2d6f..f5352c27b 100644 --- a/test/fshtypes/Instance.test.ts +++ b/test/fshtypes/Instance.test.ts @@ -24,6 +24,16 @@ describe('Instance', () => { expect(p.id).toBe('different-id'); }); + it('should not return an id set by a non-string assignment rule', () => { + const p = new Instance('MyInstance'); + const idRule = new AssignmentRule('id'); + idRule.value = BigInt(12345); + idRule.rawValue = '12345'; + p.rules.push(idRule); + // the id is still the default + expect(p.id).toBe('MyInstance'); + }); + it('should not return an id set by an instance assignment rule', () => { const p = new Instance('MyInstance'); const idRule = new AssignmentRule('id'); diff --git a/test/fshtypes/Profile.test.ts b/test/fshtypes/Profile.test.ts index 86473a76f..d844b302e 100644 --- a/test/fshtypes/Profile.test.ts +++ b/test/fshtypes/Profile.test.ts @@ -29,6 +29,16 @@ describe('Profile', () => { expect(p.id).toBe('different-id'); }); + it('should not return an id set by a non-string caret rule', () => { + const p = new Profile('MyObservation'); + const idRule = new CaretValueRule(''); + idRule.caretPath = 'id'; + idRule.value = BigInt(23456); + idRule.rawValue = '23456'; + p.rules.push(idRule); + expect(p.id).toBe('MyObservation'); + }); + it('should not return an id set by an instance caret rule', () => { const p = new Profile('MyObservation'); const idRule = new CaretValueRule(''); diff --git a/test/utils/Processing.test.ts b/test/utils/Processing.test.ts index 9c4553b62..1aecc6af1 100644 --- a/test/utils/Processing.test.ts +++ b/test/utils/Processing.test.ts @@ -426,6 +426,18 @@ describe('Processing', () => { expect(config.fhirVersion).toEqual(['4.5.0']); }); + it('should allow FHIR R6 prerelease', () => { + const input = path.join(__dirname, 'fixtures', 'fhir-r6-prerelease'); + const config = readConfig(input); + expect(config.fhirVersion).toEqual(['6.0.0-ballot2']); + }); + + it('should allow FHIR R6 full release', () => { + const input = path.join(__dirname, 'fixtures', 'fhir-r6'); + const config = readConfig(input); + expect(config.fhirVersion).toEqual(['6.0.0']); + }); + it('should allow FHIR current', () => { const input = path.join(__dirname, 'fixtures', 'fhir-current'); const config = readConfig(input); diff --git a/test/utils/fixtures/fhir-r6-prerelease/sushi-config.yaml b/test/utils/fixtures/fhir-r6-prerelease/sushi-config.yaml new file mode 100644 index 000000000..a602ee091 --- /dev/null +++ b/test/utils/fixtures/fhir-r6-prerelease/sushi-config.yaml @@ -0,0 +1,9 @@ +id: fhir.us.minimal +canonical: http://hl7.org/fhir/us/minimal +name: MinimalIG +status: draft +version: 1.0.0 +fhirVersion: 6.0.0-ballot2 +copyrightYear: 2020+ +releaseLabel: Build CI +template: hl7.fhir.template#0.0.5 diff --git a/test/utils/fixtures/fhir-r6/sushi-config.yaml b/test/utils/fixtures/fhir-r6/sushi-config.yaml new file mode 100644 index 000000000..0b4a40806 --- /dev/null +++ b/test/utils/fixtures/fhir-r6/sushi-config.yaml @@ -0,0 +1,9 @@ +id: fhir.us.minimal +canonical: http://hl7.org/fhir/us/minimal +name: MinimalIG +status: draft +version: 1.0.0 +fhirVersion: 6.0.0 +copyrightYear: 2020+ +releaseLabel: Build CI +template: hl7.fhir.template#0.0.5