Repository: FHIR/sushi
Pull request #238 (2020-02-18 22:52:57)

Import Invariant and ObeysRule
An `ObeysRule` on a `Profile` or `Extension` references one or more `Invariant` instances. Messages are produced when:
* a system is specified for `ObeysRule.severity`, since the system is required to be `ConstraintSeverity`.
* a code other than `error` or `warning` is specified for `ObeysRule.severity`
See https://www.hl7.org/fhir/elementdefinition-definitions.html#ElementDefinition.constraint.severity, https://www.hl7.org/fhir/valueset-constraint-severity.html, and https://www.hl7.org/fhir/codesystem-constraint-severity.html for additional details. 

This pull request completes task https://standardhealthrecord.atlassian.net/browse/CIMPL-154.


Patch:
diff --git a/src/import/FSHDocument.ts b/src/import/FSHDocument.ts
index a11f494..a398345 100644
--- a/src/import/FSHDocument.ts
+++ b/src/import/FSHDocument.ts
@@ -1,4 +1,4 @@
-import { Profile, Extension, Instance, FshValueSet, FshCodeSystem } from '../fshtypes';
+import { Profile, Extension, Instance, FshValueSet, FshCodeSystem, Invariant } from '../fshtypes';
 
 export class FSHDocument {
   readonly aliases: Map<string, string>;
@@ -7,6 +7,7 @@ export class FSHDocument {
   readonly instances: Map<string, Instance>;
   readonly valueSets: Map<string, FshValueSet>;
   readonly codeSystems: Map<string, FshCodeSystem>;
+  readonly invariants: Map<string, Invariant>;
 
   constructor(public readonly file: string) {
     this.aliases = new Map();
@@ -15,5 +16,6 @@ export class FSHDocument {
     this.instances = new Map();
     this.valueSets = new Map();
     this.codeSystems = new Map();
+    this.invariants = new Map();
   }
 }
diff --git a/src/import/FSHImporter.ts b/src/import/FSHImporter.ts
index 6f4977d..930e18c 100644
--- a/src/import/FSHImporter.ts
+++ b/src/import/FSHImporter.ts
@@ -23,7 +23,8 @@ import {
   ValueSetFilter,
   VsOperator,
   ValueSetFilterValue,
-  FshCodeSystem
+  FshCodeSystem,
+  Invariant
 } from '../fshtypes';
 import {
   Rule,
@@ -34,7 +35,8 @@ import {
   FixedValueType,
   OnlyRule,
   ContainsRule,
-  CaretValueRule
+  CaretValueRule,
+  ObeysRule
 } from '../fshtypes/rules';
 import { ParserRuleContext, InputStream, CommonTokenStream } from 'antlr4';
 import { logger } from '../utils/FSHLogger';
@@ -77,6 +79,14 @@ enum CsMetadataKey {
   Unknown = 'Unknown'
 }
 
+enum InvariantMetadataKey {
+  Description = 'Description',
+  Expression = 'Expression',
+  XPath = 'XPath',
+  Severity = 'Severity',
+  Unknown = 'Unknown'
+}
+
 enum Flag {
   MustSupport,
   Summary,
@@ -191,6 +201,10 @@ export class FSHImporter extends FSHVisitor {
     if (ctx.codeSystem()) {
       this.visitCodeSystem(ctx.codeSystem());
     }
+
+    if (ctx.invariant()) {
+      this.visitInvariant(ctx.invariant());
+    }
   }
 
   visitProfile(ctx: pc.ProfileContext) {
@@ -428,6 +442,50 @@ export class FSHImporter extends FSHVisitor {
     });
   }
 
+  visitInvariant(ctx: pc.InvariantContext) {
+    const invariant = new Invariant(ctx.SEQUENCE().getText())
+      .withLocation(this.extractStartStop(ctx))
+      .withFile(this.currentFile);
+    this.parseInvariant(invariant, ctx.invariantMetadata());
+    if (invariant.description == null) {
+      logger.error(`Invariant ${invariant.name} must have a Description.`, invariant.sourceInfo);
+    }
+    if (invariant.severity == null) {
+      logger.error(`Invariant ${invariant.name} must have a Severity.`, invariant.sourceInfo);
+    }
+    this.currentDoc.invariants.set(invariant.name, invariant);
+  }
+
+  private parseInvariant(invariant: Invariant, metaCtx: pc.InvariantMetadataContext[] = []) {
+    const seenPairs: Map<InvariantMetadataKey, string | FshCode> = new Map();
+    metaCtx
+      .map(invariantMetadata => ({
+        ...this.visitInvariantMetadata(invariantMetadata),
+        context: invariantMetadata
+      }))
+      .forEach(pair => {
+        if (seenPairs.has(pair.key)) {
+          logger.error(
+            `Metadata field '${pair.key}' already declared with value '${seenPairs.get(
+              pair.key
+            )}'.`,
+            { file: this.currentFile, location: this.extractStartStop(pair.context) }
+          );
+          return;
+        }
+        seenPairs.set(pair.key, pair.value);
+        if (pair.key === InvariantMetadataKey.Description) {
+          invariant.description = pair.value as string;
+        } else if (pair.key === InvariantMetadataKey.Expression) {
+          invariant.expression = pair.value as string;
+        } else if (pair.key === InvariantMetadataKey.Severity) {
+          invariant.severity = pair.value as FshCode;
+        } else if (pair.key === InvariantMetadataKey.XPath) {
+          invariant.xpath = pair.value as string;
+        }
+      });
+  }
+
   visitSdMetadata(ctx: pc.SdMetadataContext): { key: SdMetadataKey; value: string } {
     if (ctx.id()) {
       return { key: SdMetadataKey.Id, value: this.visitId(ctx.id()) };
@@ -479,6 +537,36 @@ export class FSHImporter extends FSHVisitor {
     return { key: CsMetadataKey.Unknown, value: ctx.getText() };
   }
 
+  visitInvariantMetadata(
+    ctx: pc.InvariantMetadataContext
+  ): { key: InvariantMetadataKey; value: string | FshCode } {
+    if (ctx.description()) {
+      return {
+        key: InvariantMetadataKey.Description,
+        value: this.visitDescription(ctx.description())
+      };
+    } else if (ctx.expression()) {
+      return {
+        key: InvariantMetadataKey.Expression,
+        value: this.visitExpression(ctx.expression())
+      };
+    } else if (ctx.xpath()) {
+      return {
+        key: InvariantMetadataKey.XPath,
+        value: this.visitXpath(ctx.xpath())
+      };
+    } else if (ctx.severity()) {
+      return {
+        key: InvariantMetadataKey.Severity,
+        value: this.visitSeverity(ctx.severity())
+      };
+    }
+    return {
+      key: InvariantMetadataKey.Unknown,
+      value: ctx.getText()
+    };
+  }
+
   visitId(ctx: pc.IdContext): string {
     return ctx.SEQUENCE().getText();
   }
@@ -504,6 +592,54 @@ export class FSHImporter extends FSHVisitor {
     return this.aliasAwareValue(ctx.SEQUENCE().getText());
   }
 
+  visitExpression(ctx: pc.ExpressionContext): string {
+    return this.extractString(ctx.STRING());
+  }
+
+  visitXpath(ctx: pc.XpathContext): string {
+    return this.extractString(ctx.STRING());
+  }
+
+  visitSeverity(ctx: pc.SeverityContext): FshCode {
+    const concept = this.parseCodeLexeme(ctx.CODE().getText())
+      .withLocation(this.extractStartStop(ctx.CODE()))
+      .withFile(this.currentFile);
+    if (concept.system?.length > 0) {
+      logger.warn('Do not specify a system for invariant severity.', concept.sourceInfo);
+    }
+    if (concept.code != 'error' && concept.code != 'warning') {
+      logger.error(
+        'Invalid invariant severity code: code must be "#error" or "#warning".',
+        concept.sourceInfo
+      );
+    }
+    return concept;
+  }
+
+  private parseCodeLexeme(conceptText: string): FshCode {
+    const splitPoint = conceptText.match(/(^|[^\\])(\\\\)*#/);
+    let system: string, code: string;
+    if (splitPoint == null) {
+      system = '';
+      code = conceptText.slice(1);
+    } else {
+      system = conceptText.slice(0, splitPoint.index) + splitPoint[0].slice(0, -1);
+      code = conceptText.slice(splitPoint.index + splitPoint[0].length);
+    }
+    system = system.replace(/\\\\/g, '\\').replace(/\\#/g, '#');
+    if (code.startsWith('"')) {
+      code = code
+        .slice(1, code.length - 1)
+        .replace(/\\\\/g, '\\')
+        .replace(/\\"/g, '"');
+    }
+    const concept = new FshCode(code);
+    if (system.length > 0) {
+      concept.system = this.aliasAwareValue(system);
+    }
+    return concept;
+  }
+
   visitSdRule(ctx: pc.SdRuleContext): Rule[] {
     if (ctx.cardRule()) {
       return this.visitCardRule(ctx.cardRule());
@@ -519,6 +655,8 @@ export class FSHImporter extends FSHVisitor {
       return this.visitContainsRule(ctx.containsRule());
     } else if (ctx.caretValueRule()) {
       return [this.visitCaretValueRule(ctx.caretValueRule())];
+    } else if (ctx.obeysRule()) {
+      return this.visitObeysRule(ctx.obeysRule());
     }
     logger.warn(`Unsupported rule: ${ctx.getText()}`, {
       file: this.currentFile,
@@ -685,30 +823,9 @@ export class FSHImporter extends FSHVisitor {
   }
 
   visitCode(ctx: pc.CodeContext): FshCode {
-    // split on the first unescaped #
-    const conceptText = ctx.CODE().getText();
-    const splitPoint = conceptText.match(/(^|[^\\])(\\\\)*#/);
-    let system: string, code: string;
-    if (splitPoint == null) {
-      system = '';
-      code = conceptText.slice(1);
-    } else {
-      system = conceptText.slice(0, splitPoint.index) + splitPoint[0].slice(0, -1);
-      code = conceptText.slice(splitPoint.index + splitPoint[0].length);
-    }
-    system = system.replace(/\\\\/g, '\\').replace(/\\#/g, '#');
-    if (code.startsWith('"')) {
-      code = code
-        .slice(1, code.length - 1)
-        .replace(/\\\\/g, '\\')
-        .replace(/\\"/g, '"');
-    }
-    const concept = new FshCode(code)
+    const concept = this.parseCodeLexeme(ctx.CODE().getText())
       .withLocation(this.extractStartStop(ctx))
       .withFile(this.currentFile);
-    if (system && system.length > 0) {
-      concept.system = this.aliasAwareValue(system);
-    }
     if (ctx.STRING()) {
       concept.display = this.extractString(ctx.STRING());
     }
@@ -849,6 +966,19 @@ export class FSHImporter extends FSHVisitor {
     return caretValueRule;
   }
 
+  visitObeysRule(ctx: pc.ObeysRuleContext): ObeysRule[] {
+    const rules: ObeysRule[] = [];
+    const path = ctx.path() ? this.visitPath(ctx.path()) : '';
+    ctx.SEQUENCE().forEach(invariant => {
+      const obeysRule = new ObeysRule(path)
+        .withLocation(this.extractStartStop(ctx))
+        .withFile(this.currentFile);
+      obeysRule.invariant = invariant.getText();
+      rules.push(obeysRule);
+    });
+    return rules;
+  }
+
   visitVsComponent(ctx: pc.VsComponentContext): ValueSetComponent {
     const inclusion = ctx.KW_EXCLUDE() == null;
     let vsComponent: ValueSetConceptComponent | ValueSetFilterComponent;
diff --git a/src/import/parserContexts.ts b/src/import/parserContexts.ts
index 49afce6..e870873 100644
--- a/src/import/parserContexts.ts
+++ b/src/import/parserContexts.ts
@@ -11,7 +11,7 @@ export interface EntityContext extends ParserRuleContext {
   instance(): InstanceContext;
   valueSet(): ValueSetContext;
   codeSystem(): CodeSystemContext;
-  //invariant(): InvariantContext;
+  invariant(): InvariantContext;
 }
 
 export interface AliasContext extends ParserRuleContext {
@@ -75,6 +75,18 @@ export interface CsMetadataContext extends ParserRuleContext {
   description(): DescriptionContext;
 }
 
+export interface InvariantContext extends ParserRuleContext {
+  SEQUENCE(): ParserRuleContext;
+  invariantMetadata(): InvariantMetadataContext[];
+}
+
+export interface InvariantMetadataContext extends ParserRuleContext {
+  description(): DescriptionContext;
+  expression(): ExpressionContext;
+  xpath(): XpathContext;
+  severity(): SeverityContext;
+}
+
 export interface ParentContext extends ParserRuleContext {
   SEQUENCE(): ParserRuleContext;
 }
@@ -92,6 +104,18 @@ export interface DescriptionContext extends ParserRuleContext {
   MULTILINE_STRING(): ParserRuleContext;
 }
 
+export interface ExpressionContext extends ParserRuleContext {
+  STRING(): ParserRuleContext;
+}
+
+export interface XpathContext extends ParserRuleContext {
+  STRING(): ParserRuleContext;
+}
+
+export interface SeverityContext extends ParserRuleContext {
+  CODE(): ParserRuleContext;
+}
+
 export interface InstanceOfContext extends ParserRuleContext {
   SEQUENCE(): ParserRuleContext;
 }
@@ -103,7 +127,7 @@ export interface SdRuleContext extends ParserRuleContext {
   fixedValueRule(): FixedValueRuleContext;
   containsRule(): ContainsRuleContext;
   onlyRule(): OnlyRuleContext;
-  // obeysRule(): ObeysRuleContext;
+  obeysRule(): ObeysRuleContext;
   caretValueRule(): CaretValueRuleContext;
 }
 
@@ -222,6 +246,11 @@ export interface TargetTypeContext extends ParserRuleContext {
   reference(): ReferenceContext;
 }
 
+export interface ObeysRuleContext extends ParserRuleContext {
+  path(): PathContext;
+  SEQUENCE(): ParserRuleContext[];
+}
+
 export interface CaretValueRuleContext extends ParserRuleContext {
   path(): PathContext;
   caretPath(): CaretPathContext;


Test patch:
diff --git a/test/import/FSHImporter.Extension.test.ts b/test/import/FSHImporter.Extension.test.ts
index 1672154..89f2e2d 100644
--- a/test/import/FSHImporter.Extension.test.ts
+++ b/test/import/FSHImporter.Extension.test.ts
@@ -3,7 +3,8 @@ import {
   assertFlagRule,
   assertOnlyRule,
   assertValueSetRule,
-  assertCaretValueRule
+  assertCaretValueRule,
+  assertObeysRule
 } from '../testhelpers/asserts';
 import { loggerSpy } from '../testhelpers/loggerSpy';
 import { importSingleText } from '../testhelpers/importSingleText';
@@ -190,5 +191,20 @@ describe('FSHImporter', () => {
         assertCaretValueRule(extension.rules[0], 'id', 'short', 'foo');
       });
     });
+
+    describe('#obeysRule', () => {
+      it('should parse an obeys rule with a path and multiple invariants', () => {
+        const input = `
+        Extension: SomeExtension
+        * extension obeys inv-1 and inv-2
+        `;
+
+        const result = importSingleText(input);
+        const extension = result.extensions.get('SomeExtension');
+        expect(extension.rules).toHaveLength(2);
+        assertObeysRule(extension.rules[0], 'extension', 'inv-1');
+        assertObeysRule(extension.rules[1], 'extension', 'inv-2');
+      });
+    });
   });
 });
diff --git a/test/import/FSHImporter.Invariant.test.ts b/test/import/FSHImporter.Invariant.test.ts
new file mode 100644
index 0000000..e329190
--- /dev/null
+++ b/test/import/FSHImporter.Invariant.test.ts
@@ -0,0 +1,151 @@
+import { importSingleText } from '../testhelpers/importSingleText';
+import { FshCode } from '../../src/fshtypes';
+import { loggerSpy } from '../testhelpers/loggerSpy';
+
+describe('FSHImporter', () => {
+  describe('Invariant', () => {
+    describe('#invariantMetadata', () => {
+      it('should parse the simplest possible invariant', () => {
+        const input = `
+        Invariant: emp-1
+        Severity: #error
+        Description: "This does not actually require anything."
+        `;
+        const result = importSingleText(input, 'Empty.fsh');
+        expect(result.invariants.size).toBe(1);
+        const invariant = result.invariants.get('emp-1');
+        expect(invariant.name).toBe('emp-1');
+        const severityCode = new FshCode('error')
+          .withLocation([3, 19, 3, 24])
+          .withFile('Empty.fsh');
+        expect(invariant.severity).toEqual(severityCode);
+        expect(invariant.description).toBe('This does not actually require anything.');
+        expect(invariant.sourceInfo.location).toEqual({
+          startLine: 2,
+          startColumn: 9,
+          endLine: 4,
+          endColumn: 63
+        });
+        expect(invariant.sourceInfo.file).toBe('Empty.fsh');
+      });
+
+      it('should parse an invariant with additional metadata', () => {
+        const input = `
+        Invariant: full-1
+        Description: "This resource must define a cage and aquarium."
+        Expression: "cage.exists() and aquarium.exists()"
+        XPath: "exists(f:cage) and exists(f:aquarium)"
+        Severity: #error
+        `;
+        const result = importSingleText(input, 'Full.fsh');
+        expect(result.invariants.size).toBe(1);
+        const invariant = result.invariants.get('full-1');
+        expect(invariant.name).toBe('full-1');
+        expect(invariant.description).toBe('This resource must define a cage and aquarium.');
+        expect(invariant.expression).toBe('cage.exists() and aquarium.exists()');
+        expect(invariant.xpath).toBe('exists(f:cage) and exists(f:aquarium)');
+        const severityCode = new FshCode('error').withLocation([6, 19, 6, 24]).withFile('Full.fsh');
+        expect(invariant.severity).toEqual(severityCode);
+        expect(invariant.sourceInfo.location).toEqual({
+          startLine: 2,
+          startColumn: 9,
+          endLine: 6,
+          endColumn: 24
+        });
+        expect(invariant.sourceInfo.file).toBe('Full.fsh');
+      });
+
+      it('should only apply each metadata attribute the first time it is declared', () => {
+        const input = `
+        Invariant: twice-1
+        Description: "This resource is described."
+        Expression: "description.exists()"
+        XPath: "exists(f:description)"
+        Severity: #error
+        Description: "This resource is not described."
+        Expression: "not(description.exists())"
+        XPath: "not(exists(f:description))"
+        Severity: #warning
+        `;
+        const result = importSingleText(input, 'Twice.fsh');
+        expect(result.invariants.size).toBe(1);
+        const invariant = result.invariants.get('twice-1');
+        expect(invariant.name).toBe('twice-1');
+        expect(invariant.description).toBe('This resource is described.');
+        expect(invariant.expression).toBe('description.exists()');
+        expect(invariant.xpath).toBe('exists(f:description)');
+        const severityCode = new FshCode('error')
+          .withLocation([6, 19, 6, 24])
+          .withFile('Twice.fsh');
+        expect(invariant.severity).toEqual(severityCode);
+      });
+
+      it('should log an error when encountering a duplicate metadata attribute', () => {
+        const input = `
+        Invariant: twice-1
+        Description: "This resource is described."
+        Expression: "description.exists()"
+        XPath: "exists(f:description)"
+        Severity: #error
+        Description: "This resource is not described."
+        Expression: "not(description.exists())"
+        XPath: "not(exists(f:description))"
+        Severity: #warning
+        `;
+        importSingleText(input, 'Twice.fsh');
+        expect(loggerSpy.getMessageAtIndex(-4, 'error')).toMatch(/File: Twice\.fsh.*Line: 7\D*/s);
+        expect(loggerSpy.getMessageAtIndex(-3, 'error')).toMatch(/File: Twice\.fsh.*Line: 8\D*/s);
+        expect(loggerSpy.getMessageAtIndex(-2, 'error')).toMatch(/File: Twice\.fsh.*Line: 9\D*/s);
+        expect(loggerSpy.getLastMessage('error')).toMatch(/File: Twice\.fsh.*Line: 10\D*/s);
+      });
+
+      it('should log an error when there is no severity metadata', () => {
+        const input = `
+        Invariant: what-1
+        Description: "I don't know how important this is."
+        `;
+        importSingleText(input, 'What.fsh');
+        expect(loggerSpy.getLastMessage('error')).toMatch(/File: What\.fsh.*Line: 2\D+3\D*/s);
+      });
+
+      it('should log an error when there is no description metadata', () => {
+        const input = `
+        Invariant: bad-1
+        Severity: #warning
+        `;
+        importSingleText(input, 'Bad.fsh');
+        expect(loggerSpy.getLastMessage('error')).toMatch(/File: Bad\.fsh.*Line: 2\D+3\D*/s);
+      });
+
+      it('should log a warning when the severity code includes a system', () => {
+        const input = `
+        Invariant: un-1
+        Severity: https://unnecessary.org#error
+        Description: "You don't need that system."
+        `;
+        const result = importSingleText(input, 'Unnecessary.fsh');
+        expect(result.invariants.size).toBe(1);
+        const invariant = result.invariants.get('un-1');
+        const severityCode = new FshCode('error', 'https://unnecessary.org')
+          .withLocation([3, 19, 3, 47])
+          .withFile('Unnecessary.fsh');
+        expect(invariant.severity).toEqual(severityCode);
+        expect(loggerSpy.getLastMessage('warn')).toMatch(/File: Unnecessary\.fsh.*Line: 3\D*/s);
+      });
+
+      it('should log an error when the severity code is invalid', () => {
+        const input = `
+        Invariant: nope-3
+        Severity: #nope
+        Description: "Nope is not a real severity."
+        `;
+        const result = importSingleText(input, 'Nope.fsh');
+        expect(result.invariants.size).toBe(1);
+        const invariant = result.invariants.get('nope-3');
+        const severityCode = new FshCode('nope').withLocation([3, 19, 3, 23]).withFile('Nope.fsh');
+        expect(invariant.severity).toEqual(severityCode);
+        expect(loggerSpy.getLastMessage('error')).toMatch(/File: Nope\.fsh.*Line: 3\D*/s);
+      });
+    });
+  });
+});
diff --git a/test/import/FSHImporter.Profile.test.ts b/test/import/FSHImporter.Profile.test.ts
index f20fd24..637f1c5 100644
--- a/test/import/FSHImporter.Profile.test.ts
+++ b/test/import/FSHImporter.Profile.test.ts
@@ -5,7 +5,8 @@ import {
   assertOnlyRule,
   assertValueSetRule,
   assertContainsRule,
-  assertCaretValueRule
+  assertCaretValueRule,
+  assertObeysRule
 } from '../testhelpers/asserts';
 import { FshCode, FshQuantity, FshRatio, FshReference } from '../../src/fshtypes';
 import { loggerSpy } from '../testhelpers/loggerSpy';
@@ -889,9 +890,19 @@ describe('FSHImporter', () => {
     });
 
     describe('#obeysRule', () => {
-      // the current importer does not support obeys rule.
-      // this test should be removed once obeys rules are supported.
-      it('should issue a message when parsing an obeys rule', () => {
+      it('should parse an obeys rule with one invariant and no path', () => {
+        const input = `
+        Profile: ObservationProfile
+        Parent: Observation
+        * obeys SomeInvariant
+        `;
+        const result = importSingleText(input, 'Obeys.fsh');
+        const profile = result.profiles.get('ObservationProfile');
+        expect(profile.rules).toHaveLength(1);
+        assertObeysRule(profile.rules[0], '', 'SomeInvariant');
+      });
+
+      it('should parse an obeys rule with one invariant and a path', () => {
         const input = `
         Profile: ObservationProfile
         Parent: Observation
@@ -899,8 +910,36 @@ describe('FSHImporter', () => {
         `;
         const result = importSingleText(input, 'Obeys.fsh');
         const profile = result.profiles.get('ObservationProfile');
-        expect(profile.rules).toHaveLength(0);
-        expect(loggerSpy.getLastMessage('warn')).toMatch(/File: Obeys\.fsh.*Line: 4\D*/s);
+        expect(profile.rules).toHaveLength(1);
+        assertObeysRule(profile.rules[0], 'category', 'SomeInvariant');
+      });
+
+      it('should parse an obeys rule with multiple invariants and no path', () => {
+        const input = `
+        Profile: ObservationProfile
+        Parent: Observation
+        * obeys SomeInvariant and ThisInvariant and ThatInvariant
+        `;
+        const result = importSingleText(input, 'Obeys.fsh');
+        const profile = result.profiles.get('ObservationProfile');
+        expect(profile.rules).toHaveLength(3);
+        assertObeysRule(profile.rules[0], '', 'SomeInvariant');
+        assertObeysRule(profile.rules[1], '', 'ThisInvariant');
+        assertObeysRule(profile.rules[2], '', 'ThatInvariant');
+      });
+
+      it('should parse an obeys rule with multiple invariants and a path', () => {
+        const input = `
+        Profile: ObservationProfile
+        Parent: Observation
+        * category obeys SomeInvariant and ThisInvariant and ThatInvariant
+        `;
+        const result = importSingleText(input, 'Obeys.fsh');
+        const profile = result.profiles.get('ObservationProfile');
+        expect(profile.rules).toHaveLength(3);
+        assertObeysRule(profile.rules[0], 'category', 'SomeInvariant');
+        assertObeysRule(profile.rules[1], 'category', 'ThisInvariant');
+        assertObeysRule(profile.rules[2], 'category', 'ThatInvariant');
       });
     });
   });
diff --git a/test/testhelpers/asserts.ts b/test/testhelpers/asserts.ts
index b65bdc3..e7bf8bc 100644
--- a/test/testhelpers/asserts.ts
+++ b/test/testhelpers/asserts.ts
@@ -8,7 +8,8 @@ import {
   OnlyRule,
   OnlyRuleType,
   ContainsRule,
-  CaretValueRule
+  CaretValueRule,
+  ObeysRule
 } from '../../src/fshtypes/rules';
 import {
   ValueSetComponent,
@@ -89,6 +90,13 @@ export function assertCaretValueRule(
   expect(caretValueRule.value).toEqual(value);
 }
 
+export function assertObeysRule(rule: Rule, path: string, invariant: string) {
+  expect(rule).toBeInstanceOf(ObeysRule);
+  const obeysRule = rule as ObeysRule;
+  expect(obeysRule.path).toBe(path);
+  expect(obeysRule.invariant).toBe(invariant);
+}
+
 export function assertValueSetConceptComponent(
   component: ValueSetComponent,
   fromSystem: string,