Repository: FHIR/sushi
Pull request #245 (2020-02-20 18:52:01)

Infer cardinalities for inline extensions
This PR adds support for setting a `0..0` cardinality on `value[x]` or `extension` on extensions that are defined inline. Since extensions can be defined inline on Extensions or Profiles, this moves the preprocessing onto all the StructureDefinitions that are exported.

This should resolve the comment on PR #215 - comment [here](https://github.com/FHIR/sushi/pull/215#issuecomment-585749318).

jafeltra: I think I discovered an issue with inferring cardinalities for extensions and properly reporting errors when a user sets a cardinality to `0..0` directly. It is not introduced as part of this PR, but I have an idea of how to fix it, so I'm looking into it a bit. If I don't have a good solution by stand up in the morning, I may ask if you all think it is worth it to continue addressing as part of this PR.
jafeltra: I mentioned this at stand up, but I found another issue with setting rules incorrectly on profiles. Since this is directly related to this PR, I'll continue working on it. But I don't think this PR is really ready to be reviewed in it's current state.
jafeltra: Okay I think this is finally ready for re-review. The latest updates check that you are either on a FSH extension or you are setting a rule on an inline extension before doing any of the rule inferring. This allows you to add rules about `value[x]` elements on profiles and nested elements that have a `value[x]` on profiles. Also, you can add extensions on elements that are not already extensions without changing any rules about `value[x]`.

As part of this, I realized I was logging errors for invalid rules that were being inferred and then applied to StructureDefinitions, but the tests I had written weren't catching those logs because no errors were thrown and I only checked logs in the cases that were checking for errors. To fix this, I reset the `loggerSpy` before each test and check that it contains what is expected after each test (which should be an empty list for the majority of the tests).
jafeltra: I think I addressed all your comments @cmoesel.

I'm also realizing I forgot to post anything here earlier, but I was able to update the branch to not add any  duplicate rules if the user adds a `0..0` card rule themselves on an extension. I was also able to update the way we were searching for slices in ElementDefinition `constrainCardinality`, which was leading to the `InvalidMaxOfSliceError`s being logged, which Mark mentioned seeing in #232.

Patch:
diff --git a/src/export/InstanceExporter.ts b/src/export/InstanceExporter.ts
index e6fa3bb..883b747 100644
--- a/src/export/InstanceExporter.ts
+++ b/src/export/InstanceExporter.ts
@@ -2,7 +2,12 @@ import { FSHTank } from '../import/FSHTank';
 import { StructureDefinition, InstanceDefinition, ElementDefinition } from '../fhirtypes';
 import { Instance } from '../fshtypes';
 import { logger, Fishable, Type } from '../utils';
-import { setPropertyOnInstance, replaceReferences, replaceField } from '../fhirtypes/common';
+import {
+  setPropertyOnInstance,
+  replaceReferences,
+  replaceField,
+  splitOnPathPeriods
+} from '../fhirtypes/common';
 import { InstanceOfNotDefinedError } from '../errors/InstanceOfNotDefinedError';
 import { Package } from '.';
 import { isEmpty, cloneDeep } from 'lodash';
@@ -46,8 +51,7 @@ export class InstanceExporter {
           pathPart.brackets?.forEach(b => (path += /^[-+]?\d+$/.test(b) ? '' : `[${b}]`));
           const element = instanceOfStructureDefinition.findElementByPath(path, this.fisher);
           // Reconstruct the part of the rule's path that we just got the element for
-          let rulePathPart = rule.path
-            .split(/\.(?![^\[]*\])/g) // match a period that isn't within square brackets
+          let rulePathPart = splitOnPathPeriods(rule.path)
             .slice(0, i + 1)
             .join('.');
           rulePathPart += '.';
diff --git a/src/export/StructureDefinitionExporter.ts b/src/export/StructureDefinitionExporter.ts
index 5405998..5fe85c6 100644
--- a/src/export/StructureDefinitionExporter.ts
+++ b/src/export/StructureDefinitionExporter.ts
@@ -12,7 +12,7 @@ import {
   CaretValueRule
 } from '../fshtypes/rules';
 import { logger, Type, Fishable, Metadata, MasterFisher } from '../utils';
-import { replaceReferences } from '../fhirtypes/common';
+import { replaceReferences, splitOnPathPeriods } from '../fhirtypes/common';
 import { Package } from './Package';
 
 /**
@@ -168,48 +168,89 @@ export class StructureDefinitionExporter implements Fishable {
   }
 
   /**
-   * Does any necessary preprocessing of extensions.
+   * Does any necessary preprocessing of profiles and extensions.
    * @param {Extension} fshDefinition - The extension to do preprocessing on. It is updated directly based on processing.
    */
-  private preprocessExtension(fshDefinition: Extension): void {
-    let hasRuleAppliedToValueX = false;
-    let hasRuleAppliedToExtension = false;
+  private preprocessStructureDefinition(
+    fshDefinition: Extension | Profile,
+    isExtension: boolean
+  ): void {
+    const inferredCardRulesMap = new Map(); // key is the rule, value is a boolean of whether it should be set
     fshDefinition.rules.forEach(rule => {
-      if (rule.path.startsWith('extension')) {
-        if (hasRuleAppliedToValueX) {
-          logger.error(
-            `Extension ${fshDefinition.name} cannot have both a value and sub-extensions`,
-            rule.sourceInfo
-          );
-        }
-        if (!(rule instanceof CardRule && rule.max === '0')) {
-          hasRuleAppliedToExtension = true;
+      const rulePathParts = splitOnPathPeriods(rule.path);
+      rulePathParts.forEach((pathPart, i) => {
+        const previousPathPart = rulePathParts[i - 1];
+        // A rule is related to an extension if it is directly on a FSH defined extension or if it is defined inline on a profile or extension.
+        // Check the section before the current pathPart to see if it is an inline extension.
+        const isOnExtension =
+          (isExtension && rulePathParts.length === 1) || previousPathPart?.startsWith('extension');
+
+        // If we are not looking at a rule on an extension, don't infer anything. Return to check the next rule.
+        if (!isOnExtension) {
+          return;
         }
-      } else if (rule.path.startsWith('value')) {
-        if (hasRuleAppliedToExtension) {
-          logger.error(
-            `Extension ${fshDefinition.name} cannot have both a value and sub-extensions`,
-            rule.sourceInfo
+
+        const initialPath = rulePathParts.slice(0, i).join('.');
+        const basePath = `${initialPath}${initialPath ? '.' : ''}`;
+
+        // See if we can infer any rules about an extension (inline or FSH defined)
+        if (pathPart.startsWith('extension')) {
+          const relevantContradictoryRule = `${basePath}extension`;
+          const relevantContradictoryRuleMapEntry = inferredCardRulesMap.get(
+            relevantContradictoryRule
           );
+          if (!(rule instanceof CardRule && rule.max === '0')) {
+            if (relevantContradictoryRuleMapEntry) {
+              logger.error(
+                `Extension on ${fshDefinition.name} cannot have both a value and sub-extensions`,
+                rule.sourceInfo
+              );
+              inferredCardRulesMap.set(relevantContradictoryRule, false);
+            } else {
+              // If we don't already have a contradiction, add new rule to infer value[x] constraints
+              const relevantRule = `${basePath}value[x]`;
+              inferredCardRulesMap.set(relevantRule, true);
+            }
+          } else {
+            if (relevantContradictoryRuleMapEntry) {
+              inferredCardRulesMap.set(relevantContradictoryRule, false);
+            }
+          }
+        } else if (pathPart.startsWith('value')) {
+          const relevantContradictoryRule = `${basePath}value[x]`;
+          const relevantContradictoryRuleMapEntry = inferredCardRulesMap.get(
+            relevantContradictoryRule
+          );
+          if (!(rule instanceof CardRule && rule.max === '0')) {
+            if (relevantContradictoryRuleMapEntry) {
+              logger.error(
+                `Extension on ${fshDefinition.name} cannot have both a value and sub-extensions`,
+                rule.sourceInfo
+              );
+              inferredCardRulesMap.set(relevantContradictoryRule, false);
+            } else {
+              // If we don't already have a contradiction, add new rule to infer extension constraints
+              const relevantRule = `${basePath}extension`;
+              inferredCardRulesMap.set(relevantRule, true);
+            }
+          } else {
+            if (relevantContradictoryRuleMapEntry) {
+              inferredCardRulesMap.set(relevantContradictoryRule, false);
+            }
+          }
         }
-        if (!(rule instanceof CardRule && rule.max === '0')) {
-          hasRuleAppliedToValueX = true;
-        }
-      }
+      });
     });
 
-    // If only value[x] or extension is use, constrain cardinality of the other to 0..0.
+    // If only value[x] or extension is used, constrain cardinality of the other to 0..0.
     // If both are used, an error has been logged, but the rules will still be applied.
-    if (hasRuleAppliedToExtension && !hasRuleAppliedToValueX) {
-      const valueCardRule = new CardRule('value[x]');
-      valueCardRule.min = 0;
-      valueCardRule.max = '0';
-      fshDefinition.rules.push(valueCardRule);
-    } else if (hasRuleAppliedToValueX && !hasRuleAppliedToExtension) {
-      const extensionCardRule = new CardRule('extension');
-      extensionCardRule.min = 0;
-      extensionCardRule.max = '0';
-      fshDefinition.rules.push(extensionCardRule);
+    for (const [rule, shouldRuleBeSet] of inferredCardRulesMap) {
+      if (shouldRuleBeSet) {
+        const inferredCardRule = new CardRule(rule);
+        inferredCardRule.min = 0;
+        inferredCardRule.max = '0';
+        fshDefinition.rules.push(inferredCardRule);
+      }
     }
   }
 
@@ -276,11 +317,12 @@ export class StructureDefinitionExporter implements Fishable {
     // incomplete definitions to be used to resolve circular reference issues.
     if (structDef.type === 'Extension') {
       this.pkg.extensions.push(structDef);
-      this.preprocessExtension(fshDefinition as Extension);
     } else {
       this.pkg.profiles.push(structDef);
     }
 
+    this.preprocessStructureDefinition(fshDefinition, structDef.type === 'Extension');
+
     this.setRules(structDef, fshDefinition);
     structDef.inProgress = false;
 
diff --git a/src/fhirtypes/ElementDefinition.ts b/src/fhirtypes/ElementDefinition.ts
index bbe7191..3486138 100644
--- a/src/fhirtypes/ElementDefinition.ts
+++ b/src/fhirtypes/ElementDefinition.ts
@@ -354,6 +354,12 @@ export class ElementDefinition {
     setPropertyOnDefinitionInstance(this, path, value, fisher);
   }
 
+  getSlices() {
+    return this.structDef.elements.filter(
+      e => e.id !== this.id && e.path === this.path && e.id.startsWith(this.id)
+    );
+  }
+
   /**
    * Constrains the cardinality of this element.  Cardinality constraints can only narrow
    * cardinality.  Attempts to constrain to a wider cardinality will throw.
@@ -394,7 +400,7 @@ export class ElementDefinition {
       // Check that new max >= sum of mins of children
       this.checkSumOfSliceMins(max);
       // Check that new max >= every individual child max
-      const slices = this.structDef.elements.filter(e => e.id !== this.id && e.path === this.path);
+      const slices = this.getSlices();
       const overMaxChild = slices.find(child => child.max === '*' || parseInt(child.max) > maxInt);
       if (!isUnbounded && overMaxChild) {
         throw new InvalidMaxOfSliceError(overMaxChild.max, overMaxChild.sliceName, max);
@@ -424,9 +430,7 @@ export class ElementDefinition {
    * @throws {InvalidSumOfSliceMinsError} when the sum of mins of the slices exceeds max of sliced element
    */
   private checkSumOfSliceMins(newSlicedElementMax: string, sliceMinIncrease = 0) {
-    const slices = this.parent()
-      .children()
-      .filter(e => e.id !== this.id && e.path === this.path && !e.slicing);
+    const slices = this.getSlices();
     const sumOfMins = sliceMinIncrease + slices.reduce((prev, curr) => (prev += curr.min), 0);
     if (newSlicedElementMax !== '*' && sumOfMins > parseInt(newSlicedElementMax)) {
       throw new InvalidSumOfSliceMinsError(sumOfMins, newSlicedElementMax, this.id);
diff --git a/src/fhirtypes/StructureDefinition.ts b/src/fhirtypes/StructureDefinition.ts
index 7badc9e..69d78b8 100644
--- a/src/fhirtypes/StructureDefinition.ts
+++ b/src/fhirtypes/StructureDefinition.ts
@@ -9,7 +9,13 @@ import { Meta } from './specialTypes';
 import { Identifier, CodeableConcept, Coding, Narrative, Resource, Extension } from './dataTypes';
 import { ContactDetail, UsageContext } from './metaDataTypes';
 import { CannotResolvePathError, InvalidElementAccessError } from '../errors';
-import { getArrayIndex, setPropertyOnDefinitionInstance, HasName, HasId } from './common';
+import {
+  getArrayIndex,
+  setPropertyOnDefinitionInstance,
+  HasName,
+  HasId,
+  splitOnPathPeriods
+} from './common';
 import { Fishable, Type } from '../utils/Fishable';
 import { applyMixins } from '../utils';
 
@@ -503,7 +509,7 @@ export class StructureDefinition {
    */
   private parseFSHPath(fshPath: string): PathPart[] {
     const pathParts: PathPart[] = [];
-    const splitPath = fshPath.split(/\.(?![^\[]*\])/g); // match a period that isn't within square brackets
+    const splitPath = splitOnPathPeriods(fshPath);
     for (const pathPart of splitPath) {
       const splitPathPart = pathPart.split('[');
       if (splitPathPart.length === 1 || pathPart.endsWith('[x]')) {
diff --git a/src/fhirtypes/common.ts b/src/fhirtypes/common.ts
index 4ffac16..9ea66da 100644
--- a/src/fhirtypes/common.ts
+++ b/src/fhirtypes/common.ts
@@ -14,6 +14,10 @@ import cloneDeep = require('lodash/cloneDeep');
 import { logger } from '../utils';
 import { FHIRId, idRegex } from './primitiveTypes';
 
+export function splitOnPathPeriods(path: string): string[] {
+  return path.split(/\.(?![^\[]*\])/g); // match a period that isn't within square brackets
+}
+
 /**
  * This function sets an instance property of an SD or ED if possible
  * @param {StructureDefinition | ElementDefinition} - The instance to fix a value on


Test patch:
diff --git a/test/export/StructureDefinitionExporter.test.ts b/test/export/StructureDefinitionExporter.test.ts
index 9ef5fbe..e75bea8 100644
--- a/test/export/StructureDefinitionExporter.test.ts
+++ b/test/export/StructureDefinitionExporter.test.ts
@@ -50,6 +50,7 @@ describe('StructureDefinitionExporter', () => {
     pkg = new Package(input.config);
     fisher = new TestFisher(input, defs, pkg);
     exporter = new StructureDefinitionExporter(input, pkg, fisher);
+    loggerSpy.reset();
   });
 
   // Profile
@@ -1404,6 +1405,7 @@ describe('StructureDefinitionExporter', () => {
 
     expect(valueElement.min).toEqual(0);
     expect(valueElement.max).toEqual('0');
+    expect(loggerSpy.getAllLogs('error')).toHaveLength(0);
   });
 
   it('should not zero out Extension.value[x] if Extension.extension is zeroed out', () => {
@@ -1421,6 +1423,7 @@ describe('StructureDefinitionExporter', () => {
 
     expect(valueElement.min).toEqual(0);
     expect(valueElement.max).toEqual('1');
+    expect(loggerSpy.getAllLogs('error')).toHaveLength(0);
   });
 
   it('should log an error if Extension.extension and Extension.value[x] are both used but apply both rules', () => {
@@ -1446,9 +1449,10 @@ describe('StructureDefinitionExporter', () => {
     expect(extensionElement).toBeDefined();
     expect(extensionElement.sliceName).toEqual('MySlice');
     expect(loggerSpy.getLastMessage()).toMatch(
-      /Extension MyInvalidExtension cannot have both a value and sub-extensions/s
+      /Extension on MyInvalidExtension cannot have both a value and sub-extensions/s
     );
     expect(loggerSpy.getLastMessage()).toMatch(/File: InvalidExtension\.fsh.*Line: 4\D*/s);
+    expect(loggerSpy.getAllLogs('error')).toHaveLength(1);
   });
 
   it('should zero out Extension.extension when Extension.value[x] is used', () => {
@@ -1465,12 +1469,17 @@ describe('StructureDefinitionExporter', () => {
 
     expect(extensionElement.min).toEqual(0);
     expect(extensionElement.max).toEqual('0');
+    expect(loggerSpy.getAllLogs('error')).toHaveLength(0);
   });
 
   it('should not zero out Extension.extension if Extension.value[x] is zeroed out', () => {
     const extension = new Extension('MyExplicitComplexExtension');
     extension.id = 'complex-extension';
 
+    const containsRuleForExtension = new ContainsRule('extension');
+    containsRuleForExtension.items = ['MySlice'];
+    extension.rules.push(containsRuleForExtension);
+
     const cardRuleForValue = new CardRule('value[x]');
     cardRuleForValue.min = 0;
     cardRuleForValue.max = '0';
@@ -1482,9 +1491,10 @@ describe('StructureDefinitionExporter', () => {
 
     expect(extensionElement.min).toEqual(0);
     expect(extensionElement.max).toEqual('*');
+    expect(loggerSpy.getAllLogs('error')).toHaveLength(0);
   });
 
-  it('should log an error if Extension.value[x] is changed after Extension.extension is used', () => {
+  it('should log an error if Extension.value[x] is changed after Extension.extension is used but apply both rules', () => {
     const extension = new Extension('MyOtherInvalidExtension');
     extension.id = 'my-invalid-extension';
 
@@ -1507,9 +1517,354 @@ describe('StructureDefinitionExporter', () => {
     expect(extensionElement.sliceName).toEqual('MySlice');
     expect(valueElement.type).toEqual([new ElementDefinitionType('string')]);
     expect(loggerSpy.getLastMessage()).toMatch(
-      /Extension MyOtherInvalidExtension cannot have both a value and sub-extensions/s
+      /Extension on MyOtherInvalidExtension cannot have both a value and sub-extensions/s
     );
     expect(loggerSpy.getLastMessage()).toMatch(/File: OtherInvalidExtension\.fsh.*Line: 4\D*/s);
+    expect(loggerSpy.getAllLogs('error')).toHaveLength(1);
+  });
+
+  it('should zero out value[x] on an extension defined inline that uses extension', () => {
+    const extension = new Extension('MyExtension');
+    extension.id = 'my-extension';
+
+    const containsRuleForExtension = new ContainsRule('extension');
+    containsRuleForExtension.items = ['mySlice'];
+    extension.rules.push(containsRuleForExtension); // * extension contains MySlice
+
+    const cardRule = new CardRule('extension[mySlice].extension');
+    cardRule.min = 1;
+    cardRule.max = '*';
+    extension.rules.push(cardRule); // * extension[mySlice].extension 1..* which implies extension[mySlice].value[x] 0..0
+
+    exporter.exportStructDef(extension);
+    const sd = pkg.extensions[0];
+    const valueElement = sd.findElement('Extension.value[x]');
+    const mySliceValueElement = sd.findElement('Extension.extension:mySlice.value[x]');
+
+    expect(valueElement.min).toEqual(0);
+    expect(valueElement.max).toEqual('0');
+    expect(mySliceValueElement.min).toEqual(0);
+    expect(mySliceValueElement.max).toEqual('0');
+    expect(loggerSpy.getAllLogs('error')).toHaveLength(0);
+  });
+
+  it('should zero out extension on an extension defined inline that uses value[x]', () => {
+    const extension = new Extension('MyExtension');
+    extension.id = 'my-extension';
+
+    const containsRuleForExtension = new ContainsRule('extension');
+    containsRuleForExtension.items = ['mySlice'];
+    extension.rules.push(containsRuleForExtension); // * extension contains MySlice
+
+    const fixedValueRule = new FixedValueRule('extension[mySlice].valueBoolean');
+    fixedValueRule.fixedValue = true;
+    extension.rules.push(fixedValueRule);
+
+    exporter.exportStructDef(extension);
+    const sd = pkg.extensions[0];
+    const valueElement = sd.findElement('Extension.value[x]');
+    const mySliceExtensionElement = sd.findElement('Extension.extension:mySlice.extension');
+
+    expect(valueElement.min).toEqual(0);
+    expect(valueElement.max).toEqual('0');
+    expect(mySliceExtensionElement.min).toEqual(0);
+    expect(mySliceExtensionElement.max).toEqual('0');
+    expect(loggerSpy.getAllLogs('error')).toHaveLength(0);
+  });
+
+  it('should not zero out extension if value[x] is zeroed out on an extension defined inline', () => {
+    const extension = new Extension('MyExtension');
+    extension.id = 'my-extension';
+
+    const containsRuleForExtension = new ContainsRule('extension');
+    containsRuleForExtension.items = ['mySlice'];
+    extension.rules.push(containsRuleForExtension); // * extension contains MySlice
+
+    const cardRule = new CardRule('extension[mySlice].value[x]');
+    cardRule.min = 0;
+    cardRule.max = '0';
+    extension.rules.push(cardRule); // * extension[mySlice].value[x] 0..0 which should not change anything on extension[mySlice].extension
+
+    exporter.exportStructDef(extension);
+    const sd = pkg.extensions[0];
+    const valueElement = sd.findElement('Extension.value[x]');
+    const mySliceValueElement = sd.findElement('Extension.extension:mySlice.value[x]');
+    const mySliceExtensionElement = sd.findElement('Extension.extension:mySlice.extension');
+
+    expect(valueElement.min).toEqual(0);
+    expect(valueElement.max).toEqual('0');
+    expect(mySliceValueElement.min).toEqual(0);
+    expect(mySliceValueElement.max).toEqual('0');
+    expect(mySliceExtensionElement.min).toEqual(0);
+    expect(mySliceExtensionElement.max).toEqual('*'); // extension[mySlice].extension cardinality is unchanged
+    expect(loggerSpy.getAllLogs('error')).toHaveLength(0);
+  });
+
+  it('should not zero out value[x] if extension is zeroed out on an extension defined inline', () => {
+    const extension = new Extension('MyExtension');
+    extension.id = 'my-extension';
+
+    const containsRuleForExtension = new ContainsRule('extension');
+    containsRuleForExtension.items = ['mySlice'];
+    extension.rules.push(containsRuleForExtension); // * extension contains MySlice
+
+    const cardRule = new CardRule('extension[mySlice].extension');
+    cardRule.min = 0;
+    cardRule.max = '0';
+    extension.rules.push(cardRule); // * extension[mySlice].extension 0..0 which should not change anything on extension[mySlice].value[x]
+
+    exporter.exportStructDef(extension);
+    const sd = pkg.extensions[0];
+    const valueElement = sd.findElement('Extension.value[x]');
+    const mySliceValueElement = sd.findElement('Extension.extension:mySlice.value[x]');
+    const mySliceExtensionElement = sd.findElement('Extension.extension:mySlice.extension');
+
+    expect(valueElement.min).toEqual(0);
+    expect(valueElement.max).toEqual('0');
+    expect(mySliceValueElement.min).toEqual(0);
+    expect(mySliceValueElement.max).toEqual('1'); // extension[mySlice].value[x] cardinality is unchanged
+    expect(mySliceExtensionElement.min).toEqual(0);
+    expect(mySliceExtensionElement.max).toEqual('0');
+    expect(loggerSpy.getAllLogs('error')).toHaveLength(0);
+  });
+
+  it('should log an error if extension is used after value[x] on an extension defined inline and apply both rules', () => {
+    const extension = new Extension('MyInvalidExtension');
+    extension.id = 'my-invalid-extension';
+
+    const containsRuleForExtension = new ContainsRule('extension');
+    containsRuleForExtension.items = ['mySlice'];
+    extension.rules.push(containsRuleForExtension); // * extension contains MySlice
+
+    // Contradictory rules - log an error but set both
+    const valueCardRule = new CardRule('extension[mySlice].value[x]');
+    valueCardRule.min = 1;
+    valueCardRule.max = '1';
+    extension.rules.push(valueCardRule); // * extension[mySlice].value[x] 1..1
+    const extensionCardRule = new CardRule('extension[mySlice].extension')
+      .withFile('InvalidInlineExtension.fsh')
+      .withLocation([4, 7, 4, 15]);
+    extensionCardRule.min = 1;
+    extensionCardRule.max = '*';
+    extension.rules.push(extensionCardRule); // * extension[mySlice].extension 1..*
+
+    exporter.exportStructDef(extension);
+    const sd = pkg.extensions[0];
+    const valueElement = sd.findElement('Extension.value[x]');
+    const mySliceExtensionElement = sd.findElement('Extension.extension:mySlice.extension');
+    const mySliceValueElement = sd.findElement('Extension.extension:mySlice.value[x]');
+
+    expect(valueElement.min).toEqual(0);
+    expect(valueElement.max).toEqual('0');
+    expect(mySliceExtensionElement.min).toEqual(1);
+    expect(mySliceExtensionElement.max).toEqual('*');
+    expect(mySliceValueElement.min).toEqual(1);
+    expect(mySliceValueElement.max).toEqual('1');
+    expect(loggerSpy.getLastMessage()).toMatch(
+      /Extension on MyInvalidExtension cannot have both a value and sub-extensions/s
+    );
+    expect(loggerSpy.getLastMessage()).toMatch(/File: InvalidInlineExtension\.fsh.*Line: 4\D*/s);
+    expect(loggerSpy.getAllLogs('error')).toHaveLength(1);
+  });
+
+  it('should log an error if value[x] is used after extension on an extension defined inline and apply both rules', () => {
+    const extension = new Extension('MyInvalidExtension');
+    extension.id = 'my-invalid-extension';
+
+    const containsRuleForExtension = new ContainsRule('extension');
+    containsRuleForExtension.items = ['mySlice'];
+    extension.rules.push(containsRuleForExtension); // * extension contains MySlice
+
+    // Contradictory rules - log an error but set both
+    const extensionCardRule = new CardRule('extension[mySlice].extension');
+    extensionCardRule.min = 1;
+    extensionCardRule.max = '*';
+    extension.rules.push(extensionCardRule); // * extension[mySlice].extension 1..*
+    const valueCardRule = new CardRule('extension[mySlice].value[x]')
+      .withFile('InvalidInlineExtension.fsh')
+      .withLocation([5, 7, 5, 15]);
+    valueCardRule.min = 1;
+    valueCardRule.max = '1';
+    extension.rules.push(valueCardRule); // * extension[mySlice].value[x] 1..1
+
+    exporter.exportStructDef(extension);
+    const sd = pkg.extensions[0];
+    const valueElement = sd.findElement('Extension.value[x]');
+    const mySliceExtensionElement = sd.findElement('Extension.extension:mySlice.extension');
+    const mySliceValueElement = sd.findElement('Extension.extension:mySlice.value[x]');
+
+    expect(valueElement.min).toEqual(0);
+    expect(valueElement.max).toEqual('0');
+    expect(mySliceExtensionElement.min).toEqual(1);
+    expect(mySliceExtensionElement.max).toEqual('*');
+    expect(mySliceValueElement.min).toEqual(1);
+    expect(mySliceValueElement.max).toEqual('1');
+    expect(loggerSpy.getLastMessage()).toMatch(
+      /Extension on MyInvalidExtension cannot have both a value and sub-extensions/s
+    );
+    expect(loggerSpy.getLastMessage()).toMatch(/File: InvalidInlineExtension\.fsh.*Line: 5\D*/s);
+    expect(loggerSpy.getAllLogs('error')).toHaveLength(1);
+  });
+
+  it('should zero out value[x] if extension is used on an extension defined inline on a profile', () => {
+    // Other combinations of inferred CardRules on profiles are covered by the extensions tests
+    const patientProfile = new Profile('MyPatient');
+    patientProfile.parent = 'Patient';
+
+    const containsRule = new ContainsRule('maritalStatus.extension');
+    containsRule.items = ['maritalSlice'];
+    const sliceCardRule = new CardRule('maritalStatus.extension[maritalSlice].extension');
+    sliceCardRule.min = 1;
+    sliceCardRule.max = '2';
+    patientProfile.rules.push(containsRule, sliceCardRule);
+
+    exporter.exportStructDef(patientProfile);
+    const sd = pkg.profiles[0];
+    const valueElement = sd.findElement('Patient.maritalStatus.extension:maritalSlice.value[x]');
+
+    expect(valueElement.min).toEqual(0);
+    expect(valueElement.max).toEqual('0');
+    expect(loggerSpy.getAllLogs('error')).toHaveLength(0);
+  });
+
+  it('should correctly allow both extension and value[x] on profiles', () => {
+    const profile = new Profile('ExtendedObservation');
+    profile.parent = 'Observation';
+
+    const containsRule = new ContainsRule('extension');
+    containsRule.items = ['EvidenceType'];
+    const onlyRule = new OnlyRule('value[x]');
+    onlyRule.types = [{ type: 'string' }];
+    profile.rules.push(containsRule, onlyRule);
+
+    exporter.exportStructDef(profile);
+    const sd = pkg.profiles[0];
+
+    const valueElement = sd.findElement('Observation.value[x]');
+    const extensionElement = sd.findElement('Observation.extension');
+
+    expect(valueElement.min).toEqual(0);
+    expect(valueElement.max).toEqual('1');
+    expect(extensionElement.min).toEqual(0);
+    expect(extensionElement.max).toEqual('*');
+    expect(loggerSpy.getAllLogs('error')).toHaveLength(0);
+  });
+
+  it('should not add value[x] onto non-extension elements', () => {
+    const profile = new Profile('ExtendedPatient');
+    profile.parent = 'Patient';
+
+    const containsRule = new ContainsRule('extension');
+    containsRule.items = ['PatientNote'];
+    const cardRule = new CardRule('extension[PatientNote].extension');
+    cardRule.min = 1;
+    cardRule.max = '1';
+    profile.rules.push(containsRule, cardRule);
+
+    exporter.exportStructDef(profile);
+    const sd = pkg.profiles[0];
+
+    const extensionElement = sd.findElement('Patient.extension');
+    const sliceExtensionElement = sd.findElement('Patient.extension:PatientNote.extension');
+    const sliceValueElement = sd.findElement('Patient.extension:PatientNote.value[x]');
+
+    expect(extensionElement.min).toEqual(0);
+    expect(extensionElement.max).toEqual('*');
+    expect(sliceExtensionElement.min).toEqual(1);
+    expect(sliceExtensionElement.max).toEqual('1');
+    expect(sliceValueElement.min).toEqual(0);
+    expect(sliceValueElement.max).toEqual('0');
+    expect(loggerSpy.getAllLogs('error')).toHaveLength(0);
+  });
+
+  it('should set value[x] on nested elements of a profile without zeroing extension', () => {
+    const profile = new Profile('MyObservation');
+    profile.parent = 'Observation';
+
+    const onlyRule = new OnlyRule('component.value[x]');
+    onlyRule.types = [{ type: 'string' }];
+    const cardRule = new CardRule('component.extension');
+    cardRule.min = 1;
+    cardRule.max = '1';
+    profile.rules.push(onlyRule, cardRule);
+
+    exporter.exportStructDef(profile);
+    const sd = pkg.profiles[0];
+
+    const componentValueElement = sd.findElement('Observation.component.value[x]');
+    const componentExtensionElement = sd.findElement('Observation.component.extension');
+
+    expect(componentValueElement.type[0]).toEqual(new ElementDefinitionType('string'));
+    expect(componentExtensionElement.min).toEqual(1);
+    expect(componentExtensionElement.max).toEqual('1');
+    expect(loggerSpy.getAllLogs('error')).toHaveLength(0);
+  });
+
+  it('should not set inferred 0..0 CardRules if they were set on the FSH definition', () => {
+    const extension = new Extension('MyNoInferenceExtension');
+    extension.id = 'my-extension';
+
+    const containsRuleForExtension = new ContainsRule('extension');
+    containsRuleForExtension.items = ['sliceA', 'sliceB'];
+    extension.rules.push(containsRuleForExtension); // * extension contains sliceA, sliceB
+
+    // Manually zero out value[x]/extension where appropriate
+    const sliceAExtensionCardRule = new CardRule('extension[sliceA].extension');
+    sliceAExtensionCardRule.min = 1;
+    sliceAExtensionCardRule.max = '*'; // * extension[sliceA].extension 1..*
+    const sliceAValueCardRule = new CardRule('extension[sliceA].value[x]');
+    sliceAValueCardRule.min = 0;
+    sliceAValueCardRule.max = '0'; // * extension[sliceA].value[x] 0..0
+
+    const sliceBExtensionCardRule = new CardRule('extension[sliceB].value[x]');
+    sliceBExtensionCardRule.min = 1;
+    sliceBExtensionCardRule.max = '1'; // * extension[sliceB].extension 1..1
+    const sliceBValueCardRule = new CardRule('extension[sliceB].extension');
+    sliceBValueCardRule.min = 0;
+    sliceBValueCardRule.max = '0'; // * extension[sliceB].extension 0..0
+
+    extension.rules.push(
+      sliceAExtensionCardRule,
+      sliceAValueCardRule,
+      sliceBExtensionCardRule,
+      sliceBValueCardRule
+    );
+
+    exporter.exportStructDef(extension);
+    expect(extension.rules).toHaveLength(6);
+    expect(extension.rules).toEqual([
+      {
+        sourceInfo: {},
+        path: 'extension',
+        items: ['sliceA', 'sliceB']
+      },
+      {
+        sourceInfo: {},
+        path: 'extension[sliceA].extension',
+        min: 1,
+        max: '*'
+      },
+      {
+        sourceInfo: {},
+        path: 'extension[sliceA].value[x]',
+        min: 0,
+        max: '0'
+      },
+      {
+        sourceInfo: {},
+        path: 'extension[sliceB].value[x]',
+        min: 1,
+        max: '1'
+      },
+      {
+        sourceInfo: {},
+        path: 'extension[sliceB].extension',
+        min: 0,
+        max: '0'
+      },
+      { sourceInfo: {}, path: 'value[x]', min: 0, max: '0' } // The only rule inferred
+    ]);
+    expect(loggerSpy.getAllLogs('error')).toHaveLength(0);
   });
 
   // toJSON
diff --git a/test/fhirtypes/ElementDefinition.test.ts b/test/fhirtypes/ElementDefinition.test.ts
index e74e6aa..f945ca8 100644
--- a/test/fhirtypes/ElementDefinition.test.ts
+++ b/test/fhirtypes/ElementDefinition.test.ts
@@ -139,6 +139,24 @@ describe('ElementDefinition', () => {
     });
   });
 
+  describe('#getSlices', () => {
+    it('should get slices of an element', () => {
+      const component = observation.elements.find(e => e.path === 'Observation.component');
+      component.slicing = {
+        ordered: false,
+        rules: 'open',
+        discriminator: [{ type: 'value', path: 'code' }]
+      };
+      const fooSlice = component.addSlice('FooSlice');
+      const slices = component.getSlices();
+      const newElements = fooSlice.unfold(fisher);
+      const fooSliceExtension = newElements[1];
+      expect(slices).toHaveLength(1);
+      expect(slices[0].id).toEqual('Observation.component:FooSlice');
+      expect(fooSliceExtension.getSlices()).toEqual([]);
+    });
+  });
+
   describe('#hasDiff', () => {
     it('should always show a diff for brand new elements w/ no original captured', () => {
       const newElement = new ElementDefinition('newElement');