Repository: FHIR/sushi (ts)

Issue:
Population of ValueSet.expansion on demand
It would be helpful if the user could signal SUSHI to populate a value set expansion. This would counter errors that come from the IG Publisher when the terminology server does not "know" the code system. Here is one type of error that is very common:

```
None of the codes provided are in the value set http://hl7.org/fhir/us/mcode/ValueSet/brachytherapy-modality-vs (http://hl7.org/fhir/us/mcode/ValueSet/brachytherapy-modality-vs), and a code from this value set is required) (codes = http://ncithesaurus-stage.nci.nih.gov#C85254)
```

What is happening is that the validator is looking in the `expansion` section, which is empty, even though that code **is** listed in the `compose` section.  This happens frequently enough to think about how SUSHI could help.

My suggestion is that the user employ caret syntax to set `ValueSet.expansion.parameter`, for example:

```
* ^expansion.parameter.name = "sushi-generated"
* ^expansion.parameter.valueBoolean = true
```
(perhaps the first statement is all that is necessary)

There are obviously some limitations to what SUSHI can do (for example, evaluating filters) and hence some boundaries would have to be set. But even with limitations, it would be a useful feature.

 

Pull request description:
Expand ValueSet
Fixes #736 and completes task [CIMPL-655](https://standardhealthrecord.atlassian.net/browse/CIMPL-655).

Authors can request a value set expansion from SUSHI by setting caret value rules on the value set. Expansion is only possible for specific concepts and known code systems. If there are filter operators, included value sets, or unknown code systems involved, expansion will fail.

Patch:
diff --git a/src/export/FHIRExporter.ts b/src/export/FHIRExporter.ts
index 3763e70..2d9150d 100644
--- a/src/export/FHIRExporter.ts
+++ b/src/export/FHIRExporter.ts
@@ -37,8 +37,8 @@ export class FHIRExporter {
 
   export(): Package {
     this.structureDefinitionExporter.export();
-    this.valueSetExporter.export();
     this.codeSystemExporter.export();
+    this.valueSetExporter.export();
     this.instanceExporter.export();
     this.structureDefinitionExporter.applyDeferredRules();
     this.mappingExporter.export();
diff --git a/src/export/ValueSetExporter.ts b/src/export/ValueSetExporter.ts
index f71091a..f88c3fd 100644
--- a/src/export/ValueSetExporter.ts
+++ b/src/export/ValueSetExporter.ts
@@ -2,7 +2,8 @@ import {
   ValueSet,
   ValueSetComposeIncludeOrExclude,
   ValueSetComposeConcept,
-  StructureDefinition
+  StructureDefinition,
+  ValueSetExpansionContains
 } from '../fhirtypes';
 import { FSHTank } from '../import/FSHTank';
 import { FshValueSet, FshCode, ValueSetFilterValue } from '../fshtypes';
@@ -18,6 +19,10 @@ import {
 } from '../fshtypes/rules';
 import { setPropertyOnInstance, applyInsertRules } from '../fhirtypes/common';
 import { isUri } from 'valid-url';
+import fromPairs from 'lodash/fromPairs';
+import cloneDeep from 'lodash/cloneDeep';
+import flatMap from 'lodash/flatMap';
+
 export class ValueSetExporter {
   constructor(private readonly tank: FSHTank, private pkg: Package, private fisher: MasterFisher) {}
 
@@ -44,12 +49,14 @@ export class ValueSetExporter {
       components.forEach(component => {
         const composeElement: ValueSetComposeIncludeOrExclude = {};
         if (component.from.system) {
+          const systemParts = component.from.system.split('|');
           const foundSystem = (
-            this.fisher.fishForMetadata(component.from.system, Type.CodeSystem)?.url ??
+            this.fisher.fishForMetadata(systemParts[0], Type.CodeSystem)?.url ??
             component.from.system
           ).split('|');
           composeElement.system = foundSystem[0];
-          composeElement.version = foundSystem.slice(1).join('|') || undefined;
+          // if the rule specified a version, use that version.
+          composeElement.version = systemParts.slice(1).join('|') || undefined;
           if (!isUri(composeElement.system)) {
             throw new InvalidUriError(composeElement.system);
           }
@@ -130,6 +137,174 @@ export class ValueSetExporter {
     }
   }
 
+  private performExpansion(valueSet: ValueSet) {
+    const shouldExpand = valueSet.expansion?.parameter?.some(
+      parameter => parameter.name === 'sushi-generated' && parameter.valueBoolean === true
+    );
+    if (shouldExpand) {
+      const errors: string[] = [];
+      // the compose property must be defined
+      if (!valueSet.compose) {
+        errors.push('No composition defined.');
+      }
+      // filter operators are prohibited
+      const hasFilterOperators = [
+        ...(valueSet.compose?.include ?? []),
+        ...(valueSet.compose?.exclude ?? [])
+      ].some(compose => compose.filter != null);
+      if (hasFilterOperators) {
+        errors.push('Composition contains filter operators.');
+      }
+      // including/excluding value sets are prohibited
+      const usesValueSet = [
+        ...(valueSet.compose?.include ?? []),
+        ...(valueSet.compose?.exclude ?? [])
+      ].some(compose => compose.valueSet?.length);
+      if (usesValueSet) {
+        errors.push('Composition contains other ValueSets.');
+      }
+      // usage of a code system that we can't fish up is prohibited.
+      // the code system's content must be "complete".
+      // if the compose specifies a version, the code system's version must match it.
+      const referencedCodeSystems = fromPairs(
+        [...(valueSet.compose?.include ?? []), ...(valueSet.compose?.exclude ?? [])]
+          .filter(compose => {
+            return !compose.concept?.length && compose.system;
+          })
+          .map(compose => {
+            const fishedCodeSystem = this.fisher.fishForFHIR(compose.system, Type.CodeSystem);
+            if (compose.version == null || compose.version == fishedCodeSystem?.version) {
+              return [compose.system, fishedCodeSystem];
+            } else {
+              return [compose.system, null];
+            }
+          })
+      );
+      const hasUnavailableCodeSystem = Object.values(referencedCodeSystems).some(codeSystem => {
+        return codeSystem?.content !== 'complete';
+      });
+      // if we were able to fish up definitions for all of these systems, we're good
+      if (hasUnavailableCodeSystem) {
+        errors.push('Composition contains CodeSystems without available concept lists.');
+      }
+
+      // what we like best of all is when we just have a list of concepts. they'll even have a system specified already!
+      if (errors.length) {
+        logger.error(`Unable to expand ValueSet ${valueSet.name}:\n  - ${errors.join('\n  - ')}`);
+      } else {
+        valueSet.expansion.contains = [];
+
+        valueSet.compose.include.forEach(compose => {
+          // add specific included codes
+          compose.concept?.forEach(concept => {
+            const containedItem: ValueSetExpansionContains = {
+              system: compose.system,
+              code: concept.code
+            };
+            if (compose.version) {
+              containedItem.version = compose.version;
+            }
+            if (concept.display) {
+              containedItem.display = concept.display;
+            }
+            if (concept.designation) {
+              containedItem.designation = cloneDeep(concept.designation);
+            }
+            // add only if the concept is not already present
+            if (!this.isConceptPresent(containedItem, valueSet.expansion.contains)) {
+              valueSet.expansion.contains.push(containedItem);
+            }
+          });
+          // add known included code systems
+          if (!compose.concept?.length && compose.system) {
+            const codeSystem = referencedCodeSystems[compose.system];
+            const newConcepts = this.getConcepts(codeSystem, codeSystem.concept);
+            // flatten so we can remove each existing concept before adding
+            const existingConceptsFlat = flatMap(valueSet.expansion.contains, extractContained);
+            // remove each existing concept from the set of new concepts, to avoid duplication
+            existingConceptsFlat.forEach(existingConcept => {
+              this.removeConcept(existingConcept, newConcepts);
+            });
+            // add whatever remains
+            valueSet.expansion.contains.push(...newConcepts);
+          }
+        });
+        valueSet.compose.exclude?.forEach(compose => {
+          // remove specific excluded codes
+          compose.concept?.forEach(concept => {
+            this.removeConcept(
+              { system: compose.system, code: concept.code },
+              valueSet.expansion.contains
+            );
+          });
+          // remove known excluded code systems
+          // this is a little unusual to do, but we'll allow it.
+          if (!compose.concept?.length && compose.system) {
+            const codeSystem = referencedCodeSystems[compose.system];
+            const conceptsToRemove = flatMap(this.getConcepts(codeSystem, codeSystem.concept));
+            conceptsToRemove.forEach(concept =>
+              this.removeConcept(concept, valueSet.expansion.contains)
+            );
+          }
+        });
+        // Now that we're done, let's add a timestamp and a total
+        valueSet.expansion.timestamp = new Date().toISOString();
+        valueSet.expansion.total = flatMap(valueSet.expansion.contains, extractContained).length;
+      }
+    }
+  }
+
+  private isConceptPresent(
+    target: ValueSetExpansionContains,
+    concepts: ValueSetExpansionContains[]
+  ): boolean {
+    return concepts.some(
+      concept =>
+        (concept.code === target.code && concept.system === target.system) ||
+        this.isConceptPresent(target, concept.contains ?? [])
+    );
+  }
+
+  private getConcepts(system: any, concepts: any[]): ValueSetExpansionContains[] {
+    return concepts.map(concept => {
+      const contained: ValueSetExpansionContains = {
+        system: system.url,
+        code: concept.code
+      };
+      if (system.version) {
+        contained.version = system.version;
+      }
+      if (concept.display) {
+        contained.display = concept.display;
+      }
+      if (concept.concept?.length > 0) {
+        contained.contains = this.getConcepts(system, concept.concept);
+      }
+      return contained;
+    });
+  }
+
+  private removeConcept(target: ValueSetExpansionContains, concepts: ValueSetExpansionContains[]) {
+    const targetIndex = concepts.findIndex(concept => {
+      return concept.code === target.code && concept.system === target.system;
+    });
+    if (targetIndex > -1) {
+      const [removedConcept] = concepts.splice(targetIndex, 1);
+      if (removedConcept.contains?.length) {
+        concepts.splice(targetIndex, 0, ...removedConcept.contains);
+      }
+    } else {
+      concepts.forEach(concept => {
+        if (concept.contains?.length) {
+          this.removeConcept(target, concept.contains);
+          if (concept.contains.length === 0) {
+            delete concept.contains;
+          }
+        }
+      });
+    }
+  }
+
   export(): Package {
     const valueSets = this.tank.getAllValueSets();
     for (const valueSet of valueSets) {
@@ -166,6 +341,7 @@ export class ValueSetExporter {
     if (vs.compose && vs.compose.include.length == 0) {
       throw new ValueSetComposeError(fshDefinition.name);
     }
+    this.performExpansion(vs);
 
     // check for another value set with the same id
     // see https://www.hl7.org/fhir/resource.html#id
@@ -180,3 +356,9 @@ export class ValueSetExporter {
     return vs;
   }
 }
+
+// If the minimum node version reaches 11 or higher, Array.prototype.flat becomes available
+// and this can be reimplemented without needing lodash.
+function extractContained(c: ValueSetExpansionContains): ValueSetExpansionContains[] {
+  return [{ code: c.code, system: c.system }, ...flatMap(c.contains || [], extractContained)];
+}
diff --git a/src/fhirtypes/ValueSet.ts b/src/fhirtypes/ValueSet.ts
index 3637c46..d8270a7 100644
--- a/src/fhirtypes/ValueSet.ts
+++ b/src/fhirtypes/ValueSet.ts
@@ -40,6 +40,7 @@ export class ValueSet {
   purpose: string;
   copyright: string;
   compose: ValueSetCompose;
+  expansion: ValueSetExpansion;
 
   /**
    * Get the file name for serializing to disk.
@@ -95,3 +96,27 @@ export type ValueSetComposeFilter = {
   op: string;
   value: string;
 };
+
+export type ValueSetExpansion = {
+  parameter: ValueSetExpansionParameter[];
+  contains: ValueSetExpansionContains[];
+  timestamp: string;
+  total?: number;
+};
+
+export type ValueSetExpansionContains = ValueSetComposeConcept & {
+  system: string;
+  version?: string;
+  contains?: ValueSetExpansionContains[];
+};
+
+export type ValueSetExpansionParameter = {
+  name: string;
+  valueString?: string;
+  valueBoolean?: boolean;
+  valueInteger?: number;
+  valueDecimal?: number;
+  valueUri?: string;
+  valueCode?: string;
+  valueDateTime?: string;
+};


Test patch:
diff --git a/test/export/FHIRExporter.test.ts b/test/export/FHIRExporter.test.ts
index d101af4..bc34c7e 100644
--- a/test/export/FHIRExporter.test.ts
+++ b/test/export/FHIRExporter.test.ts
@@ -3,8 +3,8 @@ import { exportFHIR, Package, FHIRExporter } from '../../src/export';
 import { FSHTank, FSHDocument } from '../../src/import';
 import { FHIRDefinitions, loadFromPath } from '../../src/fhirdefs';
 import { minimalConfig } from '../utils/minimalConfig';
-import { Instance, Profile } from '../../src/fshtypes';
-import { CaretValueRule } from '../../src/fshtypes/rules';
+import { Instance, Profile, FshCodeSystem, FshValueSet } from '../../src/fshtypes';
+import { CaretValueRule, ConceptRule, ValueSetFilterComponentRule } from '../../src/fshtypes/rules';
 import { TestFisher, loggerSpy } from '../testhelpers';
 
 describe('FHIRExporter', () => {
@@ -127,5 +127,51 @@ describe('FHIRExporter', () => {
         /Could not find a resource named oops-no-resource/s
       );
     });
+
+    it('should allow a value set to be expanded when it refers to an exported code system', () => {
+      // CodeSystem: DessertCodes
+      // * #cake "Cake"
+      // * #icecream "Ice Cream"
+      const codeSystem = new FshCodeSystem('DessertCodes');
+      codeSystem.rules.push(
+        new ConceptRule('cake', 'Cake'),
+        new ConceptRule('icecream', 'Ice Cream')
+      );
+      doc.codeSystems.set(codeSystem.name, codeSystem);
+      // ValueSet: MyValueSet
+      // * ^expansion.parameter.name = "sushi-generated"
+      // * ^expansion.parameter.valueBoolean = true
+      // * include codes from DessertCodes
+      const valueSet = new FshValueSet('MyValueSet');
+      const expansionName = new CaretValueRule('');
+      expansionName.caretPath = 'expansion.parameter.name';
+      expansionName.value = 'sushi-generated';
+      const expansionValue = new CaretValueRule('');
+      expansionValue.caretPath = 'expansion.parameter.valueBoolean';
+      expansionValue.value = true;
+      valueSet.rules.push(expansionName, expansionValue);
+      const includeAllergy = new ValueSetFilterComponentRule(true);
+      includeAllergy.from.system = 'DessertCodes';
+      valueSet.rules.push(includeAllergy);
+      doc.valueSets.set(valueSet.name, valueSet);
+
+      const result = exporter.export();
+      expect(result.valueSets).toHaveLength(1);
+      const expansion = result.valueSets[0].expansion.contains;
+      expect(expansion).toHaveLength(2);
+      expect(expansion).toContainEqual({
+        code: 'cake',
+        display: 'Cake',
+        system: 'http://hl7.org/fhir/us/minimal/CodeSystem/DessertCodes',
+        version: '1.0.0'
+      });
+      expect(expansion).toContainEqual({
+        code: 'icecream',
+        display: 'Ice Cream',
+        system: 'http://hl7.org/fhir/us/minimal/CodeSystem/DessertCodes',
+        version: '1.0.0'
+      });
+      expect(loggerSpy.getAllMessages('error')).toHaveLength(0);
+    });
   });
 });
diff --git a/test/export/ValueSetExporter.test.ts b/test/export/ValueSetExporter.test.ts
index 8cc5c98..1401a71 100644
--- a/test/export/ValueSetExporter.test.ts
+++ b/test/export/ValueSetExporter.test.ts
@@ -14,6 +14,8 @@ import {
   ValueSetFilterComponentRule
 } from '../../src/fshtypes/rules';
 import { minimalConfig } from '../utils/minimalConfig';
+import { ValueSetExpansionContains } from '../../src/fhirtypes/ValueSet';
+import { readFileSync } from 'fs-extra';
 
 describe('ValueSetExporter', () => {
   let defs: FHIRDefinitions;
@@ -711,6 +713,421 @@ describe('ValueSetExporter', () => {
     expect(exported[0].compose.include[0].system).toBe('http://food.net/CodeSystem/FoodCS');
   });
 
+  describe('#expansion', () => {
+    let valueSet: FshValueSet;
+    // dateTime format from http://hl7.org/fhir/R4/datatypes.html#dateTime
+    const dateTimeFormat = /([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)(-(0[1-9]|1[0-2])(-(0[1-9]|[1-2][0-9]|3[0-1])(T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\.[0-9]+)?(Z|(\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00)))?)?)?/;
+
+    beforeEach(() => {
+      valueSet = new FshValueSet('MyValueSet');
+      const expansionName = new CaretValueRule('');
+      expansionName.caretPath = 'expansion.parameter.name';
+      expansionName.value = 'sushi-generated';
+      const expansionValue = new CaretValueRule('');
+      expansionValue.caretPath = 'expansion.parameter.valueBoolean';
+      expansionValue.value = true;
+      valueSet.rules.push(expansionName, expansionValue);
+      doc.valueSets.set(valueSet.name, valueSet);
+      // Add a CodeSystem with content = "fragment" for use in a test
+      const fragmentCsDef = JSON.parse(
+        readFileSync(
+          path.join(__dirname, '..', 'testhelpers', 'testdefs', 'CodeSystem-fragment.json'),
+          'utf-8'
+        ).trim()
+      );
+      defs.add(fragmentCsDef);
+    });
+
+    it('should expand a value set that contains specific concepts', () => {
+      // ValueSet: MyValueSet
+      // * ^expansion.parameter.name = "sushi-generated"
+      // * ^expansion.parameter.valueBoolean = true
+      // * includes http://zoo.org/animals|1.0#bear
+      // * includes #tiger and #hippo from system http://zoo.org/animals|1.1
+      const includeBear = new ValueSetConceptComponentRule(true);
+      includeBear.concepts.push(new FshCode('bear', 'http://zoo.org/animals|1.0'));
+      includeBear.from.system = 'http://zoo.org/animals|1.0';
+      const includeTigerHippo = new ValueSetConceptComponentRule(true);
+      includeTigerHippo.concepts.push(
+        new FshCode('tiger', 'http://zoo.org/animals|1.1'),
+        new FshCode('hippo', 'http://zoo.org/animals|1.1')
+      );
+      includeTigerHippo.from.system = 'http://zoo.org/animals|1.1';
+      valueSet.rules.push(includeBear, includeTigerHippo);
+
+      const exported = exporter.exportValueSet(valueSet);
+      expect(exported.expansion.contains).toHaveLength(3);
+      expect(exported.expansion.contains).toContainEqual<ValueSetExpansionContains>({
+        code: 'bear',
+        system: 'http://zoo.org/animals',
+        version: '1.0'
+      });
+      expect(exported.expansion.contains).toContainEqual<ValueSetExpansionContains>({
+        code: 'tiger',
+        system: 'http://zoo.org/animals',
+        version: '1.1'
+      });
+      expect(exported.expansion.contains).toContainEqual<ValueSetExpansionContains>({
+        code: 'hippo',
+        system: 'http://zoo.org/animals',
+        version: '1.1'
+      });
+      expect(exported.expansion.timestamp).toMatch(dateTimeFormat);
+      expect(exported.expansion.total).toBe(3);
+    });
+
+    it('should expand a value set that contains locally defined code systems from JSON', () => {
+      // ValueSet: MyValueSet
+      // * ^expansion.parameter.name = "sushi-generated"
+      // * ^expansion.parameter.valueBoolean = true
+      // * include codes from system AllergyIntoleranceClinicalStatusCodes
+      const includeAllergy = new ValueSetFilterComponentRule(true);
+      includeAllergy.from.system = 'AllergyIntoleranceClinicalStatusCodes';
+      valueSet.rules.push(includeAllergy);
+
+      const exported = exporter.exportValueSet(valueSet);
+      expect(exported.expansion.contains).toHaveLength(2);
+      expect(exported.expansion.contains).toContainEqual<ValueSetExpansionContains>({
+        code: 'active',
+        display: 'Active',
+        system: 'http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical',
+        version: '4.0.1'
+      });
+      expect(exported.expansion.contains).toContainEqual<ValueSetExpansionContains>({
+        code: 'inactive',
+        display: 'Inactive',
+        system: 'http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical',
+        version: '4.0.1',
+        contains: [
+          {
+            code: 'resolved',
+            display: 'Resolved',
+            system: 'http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical',
+            version: '4.0.1'
+          }
+        ]
+      });
+      expect(exported.expansion.timestamp).toMatch(dateTimeFormat);
+      expect(exported.expansion.total).toBe(3);
+    });
+
+    it('should expand a value set that contains a matching version of a locally defined code system', () => {
+      // ValueSet: MyValueSet
+      // * ^expansion.parameter.name = "sushi-generated"
+      // * ^expansion.parameter.valueBoolean = true
+      // * include codes from system AllergyIntoleranceClinicalStatusCodes|4.0.1
+      const includeAllergy = new ValueSetFilterComponentRule(true);
+      includeAllergy.from.system = 'AllergyIntoleranceClinicalStatusCodes|4.0.1';
+      valueSet.rules.push(includeAllergy);
+
+      const exported = exporter.exportValueSet(valueSet);
+      expect(exported.expansion.contains).toHaveLength(2);
+      expect(exported.expansion.contains).toContainEqual<ValueSetExpansionContains>({
+        code: 'active',
+        display: 'Active',
+        system: 'http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical',
+        version: '4.0.1'
+      });
+      expect(exported.expansion.contains).toContainEqual<ValueSetExpansionContains>({
+        code: 'inactive',
+        display: 'Inactive',
+        system: 'http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical',
+        version: '4.0.1',
+        contains: [
+          {
+            code: 'resolved',
+            display: 'Resolved',
+            system: 'http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical',
+            version: '4.0.1'
+          }
+        ]
+      });
+      expect(exported.expansion.timestamp).toMatch(dateTimeFormat);
+      expect(exported.expansion.total).toBe(3);
+    });
+
+    it('should not expand a value set that contains a different version of a locally defined code system', () => {
+      // ValueSet: MyValueSet
+      // * ^expansion.parameter.name = "sushi-generated"
+      // * ^expansion.parameter.valueBoolean = true
+      // * include codes from system AllergyIntoleranceClinicalStatusCodes|7.6.5
+      const includeAllergy = new ValueSetFilterComponentRule(true);
+      includeAllergy.from.system = 'AllergyIntoleranceClinicalStatusCodes|7.6.5';
+      valueSet.rules.push(includeAllergy);
+
+      const exported = exporter.exportValueSet(valueSet);
+      expect(exported.expansion.contains).toBeUndefined();
+      expect(exported.expansion.timestamp).toBeUndefined();
+      expect(exported.expansion.total).toBeUndefined();
+      expect(loggerSpy.getLastMessage('error')).toMatch(
+        /Composition contains CodeSystems without available concept lists\./s
+      );
+    });
+
+    it('should keep explicitly included concepts at the top level', () => {
+      // ValueSet: MyValueSet
+      // * ^expansion.parameter.name = "sushi-generated"
+      // * ^expansion.parameter.valueBoolean = true
+      // * include AllergyIntoleranceClinicalStatusCodes#resolved
+      // * include codes from system AllergyIntoleranceClinicalStatusCodes
+      // Because the #resolved code is already present when the full system is included,
+      // it is not added again. Therefore, it stays in place in the main concept list.
+      const includeResolved = new ValueSetConceptComponentRule(true);
+      includeResolved.concepts.push(
+        new FshCode('resolved', 'AllergyIntoleranceClinicalStatusCodes')
+      );
+      const includeAllergy = new ValueSetFilterComponentRule(true);
+      includeAllergy.from.system = 'AllergyIntoleranceClinicalStatusCodes';
+      includeResolved.from.system = 'AllergyIntoleranceClinicalStatusCodes';
+      valueSet.rules.push(includeResolved, includeAllergy);
+      const exported = exporter.exportValueSet(valueSet);
+      expect(exported.expansion.contains).toHaveLength(3);
+      expect(exported.expansion.timestamp).toMatch(dateTimeFormat);
+      expect(exported.expansion.total).toBe(3);
+    });
+
+    it('should not change the hierarchy of included codes when a duplicate is added', () => {
+      // ValueSet: MyValueSet
+      // * ^expansion.parameter.name = "sushi-generated"
+      // * ^expansion.parameter.valueBoolean = true
+      // * include codes from system AllergyIntoleranceClinicalStatusCodes
+      // * include AllergyIntoleranceClinicalStatusCodes#resolved
+      // Because the full system is included first, adding the specific code has no effect.
+      // Thus, the hierarchy present in the included system is maintained.
+      const includeAllergy = new ValueSetFilterComponentRule(true);
+      includeAllergy.from.system = 'AllergyIntoleranceClinicalStatusCodes';
+      const includeResolved = new ValueSetConceptComponentRule(true);
+      includeResolved.concepts.push(
+        new FshCode('resolved', 'AllergyIntoleranceClinicalStatusCodes')
+      );
+      includeResolved.from.system = 'AllergyIntoleranceClinicalStatusCodes';
+      valueSet.rules.push(includeAllergy, includeResolved);
+      const exported = exporter.exportValueSet(valueSet);
+      expect(exported.expansion.contains).toHaveLength(2);
+      expect(exported.expansion.timestamp).toMatch(dateTimeFormat);
+      expect(exported.expansion.total).toBe(3);
+    });
+
+    it('should remove excluded concepts from the expansion', () => {
+      // ValueSet: MyValueSet
+      // * ^expansion.parameter.name = "sushi-generated"
+      // * ^expansion.parameter.valueBoolean = true
+      // * include codes from system AllergyIntoleranceClinicalStatusCodes
+      // * exclude AllergyIntoleranceClinicalStatusCodes#active
+      const includeAllergy = new ValueSetFilterComponentRule(true);
+      includeAllergy.from.system = 'AllergyIntoleranceClinicalStatusCodes';
+      const excludeActive = new ValueSetConceptComponentRule(false);
+      excludeActive.concepts.push(new FshCode('active', 'AllergyIntoleranceClinicalStatusCodes'));
+      excludeActive.from.system = 'AllergyIntoleranceClinicalStatusCodes';
+      valueSet.rules.push(includeAllergy, excludeActive);
+
+      const exported = exporter.exportValueSet(valueSet);
+      expect(exported.expansion.contains).toHaveLength(1);
+      expect(exported.expansion.contains).toContainEqual<ValueSetExpansionContains>({
+        code: 'inactive',
+        display: 'Inactive',
+        system: 'http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical',
+        version: '4.0.1',
+        contains: [
+          {
+            code: 'resolved',
+            display: 'Resolved',
+            system: 'http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical',
+            version: '4.0.1'
+          }
+        ]
+      });
+      expect(exported.expansion.timestamp).toMatch(dateTimeFormat);
+      expect(exported.expansion.total).toBe(2);
+    });
+
+    it('should remove a contained excluded concept', () => {
+      // ValueSet: MyValueSet
+      // * ^expansion.parameter.name = "sushi-generated"
+      // * ^expansion.parameter.valueBoolean = true
+      // * include codes from system AllergyIntoleranceClinicalStatusCodes
+      // * exclude AllergyIntoleranceClinicalStatusCodes#resolved
+      const includeAllergy = new ValueSetFilterComponentRule(true);
+      includeAllergy.from.system = 'AllergyIntoleranceClinicalStatusCodes';
+      const excludeResolved = new ValueSetConceptComponentRule(false);
+      excludeResolved.concepts.push(
+        new FshCode('resolved', 'AllergyIntoleranceClinicalStatusCodes')
+      );
+      excludeResolved.from.system = 'AllergyIntoleranceClinicalStatusCodes';
+      valueSet.rules.push(includeAllergy, excludeResolved);
+
+      const exported = exporter.exportValueSet(valueSet);
+      expect(exported.expansion.contains).toHaveLength(2);
+      expect(exported.expansion.contains).toContainEqual<ValueSetExpansionContains>({
+        code: 'active',
+        display: 'Active',
+        system: 'http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical',
+        version: '4.0.1'
+      });
+      expect(exported.expansion.contains).toContainEqual<ValueSetExpansionContains>({
+        code: 'inactive',
+        display: 'Inactive',
+        system: 'http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical',
+        version: '4.0.1'
+      });
+      expect(exported.expansion.timestamp).toMatch(dateTimeFormat);
+      expect(exported.expansion.total).toBe(2);
+    });
+
+    it('should keep contained concepts when excluding the top-level concept', () => {
+      // ValueSet: MyValueSet
+      // * ^expansion.parameter.name = "sushi-generated"
+      // * ^expansion.parameter.valueBoolean = true
+      // * include codes from system AllergyIntoleranceClinicalStatusCodes
+      // * exclude AllergyIntoleranceClinicalStatusCodes#inactive
+      const includeAllergy = new ValueSetFilterComponentRule(true);
+      includeAllergy.from.system = 'AllergyIntoleranceClinicalStatusCodes';
+      const excludeInactive = new ValueSetConceptComponentRule(false);
+      excludeInactive.concepts.push(
+        new FshCode('inactive', 'AllergyIntoleranceClinicalStatusCodes')
+      );
+      excludeInactive.from.system = 'AllergyIntoleranceClinicalStatusCodes';
+      valueSet.rules.push(includeAllergy, excludeInactive);
+
+      const exported = exporter.exportValueSet(valueSet);
+      expect(exported.expansion.contains).toHaveLength(2);
+      expect(exported.expansion.contains).toContainEqual<ValueSetExpansionContains>({
+        code: 'active',
+        display: 'Active',
+        system: 'http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical',
+        version: '4.0.1'
+      });
+      // the contained concept moves up a level, and is now in the top-level contains array
+      expect(exported.expansion.contains).toContainEqual<ValueSetExpansionContains>({
+        code: 'resolved',
+        display: 'Resolved',
+        system: 'http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical',
+        version: '4.0.1'
+      });
+      expect(exported.expansion.timestamp).toMatch(dateTimeFormat);
+      expect(exported.expansion.total).toBe(2);
+    });
+
+    it('should remove all concepts from an excluded code system', () => {
+      // ValueSet: MyValueSet
+      // * ^expansion.parameter.name = "sushi-generated"
+      // * ^expansion.parameter.valueBoolean = true
+      // * include AllergyIntoleranceClinicalStatusCodes#active
+      // * include AllergyIntoleranceClinicalStatusCodes#inactive
+      // * include http://zoo.org/animals|1.0#bear
+      // * exclude codes from system AllergyIntoleranceClinicalStatusCodes
+      const includeActive = new ValueSetConceptComponentRule(true);
+      includeActive.concepts.push(new FshCode('active', 'AllergyIntoleranceClinicalStatusCodes'));
+      includeActive.from.system = 'AllergyIntoleranceClinicalStatusCodes';
+      const includeInactive = new ValueSetConceptComponentRule(true);
+      includeInactive.concepts.push(
+        new FshCode('inactive', 'AllergyIntoleranceClinicalStatusCodes')
+      );
+      includeInactive.from.system = 'AllergyIntoleranceClinicalStatusCodes';
+      const includeBear = new ValueSetConceptComponentRule(true);
+      includeBear.concepts.push(new FshCode('bear', 'http://zoo.org/animals|1.0'));
+      includeBear.from.system = 'http://zoo.org/animals|1.0';
+      const excludeAllergy = new ValueSetFilterComponentRule(false);
+      excludeAllergy.from.system = 'AllergyIntoleranceClinicalStatusCodes';
+      valueSet.rules.push(includeActive, includeInactive, includeBear, excludeAllergy);
+
+      const exported = exporter.exportValueSet(valueSet);
+      expect(exported.expansion.contains).toHaveLength(1);
+      expect(exported.expansion.contains).toContainEqual<ValueSetExpansionContains>({
+        code: 'bear',
+        system: 'http://zoo.org/animals',
+        version: '1.0'
+      });
+      expect(exported.expansion.timestamp).toMatch(dateTimeFormat);
+      expect(exported.expansion.total).toBe(1);
+    });
+
+    it('should not expand a value set that does not define its composition', () => {
+      // ValueSet: MyValueSet
+      // * ^expansion.parameter.name = "sushi-generated"
+      // * ^expansion.parameter.valueBoolean = true
+      const exported = exporter.exportValueSet(valueSet);
+      expect(exported.expansion.contains).toBeUndefined();
+      expect(exported.expansion.timestamp).toBeUndefined();
+      expect(exported.expansion.total).toBeUndefined();
+      expect(loggerSpy.getLastMessage('error')).toMatch(/No composition defined\./s);
+    });
+
+    it('should not expand a value set that uses a filter operator', () => {
+      // ValueSet: MyValueSet
+      // * ^expansion.parameter.name = "sushi-generated"
+      // * ^expansion.parameter.valueBoolean = true
+      // * include codes from system AllergyIntoleranceClinicalStatusCodes where definition exists
+      const includeAllergy = new ValueSetFilterComponentRule(true);
+      includeAllergy.from.system = 'AllergyIntoleranceClinicalStatusCodes';
+      includeAllergy.filters.push({
+        property: 'definition',
+        operator: VsOperator.EXISTS,
+        value: true
+      });
+      valueSet.rules.push(includeAllergy);
+
+      const exported = exporter.exportValueSet(valueSet);
+      expect(exported.expansion.contains).toBeUndefined();
+      expect(exported.expansion.timestamp).toBeUndefined();
+      expect(exported.expansion.total).toBeUndefined();
+      expect(loggerSpy.getLastMessage('error')).toMatch(/Composition contains filter operators\./s);
+    });
+
+    it('should not expand a value set that references other value sets', () => {
+      // ValueSet: MyValueSet
+      // * ^expansion.parameter.name = "sushi-generated"
+      // * ^expansion.parameter.valueBoolean = true
+      // * include codes from valueset AllergyIntoleranceClinicalStatusCodes
+      const includeAllergy = new ValueSetFilterComponentRule(true);
+      includeAllergy.from.valueSets = ['AllergyIntoleranceClinicalStatusCodes'];
+      valueSet.rules.push(includeAllergy);
+
+      const exported = exporter.exportValueSet(valueSet);
+      expect(exported.expansion.contains).toBeUndefined();
+      expect(exported.expansion.timestamp).toBeUndefined();
+      expect(exported.expansion.total).toBeUndefined();
+      expect(loggerSpy.getLastMessage('error')).toMatch(/Composition contains other ValueSets\./s);
+    });
+
+    it('should not expand value sets that refer to code systems without available definitions', () => {
+      // ValueSet: MyValueSet
+      // * ^expansion.parameter.name = "sushi-generated"
+      // * ^expansion.parameter.valueBoolean = true
+      // * include codes from system http://example.org/CodeSystem/MyCodeSystem
+      // This code system's content is "supplement"
+      const includeUnknown = new ValueSetFilterComponentRule(true);
+      includeUnknown.from.system = 'http://example.org/CodeSystem/MyCodeSystem';
+      valueSet.rules.push(includeUnknown);
+
+      const exported = exporter.exportValueSet(valueSet);
+      expect(exported.expansion.contains).toBeUndefined();
+      expect(exported.expansion.timestamp).toBeUndefined();
+      expect(exported.expansion.total).toBeUndefined();
+      expect(loggerSpy.getLastMessage('error')).toMatch(
+        /Composition contains CodeSystems without available concept lists\./s
+      );
+    });
+
+    it('should not expand value sets that refer to code systems with content that is not complete', () => {
+      // ValueSet: MyValueSet
+      // * ^expansion.parameter.name = "sushi-generated"
+      // * ^expansion.parameter.valueBoolean = true
+      // * include codes from system http://example.org/CodeSystem/ZooFragmentSet
+      const includeFragment = new ValueSetFilterComponentRule(true);
+      includeFragment.from.system = 'http://example.org/CodeSystem/ZooFragmentSet';
+      valueSet.rules.push(includeFragment);
+
+      const exported = exporter.exportValueSet(valueSet);
+      expect(exported.expansion.contains).toBeUndefined();
+      expect(exported.expansion.timestamp).toBeUndefined();
+      expect(exported.expansion.total).toBeUndefined();
+      expect(loggerSpy.getLastMessage('error')).toMatch(
+        /Composition contains CodeSystems without available concept lists\./s
+      );
+    });
+  });
+
   describe('#insertRules', () => {
     let vs: FshValueSet;
     let ruleSet: RuleSet;
diff --git a/test/testhelpers/testdefs/CodeSystem-fragment.json b/test/testhelpers/testdefs/CodeSystem-fragment.json
new file mode 100644
index 0000000..5d8a4c1
--- /dev/null
+++ b/test/testhelpers/testdefs/CodeSystem-fragment.json
@@ -0,0 +1,19 @@
+{
+  "resourceType": "CodeSystem",
+  "status": "active",
+  "content": "fragment",
+  "name": "ZooFragmentSet",
+  "id": "ZooFragmentSet",
+  "version": "1.0.0",
+  "url": "http://example.org/CodeSystem/ZooFragmentSet",
+  "concept": [
+    {
+      "code": "bear",
+      "display": "Bear"
+    },
+    {
+      "code": "tiger",
+      "display": "Tiger"
+    }
+  ]
+}
\ No newline at end of file