Repository: FHIR/sushi (ts)

Issue:
Log error when a non-modifier extension is used in a modifierExtension path
The IG Publisher issues an error if an extension is used in `modifierExtension` but that extension does not have `isModifier` set.  SUSHI should consider checking for this and issuing an error so that authors can fix it _before_ they get to the stage of running the publisher.

See: https://chat.fhir.org/#narrow/stream/215610-shorthand/topic/ModiferExtension

Pull request:
Check if extension is or is not a modifier extension when constraining types or assigning instances
Fixes #576. (thanks @ngfreiter)

The title is a bit of a mouthful, but the idea is that:
- a "modifier extension" is one where the root `Extension` element's `isModifier` property is `true`,
- modifier extensions should only be used on the `modifierExtension` element, and
- only modifier extensions should be used on the `modifierExtension` element.

When constraining an element's type by OnlyRule or ContainsRule, if the type being constrained is `Extension`, check to see if the profiles being applied are modifier extensions. If so, and if the element is `modifierExtension`, everything is fine. But otherwise, show an error. Similarly, if the profiles being applied are not modifier extensions, and the element being constrained is `modifierExtension`, show an error.

When assigning instances of an extension in an Instance definition, check to see if those extension instances represent modifier extensions. If a modifier extension instance is assigned to an element other than `modifierExtension`, show an error. If a non-modifier extension instance is assigned to `modifierExtension`, show an error. If information about the extension instance's type is not available, assume it is okay and do not show an error.

Honestly, I think it's easier to understand when written as FSH, so hopefully the tests clearly demonstrate the scenarios I want to handle.

My implementation for this specifically does _not_ check rules where `extension.url` or `modifierExtension.url` is set directly by the rule. My feeling is that checking the modifier-or-not status of an extension is most appropriate when using the extension, and not some contained element of the extension. But, I am willing to add these checks if folks think that it makes sense and adds value.

Patch:
diff --git a/src/export/InstanceExporter.ts b/src/export/InstanceExporter.ts
index 53766a9..d18443a 100644
--- a/src/export/InstanceExporter.ts
+++ b/src/export/InstanceExporter.ts
@@ -1,6 +1,6 @@
 import { FSHTank } from '../import/FSHTank';
 import { StructureDefinition, InstanceDefinition, ElementDefinition, PathPart } from '../fhirtypes';
-import { Instance } from '../fshtypes';
+import { Instance, SourceInfo } from '../fshtypes';
 import { logger, Fishable, Type, Metadata, resolveSoftIndexing } from '../utils';
 import {
   setPropertyOnInstance,
@@ -8,7 +8,10 @@ import {
   cleanResource,
   splitOnPathPeriods,
   setImpliedPropertiesOnInstance,
-  applyInsertRules
+  applyInsertRules,
+  isExtension,
+  getSliceName,
+  isModifierExtension
 } from '../fhirtypes/common';
 import { InstanceOfNotDefinedError } from '../errors/InstanceOfNotDefinedError';
 import { InstanceOfLogicalProfileError } from '../errors/InstanceOfLogicalProfileError';
@@ -85,7 +88,10 @@ export class InstanceExporter implements Fishable {
     // This order is required due to the fact that validateValueAtPath changes instanceOfStructureDefinition
     // in certain cases that must happen before setting rules from the Structure Definition. In the future
     // we may want to refactor validateValueAtPath, but for now things should happen in this order
-    const ruleMap: Map<string, { pathParts: PathPart[]; assignedValue: any }> = new Map();
+    const ruleMap: Map<
+      string,
+      { pathParts: PathPart[]; assignedValue: any; sourceInfo: SourceInfo }
+    > = new Map();
     rules.forEach(rule => {
       try {
         const matchingInlineResourcePaths = inlineResourcePaths.filter(
@@ -107,7 +113,8 @@ export class InstanceExporter implements Fishable {
         // Record each valid rule in a map
         ruleMap.set(rule.path, {
           pathParts: validatedRule.pathParts,
-          assignedValue: validatedRule.assignedValue
+          assignedValue: validatedRule.assignedValue,
+          sourceInfo: rule.sourceInfo
         });
       } catch (e) {
         logger.error(e.message, rule.sourceInfo);
@@ -122,9 +129,62 @@ export class InstanceExporter implements Fishable {
     //     in step 2 are maintained...don't worry I'm confused too
     setImpliedPropertiesOnInstance(instanceDef, instanceOfStructureDefinition, paths, this.fisher);
     const ruleInstance = cloneDeep(instanceDef);
-    ruleMap.forEach(rule =>
-      setPropertyOnInstance(ruleInstance, rule.pathParts, rule.assignedValue, this.fisher)
-    );
+    ruleMap.forEach(rule => {
+      setPropertyOnInstance(ruleInstance, rule.pathParts, rule.assignedValue, this.fisher);
+      // was an instance of an extension used correctly with respect to modifiers?
+      if (
+        isExtension(rule.pathParts[rule.pathParts.length - 1].base) &&
+        typeof rule.assignedValue === 'object'
+      ) {
+        const extension = this.fisher.fishForFHIR(rule.assignedValue.url, Type.Extension);
+        if (extension) {
+          const pathBase = rule.pathParts[rule.pathParts.length - 1].base;
+          const isModifier = isModifierExtension(extension);
+          if (isModifier && pathBase === 'extension') {
+            logger.error(
+              `Instance of modifier extension ${
+                extension.name ?? extension.id
+              } assigned to extension path. Modifier extensions should only be assigned to modifierExtension paths.`,
+              rule.sourceInfo
+            );
+          } else if (!isModifier && pathBase === 'modifierExtension') {
+            logger.error(
+              `Instance of non-modifier extension ${
+                extension.name ?? extension.id
+              } assigned to modifierExtension path. Non-modifier extensions should only be assigned to extension paths.`,
+              rule.sourceInfo
+            );
+          }
+        }
+      }
+      // were extensions used correctly along the path?
+      rule.pathParts.forEach(pathPart => {
+        if (isExtension(pathPart.base)) {
+          const sliceName = getSliceName(pathPart);
+          if (sliceName) {
+            const extension = this.fisher.fishForFHIR(sliceName, Type.Extension);
+            if (extension) {
+              const isModifier = isModifierExtension(extension);
+              if (isModifier && pathPart.base === 'extension') {
+                logger.error(
+                  `Modifier extension ${
+                    extension.name ?? extension.id
+                  } used on extension element. Modifier extensions should only be used with modifierExtension elements.`,
+                  rule.sourceInfo
+                );
+              } else if (!isModifier && pathPart.base === 'modifierExtension') {
+                logger.error(
+                  `Non-modifier extension ${
+                    extension.name ?? extension.id
+                  } used on modifierExtension element. Non-modifier extensions should only be used with extension elements.`,
+                  rule.sourceInfo
+                );
+              }
+            }
+          }
+        }
+      });
+    });
     instanceDef = merge(instanceDef, ruleInstance);
     return instanceDef;
   }
diff --git a/src/export/StructureDefinitionExporter.ts b/src/export/StructureDefinitionExporter.ts
index 5b81daf..e5c6b34 100644
--- a/src/export/StructureDefinitionExporter.ts
+++ b/src/export/StructureDefinitionExporter.ts
@@ -50,7 +50,8 @@ import {
   getTypeFromFshDefinitionOrParent,
   getUrlFromFshDefinition,
   replaceReferences,
-  splitOnPathPeriods
+  splitOnPathPeriods,
+  isModifierExtension
 } from '../fhirtypes/common';
 import { Package } from './Package';
 import { isUri } from 'valid-url';
@@ -654,7 +655,7 @@ export class StructureDefinitionExporter implements Fishable {
     }
     rule.items.forEach(item => {
       if (item.type) {
-        const extension = this.fishForMetadata(item.type, Type.Extension);
+        const extension = this.fishForFHIR(item.type, Type.Extension);
         if (extension == null) {
           logger.error(
             `Cannot create ${item.name} extension; unable to locate extension definition for: ${item.type}.`,
@@ -681,6 +682,20 @@ export class StructureDefinitionExporter implements Fishable {
           // Otherwise it is a conflicting duplicate extension or some other error
           logger.error(e.message, rule.sourceInfo);
         }
+        // check if we have used modifier extensions correctly
+        const isModifier = isModifierExtension(extension);
+        const isModifierPath = element.path.endsWith('.modifierExtension');
+        if (isModifier && !isModifierPath) {
+          logger.error(
+            `Modifier extension ${item.type} assigned to extension path. Modifier extensions should only be assigned to modifierExtension paths.`,
+            rule.sourceInfo
+          );
+        } else if (!isModifier && isModifierPath) {
+          logger.error(
+            `Non-modifier extension ${item.type} assigned to modifierExtension path. Non-modifier extensions should only be assigned to extension paths.`,
+            rule.sourceInfo
+          );
+        }
       } else {
         try {
           // If the extension is inline, assign its url element automatically to the sliceName
diff --git a/src/fhirtypes/ElementDefinition.ts b/src/fhirtypes/ElementDefinition.ts
index 4624658..cf473bb 100644
--- a/src/fhirtypes/ElementDefinition.ts
+++ b/src/fhirtypes/ElementDefinition.ts
@@ -46,7 +46,12 @@ import {
   MismatchedBindingTypeError,
   ValidationError
 } from '../errors';
-import { setPropertyOnDefinitionInstance, splitOnPathPeriods, isReferenceType } from './common';
+import {
+  setPropertyOnDefinitionInstance,
+  splitOnPathPeriods,
+  isReferenceType,
+  isModifierExtension
+} from './common';
 import { Fishable, Type, Metadata, logger } from '../utils';
 import { InstanceDefinition } from './InstanceDefinition';
 import { idRegex } from './primitiveTypes';
@@ -961,6 +966,28 @@ export class ElementDefinition {
 
     // Finally, reset this element's types to the new types
     this.type = newTypes;
+    // extra check for modifier extension usage
+    if (typeMatches.get('Extension')?.length > 0) {
+      // fish up each specific profile by url to see if it is a modifier extension
+      const isModifierPath = this.path.endsWith('.modifierExtension');
+      typeMatches.get('Extension').forEach(typeMatch => {
+        const fullExtension = fisher.fishForFHIR(typeMatch.metadata.url, Type.Extension);
+        if (fullExtension) {
+          const isModifier = isModifierExtension(fullExtension);
+          if (isModifier && !isModifierPath) {
+            logger.error(
+              `Modifier extension ${typeMatch.metadata.name} used to constrain extension element. Modifier extensions should only be used with modifierExtension elements.`,
+              rule.sourceInfo
+            );
+          } else if (!isModifier && isModifierPath) {
+            logger.error(
+              `Non-modifier extension ${typeMatch.metadata.name} used to constrain modifierExtension element. Non-modifier extensions should only be used with extension elements.`,
+              rule.sourceInfo
+            );
+          }
+        }
+      });
+    }
   }
 
   /**
diff --git a/src/fhirtypes/StructureDefinition.ts b/src/fhirtypes/StructureDefinition.ts
index 29224ec..70efb6f 100644
--- a/src/fhirtypes/StructureDefinition.ts
+++ b/src/fhirtypes/StructureDefinition.ts
@@ -499,12 +499,12 @@ export class StructureDefinition {
       }
       currentElement = this.findElementByPath(currentPath, fisher);
       // Allow for adding extension elements to the instance that are not on the SD
-      if (!currentElement && pathPart.base === 'extension') {
+      if (!currentElement && isExtension(pathPart.base)) {
         // Get extension element (if currentPath is A.B.extension[C], get A.B.extension)
         const extensionPath = `${previousPath ? `${previousPath}.` : ''}${pathPart.base}`;
         const extensionElement = this.findElementByPath(extensionPath, fisher);
         // Get the extension being referred to
-        const extension = fisher.fishForMetadata(pathPart.brackets[0]);
+        const extension = fisher.fishForMetadata(pathPart.brackets[0], Type.Extension);
         if (extension && extensionElement) {
           // If the extension exists, add it as a slice to the SD so that we can assign it
           // This function is only called by InstanceExporter on copies of SDs, not those being exported
diff --git a/src/fhirtypes/common.ts b/src/fhirtypes/common.ts
index d9a09bf..b4307c4 100644
--- a/src/fhirtypes/common.ts
+++ b/src/fhirtypes/common.ts
@@ -773,6 +773,13 @@ export function isExtension(path: string): boolean {
   return ['modifierExtension', 'extension'].includes(path);
 }
 
+export function isModifierExtension(extension: any): boolean {
+  return (
+    extension?.snapshot.element.find((el: ElementDefinition) => el.id === 'Extension')
+      ?.isModifier === true
+  );
+}
+
 /**
  * Checks if a provided type can be treated as a Reference
  * @param type - The type being checked


Test patch:
diff --git a/test/export/InstanceExporter.test.ts b/test/export/InstanceExporter.test.ts
index bcb97a0..629350c 100644
--- a/test/export/InstanceExporter.test.ts
+++ b/test/export/InstanceExporter.test.ts
@@ -2725,6 +2725,164 @@ describe('InstanceExporter', () => {
       expect(exported.extension).toBeUndefined();
     });
 
+    it('should log an error when a modifier extension is assigned to an extension path', () => {
+      // Extension: StrangeExtension
+      // * . ?!
+      const strangeExtension = new Extension('StrangeExtension');
+      const modifierRule = new FlagRule('.');
+      modifierRule.modifier = true;
+      strangeExtension.rules.push(modifierRule);
+      doc.extensions.set(strangeExtension.name, strangeExtension);
+      // Instance: StrangeInstance
+      // InstanceOf: StrangeExtension
+      // Usage: #inline
+      const strangeInstance = new Instance('StrangeInstance');
+      strangeInstance.instanceOf = 'StrangeExtension';
+      strangeInstance.usage = 'Inline';
+      doc.instances.set(strangeInstance.name, strangeInstance);
+      // Instance: Bar
+      // InstanceOf: TestPatient
+      // extension[0] = StrangeInstance
+      const strangeRule = new AssignmentRule('extension[0]')
+        .withFile('Strange.fsh')
+        .withLocation([5, 3, 5, 28]);
+      strangeRule.value = 'StrangeInstance';
+      strangeRule.isInstance = true;
+      patientInstance.rules.push(strangeRule);
+      const exported = exportInstance(patientInstance);
+      expect(exported.extension).toEqual([
+        {
+          url: 'http://hl7.org/fhir/us/minimal/StructureDefinition/StrangeExtension'
+        }
+      ]);
+      expect(loggerSpy.getLastMessage('error')).toMatch(
+        /Instance of modifier extension StrangeExtension assigned to extension path\. Modifier extensions should only be assigned to modifierExtension paths\..*File: Strange\.fsh.*Line: 5\D*/s
+      );
+    });
+
+    it('should log an error when a non-modifier extension is assigned to a modifierExtension path', () => {
+      // Extension: StrangeExtension
+      const strangeExtension = new Extension('StrangeExtension');
+      doc.extensions.set(strangeExtension.name, strangeExtension);
+      // Instance: StrangeInstance
+      // InstanceOf: StrangeExtension
+      // Usage: #inline
+      const strangeInstance = new Instance('StrangeInstance');
+      strangeInstance.instanceOf = 'StrangeExtension';
+      strangeInstance.usage = 'Inline';
+      doc.instances.set(strangeInstance.name, strangeInstance);
+      // Instance: Bar
+      // InstanceOf: TestPatient
+      // modifierExtension[0] = StrangeInstance
+      const strangeRule = new AssignmentRule('modifierExtension[0]')
+        .withFile('Strange.fsh')
+        .withLocation([5, 3, 5, 28]);
+      strangeRule.value = 'StrangeInstance';
+      strangeRule.isInstance = true;
+      patientInstance.rules.push(strangeRule);
+      const exported = exportInstance(patientInstance);
+      expect(exported.modifierExtension).toEqual([
+        {
+          url: 'http://hl7.org/fhir/us/minimal/StructureDefinition/StrangeExtension'
+        }
+      ]);
+      expect(loggerSpy.getLastMessage('error')).toMatch(
+        /Instance of non-modifier extension StrangeExtension assigned to modifierExtension path\. Non-modifier extensions should only be assigned to extension paths\..*File: Strange\.fsh.*Line: 5\D*/s
+      );
+    });
+
+    it('should log an error when a modifier extension is used on an extension element as part of a longer path', () => {
+      // Extension: StrangeExtension
+      // * value[x] only string
+      // * . ?!
+      const strangeExtension = new Extension('StrangeExtension');
+      const onlyRule = new OnlyRule('value[x]');
+      onlyRule.types = [{ type: 'string' }];
+      const modifierRule = new FlagRule('.');
+      modifierRule.modifier = true;
+      strangeExtension.rules.push(onlyRule, modifierRule);
+      doc.extensions.set(strangeExtension.name, strangeExtension);
+      // Instance: Bar
+      // InstanceOf: TestPatient
+      // extension[StrangeExtension].valueString = "This is strange"
+      const strangeRule = new AssignmentRule('extension[StrangeExtension].valueString')
+        .withFile('Strange.fsh')
+        .withLocation([7, 7, 7, 19]);
+      strangeRule.value = 'This is strange';
+      patientInstance.rules.push(strangeRule);
+      const exported = exportInstance(patientInstance);
+      expect(exported.extension).toEqual([
+        {
+          url: 'http://hl7.org/fhir/us/minimal/StructureDefinition/StrangeExtension',
+          valueString: 'This is strange'
+        }
+      ]);
+      expect(loggerSpy.getLastMessage('error')).toMatch(
+        /Modifier extension StrangeExtension used on extension element\. Modifier extensions should only be used with modifierExtension elements\..*File: Strange\.fsh.*Line: 7\D*/s
+      );
+    });
+
+    it('should log an error when a modifier extension is used on an extension element in the middle of a path', () => {
+      // Extension: StrangeExtension
+      // * value[x] only string
+      // * . ?!
+      const strangeExtension = new Extension('StrangeExtension');
+      const onlyRule = new OnlyRule('value[x]');
+      onlyRule.types = [{ type: 'string' }];
+      const modifierRule = new FlagRule('.');
+      modifierRule.modifier = true;
+      strangeExtension.rules.push(onlyRule, modifierRule);
+      doc.extensions.set(strangeExtension.name, strangeExtension);
+      // Instance: Bar
+      // InstanceOf: TestPatient
+      // maritalStatus.extension[StrangeExtension].valueString = "This is strange"
+      const strangeRule = new AssignmentRule(
+        'maritalStatus.extension[StrangeExtension].valueString'
+      )
+        .withFile('Strange.fsh')
+        .withLocation([9, 5, 9, 23]);
+      strangeRule.value = 'This is strange';
+      patientInstance.rules.push(strangeRule);
+      const exported = exportInstance(patientInstance);
+      expect(exported.maritalStatus.extension).toEqual([
+        {
+          url: 'http://hl7.org/fhir/us/minimal/StructureDefinition/StrangeExtension',
+          valueString: 'This is strange'
+        }
+      ]);
+      expect(loggerSpy.getLastMessage('error')).toMatch(
+        /Modifier extension StrangeExtension used on extension element\. Modifier extensions should only be used with modifierExtension elements\..*File: Strange\.fsh.*Line: 9\D*/s
+      );
+    });
+
+    it('should log an error when a non-modifier extension is used on a modifierExtension element as part of a longer path', () => {
+      // Extension: StrangeExtension
+      // * value[x] only string
+      const strangeExtension = new Extension('StrangeExtension');
+      const onlyRule = new OnlyRule('value[x]');
+      onlyRule.types = [{ type: 'string' }];
+      strangeExtension.rules.push(onlyRule);
+      doc.extensions.set(strangeExtension.name, strangeExtension);
+      // Instance: Bar
+      // InstanceOf: TestPatient
+      // * modifierExtension[StrangeExtension].valueString = "This is normal"
+      const strangeRule = new AssignmentRule('modifierExtension[StrangeExtension].valueString')
+        .withFile('Strange.fsh')
+        .withLocation([6, 7, 6, 19]);
+      strangeRule.value = 'This is normal';
+      patientInstance.rules.push(strangeRule);
+      const exported = exportInstance(patientInstance);
+      expect(exported.modifierExtension).toEqual([
+        {
+          url: 'http://hl7.org/fhir/us/minimal/StructureDefinition/StrangeExtension',
+          valueString: 'This is normal'
+        }
+      ]);
+      expect(loggerSpy.getLastMessage('error')).toMatch(
+        /Non-modifier extension StrangeExtension used on modifierExtension element\. Non-modifier extensions should only be used with extension elements\..*File: Strange\.fsh.*Line: 6\D*/s
+      );
+    });
+
     it.skip('should throw when ordered is set in the discriminator but slices arrive out of order', () => {
       const assignedValRule = new AssignmentRule('result[Triglyceride].display');
       assignedValRule.value = 'foo';
diff --git a/test/export/StructureDefinitionExporter.test.ts b/test/export/StructureDefinitionExporter.test.ts
index 6c21133..24d4c8c 100644
--- a/test/export/StructureDefinitionExporter.test.ts
+++ b/test/export/StructureDefinitionExporter.test.ts
@@ -3496,6 +3496,158 @@ describe('StructureDefinitionExporter R4', () => {
       expect(sd).toBeTruthy();
       expect(loggerSpy.getAllLogs()).toHaveLength(0);
     });
+
+    it('should log an error when extension is constrained with a modifier extension', () => {
+      const extension = new Extension('StrangeExtension');
+      const modifier = new FlagRule('.');
+      modifier.modifier = true;
+      extension.rules.push(modifier);
+      doc.extensions.set(extension.name, extension);
+
+      const profile = new Profile('ConstrainedObservation');
+      profile.parent = 'Observation';
+      const onlyRule = new OnlyRule('category.extension')
+        .withFile('WrongModifier.fsh')
+        .withLocation([8, 3, 8, 55]);
+      onlyRule.types = [{ type: 'StrangeExtension' }];
+      profile.rules.push(onlyRule);
+
+      exporter.exportStructDef(profile);
+      const sd = pkg.profiles[0];
+      const categoryExtension = sd.findElement('Observation.category.extension');
+      expect(categoryExtension.type).toHaveLength(1);
+
+      expect(categoryExtension.type[0]).toEqual(
+        new ElementDefinitionType('Extension').withProfiles(
+          'http://hl7.org/fhir/us/minimal/StructureDefinition/StrangeExtension'
+        )
+      );
+      expect(loggerSpy.getLastMessage('error')).toMatch(
+        /Modifier extension StrangeExtension used to constrain extension element\. Modifier extensions should only be used with modifierExtension elements\..*File: WrongModifier\.fsh.*Line: 8\D*/s
+      );
+    });
+
+    it('should log an error each time a modifier extension is used to constrain an extension element', () => {
+      const strangeExtension = new Extension('StrangeExtension');
+      const modifier = new FlagRule('.');
+      modifier.modifier = true;
+      strangeExtension.rules.push(modifier);
+      doc.extensions.set(strangeExtension.name, strangeExtension);
+
+      const oddExtension = new Extension('OddExtension');
+      oddExtension.rules.push(modifier);
+      doc.extensions.set(oddExtension.name, oddExtension);
+
+      const regularExtension = new Extension('RegularExtension');
+      doc.extensions.set(regularExtension.name, regularExtension);
+
+      const profile = new Profile('ConstrainedObservation');
+      profile.parent = 'Observation';
+      const onlyRule = new OnlyRule('category.extension')
+        .withFile('WrongModifier.fsh')
+        .withLocation([8, 3, 8, 55]);
+      onlyRule.types = [
+        { type: 'StrangeExtension' },
+        { type: 'RegularExtension' },
+        { type: 'OddExtension' }
+      ];
+      profile.rules.push(onlyRule);
+
+      exporter.exportStructDef(profile);
+      const sd = pkg.profiles[0];
+      const categoryExtension = sd.findElement('Observation.category.extension');
+      expect(categoryExtension.type).toHaveLength(1);
+
+      expect(categoryExtension.type[0]).toEqual(
+        new ElementDefinitionType('Extension').withProfiles(
+          'http://hl7.org/fhir/us/minimal/StructureDefinition/StrangeExtension',
+          'http://hl7.org/fhir/us/minimal/StructureDefinition/RegularExtension',
+          'http://hl7.org/fhir/us/minimal/StructureDefinition/OddExtension'
+        )
+      );
+      expect(loggerSpy.getAllMessages('error')).toHaveLength(2);
+      expect(loggerSpy.getMessageAtIndex(0, 'error')).toMatch(
+        /Modifier extension StrangeExtension used to constrain extension element\. Modifier extensions should only be used with modifierExtension elements\..*File: WrongModifier\.fsh.*Line: 8\D*/s
+      );
+      expect(loggerSpy.getMessageAtIndex(1, 'error')).toMatch(
+        /Modifier extension OddExtension used to constrain extension element\. Modifier extensions should only be used with modifierExtension elements\..*File: WrongModifier\.fsh.*Line: 8\D*/s
+      );
+    });
+
+    it('should not log an error when extension is constrained with a non-modifier extension', () => {
+      const extension = new Extension('RegularExtension');
+      doc.extensions.set(extension.name, extension);
+
+      const profile = new Profile('ConstrainedObservation');
+      profile.parent = 'Observation';
+      const onlyRule = new OnlyRule('category.extension');
+      onlyRule.types = [{ type: 'RegularExtension' }];
+      profile.rules.push(onlyRule);
+
+      exporter.exportStructDef(profile);
+      const sd = pkg.profiles[0];
+      const categoryExtension = sd.findElement('Observation.category.extension');
+      expect(categoryExtension.type).toHaveLength(1);
+      expect(categoryExtension.type[0]).toEqual(
+        new ElementDefinitionType('Extension').withProfiles(
+          'http://hl7.org/fhir/us/minimal/StructureDefinition/RegularExtension'
+        )
+      );
+      expect(loggerSpy.getAllMessages('error')).toHaveLength(0);
+    });
+
+    it('should log an error when modifierExtension is constrained with a non-modifier extension', () => {
+      const extension = new Extension('OrdinaryExtension');
+      doc.extensions.set(extension.name, extension);
+
+      const profile = new Profile('ConstrainedObservation');
+      profile.parent = 'Observation';
+      const onlyRule = new OnlyRule('modifierExtension')
+        .withFile('WrongModifier.fsh')
+        .withLocation([8, 3, 8, 55]);
+      onlyRule.types = [{ type: 'OrdinaryExtension' }];
+      profile.rules.push(onlyRule);
+
+      exporter.exportStructDef(profile);
+      const sd = pkg.profiles[0];
+      const categoryExtension = sd.findElement('Observation.modifierExtension');
+      expect(categoryExtension.type).toHaveLength(1);
+
+      expect(categoryExtension.type[0]).toEqual(
+        new ElementDefinitionType('Extension').withProfiles(
+          'http://hl7.org/fhir/us/minimal/StructureDefinition/OrdinaryExtension'
+        )
+      );
+      expect(loggerSpy.getLastMessage('error')).toMatch(
+        /Non-modifier extension OrdinaryExtension used to constrain modifierExtension element\. Non-modifier extensions should only be used with extension elements\..*File: WrongModifier\.fsh.*Line: 8\D*/s
+      );
+    });
+
+    it('should not log an error when modifierExtension is constrained with a modifier extension', () => {
+      const extension = new Extension('UsefulExtension');
+      const modifier = new FlagRule('.');
+      modifier.modifier = true;
+      extension.rules.push(modifier);
+      doc.extensions.set(extension.name, extension);
+
+      const profile = new Profile('ConstrainedObservation');
+      profile.parent = 'Observation';
+      const onlyRule = new OnlyRule('modifierExtension');
+      onlyRule.types = [{ type: 'UsefulExtension' }];
+      profile.rules.push(onlyRule);
+
+      exporter.exportStructDef(profile);
+      const sd = pkg.profiles[0];
+      const categoryExtension = sd.findElement('Observation.modifierExtension');
+      expect(categoryExtension.type).toHaveLength(1);
+
+      expect(categoryExtension.type[0]).toEqual(
+        new ElementDefinitionType('Extension').withProfiles(
+          'http://hl7.org/fhir/us/minimal/StructureDefinition/UsefulExtension'
+        )
+      );
+      expect(loggerSpy.getAllMessages('error')).toHaveLength(0);
+    });
   });
 
   describe('#AssignedValueRule', () => {
@@ -5236,6 +5388,92 @@ describe('StructureDefinitionExporter R4', () => {
       );
     });
 
+    it('should report an error for an extension ContainsRule with a non-modifier extension type on a modifierExtension path', () => {
+      const extension = new Extension('MyExtension');
+      doc.extensions.set(extension.name, extension);
+
+      const profile = new Profile('MyObservation');
+      profile.parent = 'Observation';
+      const containsRule = new ContainsRule('modifierExtension')
+        .withFile('WrongModifier.fsh')
+        .withLocation([5, 3, 5, 29]);
+      containsRule.items = [{ name: 'myExt', type: 'MyExtension' }];
+      profile.rules.push(containsRule);
+      doc.profiles.set(profile.name, profile);
+
+      exporter.exportStructDef(profile);
+      const sd = pkg.profiles[0];
+      const extensionSlice = sd.elements.find(e => e.id === 'Observation.modifierExtension:myExt');
+      expect(extensionSlice).toBeDefined();
+      expect(loggerSpy.getLastMessage('error')).toMatch(
+        /Non-modifier extension MyExtension assigned to modifierExtension path\. Non-modifier extensions should only be assigned to extension paths\..*File: WrongModifier\.fsh.*Line: 5\D*/s
+      );
+    });
+
+    it('should not report an error for an extension ContainsRule with a modifier extension type on a modifierExtension path', () => {
+      const extension = new Extension('MyExtension');
+      const modifierRule = new FlagRule('.');
+      modifierRule.modifier = true;
+      extension.rules.push(modifierRule);
+      doc.extensions.set(extension.name, extension);
+
+      const profile = new Profile('MyObservation');
+      profile.parent = 'Observation';
+      const containsRule = new ContainsRule('modifierExtension');
+      containsRule.items = [{ name: 'myExt', type: 'MyExtension' }];
+      profile.rules.push(containsRule);
+      doc.profiles.set(profile.name, profile);
+
+      exporter.exportStructDef(profile);
+      const sd = pkg.profiles[0];
+      const extensionSlice = sd.elements.find(e => e.id === 'Observation.modifierExtension:myExt');
+      expect(extensionSlice).toBeDefined();
+      expect(loggerSpy.getAllMessages('error')).toHaveLength(0);
+    });
+
+    it('should report an error for an extension ContainsRule with a modifier extension type on an extension path', () => {
+      const extension = new Extension('MyExtension');
+      const modifierRule = new FlagRule('.');
+      modifierRule.modifier = true;
+      extension.rules.push(modifierRule);
+      doc.extensions.set(extension.name, extension);
+
+      const profile = new Profile('MyObservation');
+      profile.parent = 'Observation';
+      const containsRule = new ContainsRule('extension')
+        .withFile('WrongModifier.fsh')
+        .withLocation([9, 4, 9, 21]);
+      containsRule.items = [{ name: 'myExt', type: 'MyExtension' }];
+      profile.rules.push(containsRule);
+      doc.profiles.set(profile.name, profile);
+
+      exporter.exportStructDef(profile);
+      const sd = pkg.profiles[0];
+      const extensionSlice = sd.elements.find(e => e.id === 'Observation.extension:myExt');
+      expect(extensionSlice).toBeDefined();
+      expect(loggerSpy.getLastMessage('error')).toMatch(
+        /Modifier extension MyExtension assigned to extension path\. Modifier extensions should only be assigned to modifierExtension paths\..*File: WrongModifier\.fsh.*Line: 9\D*/s
+      );
+    });
+
+    it('should not report an error for an extension ContainsRule with a non-modifier extension type on an extension path', () => {
+      const extension = new Extension('MyExtension');
+      doc.extensions.set(extension.name, extension);
+
+      const profile = new Profile('MyObservation');
+      profile.parent = 'Observation';
+      const containsRule = new ContainsRule('category.extension');
+      containsRule.items = [{ name: 'myExt', type: 'MyExtension' }];
+      profile.rules.push(containsRule);
+      doc.profiles.set(profile.name, profile);
+
+      exporter.exportStructDef(profile);
+      const sd = pkg.profiles[0];
+      const extensionSlice = sd.elements.find(e => e.id === 'Observation.category.extension:myExt');
+      expect(extensionSlice).toBeDefined();
+      expect(loggerSpy.getAllMessages('error')).toHaveLength(0);
+    });
+
     it('should report an error for an extension ContainsRule with a type that does not resolve', () => {
       const profile = new Profile('Foo');
       profile.parent = 'Observation';