Repository: FHIR/sushi (ts)

Issue:
A code can be included muliple times in a Value Set
SUSHI allows easily detectable GIGO errors.... if you include the same code in a VS > 1 time, it comes out > 1 time.   An easy check for duplicate code/code-system would save ballot comments.

See https://fshschool.org/FSHOnline/#/share/3g9tIzO

The duplicate code ends in 008

Pull request description:
Warn when a concept is included in a ValueSet more than once
Fixes #832 and completes task [CIMPL-814](https://standardhealthrecord.atlassian.net/browse/CIMPL-814).

Including the same concept (with the same code, system, and version) in a ValueSet multiple times has no effect. It does not cause any validation errors, but it may indicate a mistake when defining the ValueSet. Warn the user when this is detected, and do not add the concept again. This only applies to inclusion: no changes are made to exclusion logic.

Patch:
diff --git a/src/export/ValueSetExporter.ts b/src/export/ValueSetExporter.ts
index f9df218..17dbf78 100644
--- a/src/export/ValueSetExporter.ts
+++ b/src/export/ValueSetExporter.ts
@@ -18,6 +18,7 @@ import {
 } from '../fshtypes/rules';
 import { setPropertyOnInstance, applyInsertRules } from '../fhirtypes/common';
 import { isUri } from 'valid-url';
+import { flatMap } from 'lodash';
 
 export class ValueSetExporter {
   constructor(private readonly tank: FSHTank, private pkg: Package, private fisher: MasterFisher) {}
@@ -91,7 +92,45 @@ export class ValueSetExporter {
           });
         }
         if (component.inclusion) {
-          valueSet.compose.include.push(composeElement);
+          if (composeElement.concept?.length > 0) {
+            // warn the user if they have already included a concept in this component
+            // concept, system, and version must all match to be considered equal
+            const matchingComposeElements = valueSet.compose.include.filter(compose => {
+              return (
+                compose.system === composeElement.system &&
+                compose.version === composeElement.version &&
+                compose.concept?.length > 0
+              );
+            });
+            const potentialMatches = flatMap(
+              matchingComposeElements,
+              compose => compose.concept
+            ).map(concept => concept.code);
+            composeElement.concept = composeElement.concept.filter(
+              (concept, idx, currentConcepts) => {
+                if (
+                  potentialMatches.includes(concept.code) ||
+                  currentConcepts
+                    .slice(0, idx)
+                    .some(duplicateConcept => duplicateConcept.code === concept.code)
+                ) {
+                  logger.warn(
+                    `ValueSet ${valueSet.name} already includes ${composeElement.system}${
+                      composeElement.version ? `|${composeElement.version}` : ''
+                    }#${concept.code}`,
+                    component.sourceInfo
+                  );
+                  return false;
+                }
+                return true;
+              }
+            );
+            if (composeElement.concept.length > 0) {
+              valueSet.compose.include.push(composeElement);
+            }
+          } else {
+            valueSet.compose.include.push(composeElement);
+          }
         } else {
           valueSet.compose.exclude.push(composeElement);
         }


Test patch:
diff --git a/test/export/ValueSetExporter.test.ts b/test/export/ValueSetExporter.test.ts
index fd883e9..68e14e6 100644
--- a/test/export/ValueSetExporter.test.ts
+++ b/test/export/ValueSetExporter.test.ts
@@ -588,6 +588,109 @@ describe('ValueSetExporter', () => {
       /notAUri.*File: Breakfast\.fsh.*Line: 2 - 4\D*/s
     );
   });
+
+  it('should log a message and not add the concept again when a specific concept is included more than once', () => {
+    const valueSet = new FshValueSet('DinnerVS');
+    const pizzaComponent = new ValueSetConceptComponentRule(true)
+      .withFile('Dinner.fsh')
+      .withLocation([2, 0, 2, 15]);
+    pizzaComponent.from = { system: 'http://food.org/food' };
+    pizzaComponent.concepts.push(
+      new FshCode('Pizza', 'http://food.org/food', 'Delicious pizza to share.')
+    );
+    const multiComponent = new ValueSetConceptComponentRule(true)
+      .withFile('Dinner.fsh')
+      .withLocation([3, 0, 4, 16]);
+    multiComponent.from = { system: 'http://food.org/food' };
+    multiComponent.concepts.push(
+      new FshCode('Pizza', 'http://food.org/food', 'Delicious pizza to share.'),
+      new FshCode('Salad', 'http://food.org/food', 'Plenty of fresh vegetables.'),
+      new FshCode('Toast', 'http://food.org/food')
+    );
+    const toastComponent = new ValueSetConceptComponentRule(true)
+      .withFile('Dinner.fsh')
+      .withLocation([5, 0, 5, 17]);
+    toastComponent.from = { system: 'http://food.org/food|2.0.1' };
+    toastComponent.concepts.push(new FshCode('Toast', 'http://food.org/food|2.0.1'));
+    const saladComponent = new ValueSetConceptComponentRule(true)
+      .withFile('Dinner.fsh')
+      .withLocation([6, 0, 6, 18]);
+    saladComponent.from = { system: 'http://food.org/food' };
+    saladComponent.concepts.push(
+      new FshCode('Salad', 'http://food.org/food', 'Plenty of fresh vegetables.')
+    );
+    const versionMultiComponent = new ValueSetConceptComponentRule(true)
+      .withFile('Dinner.fsh')
+      .withLocation([7, 0, 9, 19]);
+    versionMultiComponent.from = { system: 'http://food.org/food|2.0.1' };
+    versionMultiComponent.concepts.push(
+      new FshCode('Toast', 'http://food.org/food|2.0.1'),
+      new FshCode('Waffles', 'http://food.org/food|2.0.1')
+    );
+    valueSet.rules.push(
+      pizzaComponent,
+      multiComponent,
+      toastComponent,
+      saladComponent,
+      versionMultiComponent
+    );
+    doc.valueSets.set(valueSet.name, valueSet);
+    const exported = exporter.export().valueSets;
+    expect(exported.length).toBe(1);
+    const inclusions = exported[0].compose.include;
+    expect(inclusions.length).toBe(4);
+    expect(inclusions[0]).toEqual({
+      system: 'http://food.org/food',
+      concept: [
+        {
+          code: 'Pizza',
+          display: 'Delicious pizza to share.'
+        }
+      ]
+    });
+    expect(inclusions[1]).toEqual({
+      system: 'http://food.org/food',
+      concept: [
+        {
+          code: 'Salad',
+          display: 'Plenty of fresh vegetables.'
+        },
+        {
+          code: 'Toast'
+        }
+      ]
+    });
+    expect(inclusions[2]).toEqual({
+      system: 'http://food.org/food',
+      version: '2.0.1',
+      concept: [
+        {
+          code: 'Toast'
+        }
+      ]
+    });
+    expect(inclusions[3]).toEqual({
+      system: 'http://food.org/food',
+      version: '2.0.1',
+      concept: [
+        {
+          code: 'Waffles'
+        }
+      ]
+    });
+    const warnings = loggerSpy.getAllMessages('warn');
+    expect(warnings.length).toBe(3);
+    expect(warnings[0]).toMatch(
+      /ValueSet DinnerVS already includes http:\/\/food\.org\/food#Pizza.*File: Dinner\.fsh.*Line: 3 - 4\D*/s
+    );
+    expect(warnings[1]).toMatch(
+      /ValueSet DinnerVS already includes http:\/\/food\.org\/food#Salad.*File: Dinner\.fsh.*Line: 6\D*/s
+    );
+    expect(warnings[2]).toMatch(
+      /ValueSet DinnerVS already includes http:\/\/food\.org\/food\|2\.0\.1#Toast.*File: Dinner\.fsh.*Line: 7 - 9\D*/s
+    );
+  });
+
   // CaretValueRules
   it('should apply a CaretValueRule', () => {
     const valueSet = new FshValueSet('DinnerVS');