Repository: FHIR/sushi (ts)

Issue:
Add definition to sushi-config.yaml to support extensions on ImplementationGuide.definition
Some extensions, such as [ig-link-dependency](https://hl7.org/fhir/tools/0.1.0/StructureDefinition-ig-link-dependency.html) have context `ImplementationGuide.definition`. Since `sushi-config.yaml` abstracts away the `ImplementationGuide.definition` element, however, there is nowhere to put these extensions in `sushi-config.yaml`.

Consider allowing a top-level `definition` property on `sushi-config.yaml` so that there is somewhere to hang these extensions.

Pull request description:
Support extensions on IG.definition in sushi-config.yaml
Fixes #1440

This PR adds the option to include extensions directly on IG.definition. Right now, _only_ extensions are allowed on the definition property in the configuration file. All the other properties still need to use their top-level configuration properties.

This will also require an update to FSH School to reflect the new configuration property. Once the approach here is set, I can make a PR with the new information there.

Patch:
diff --git a/src/fhirtypes/ImplementationGuide.ts b/src/fhirtypes/ImplementationGuide.ts
index beb596e..2749c20 100644
--- a/src/fhirtypes/ImplementationGuide.ts
+++ b/src/fhirtypes/ImplementationGuide.ts
@@ -55,6 +55,7 @@ export type ImplementationGuideDefinition = {
   page?: ImplementationGuideDefinitionPage;
   parameter?: ImplementationGuideDefinitionParameter[];
   template?: ImplementationGuideDefinitionTemplate[];
+  extension?: Extension[];
 };
 
 export type ImplementationGuideDefinitionGrouping = {
diff --git a/src/fshtypes/Configuration.ts b/src/fshtypes/Configuration.ts
index e163fc3..8416721 100644
--- a/src/fshtypes/Configuration.ts
+++ b/src/fshtypes/Configuration.ts
@@ -80,6 +80,8 @@ export type Configuration = {
   // the correct URL when generating the IG JSON.
   groups?: ConfigurationGroup[];
 
+  definition?: ConfigurationDefinition;
+
   // The resources property corresponds to IG.definition.resource. SUSHI can auto-generate all of
   // the resource entries based on the FSH definitions and/or information in any user-provided
   // JSON resource files. If the generated entries are not sufficient or complete, however, the
@@ -153,6 +155,11 @@ export type ConfigurationGroup = {
   resources?: string[];
 };
 
+export type ConfigurationDefinition = {
+  // NOTE: all other IG.definition properties have a top-level configuration property
+  extension?: Extension[];
+};
+
 export type ConfigurationResource = ImplementationGuideDefinitionResource & { omit?: boolean };
 
 export type ConfigurationMenuItem = {
diff --git a/src/ig/IGExporter.ts b/src/ig/IGExporter.ts
index a2fe001..debc912 100644
--- a/src/ig/IGExporter.ts
+++ b/src/ig/IGExporter.ts
@@ -171,6 +171,7 @@ export class IGExporter {
       dependsOn: [],
       global: this.config.global,
       definition: {
+        extension: this.config.definition?.extension,
         // put an empty grouping here to preserve the location of this property (delete later if not needed)
         grouping: [],
         resource: [],
diff --git a/src/import/YAMLConfiguration.ts b/src/import/YAMLConfiguration.ts
index 73c3e5b..6f51ef0 100644
--- a/src/import/YAMLConfiguration.ts
+++ b/src/import/YAMLConfiguration.ts
@@ -99,6 +99,11 @@ export type YAMLConfiguration = {
   // generating the IG JSON.
   global?: YAMLConfigurationGlobalMap;
 
+  // NOTE: All of the properties of IG.definition are abstracted to their own top-level configuration
+  // property. This definition property should only be used to provide extensions that have a context
+  // of IG.definition.
+  definition?: YAMLConfigurationDefinition;
+
   // Groups can control certain aspects of the IG generation.  The IG documentation recommends that
   // authors use the default groups that are provided by the templating framework, but if authors
   // want to use their own instead, they can use the mechanism below.  This will create
@@ -331,6 +336,10 @@ export type YAMLConfigurationGlobalMap = {
     | ImplementationGuideGlobal['profile'][]; // string[]
 };
 
+export type YAMLConfigurationDefinition = {
+  extension?: Extension[];
+};
+
 export type YAMLConfigurationGroupMap = {
   [key: string]: {
     name?: ImplementationGuide['name'];
diff --git a/src/import/YAMLschema.json b/src/import/YAMLschema.json
index 882545a..e48e470 100644
--- a/src/import/YAMLschema.json
+++ b/src/import/YAMLschema.json
@@ -178,6 +178,18 @@
         ]
       }
     },
+    "definition": {
+      "type": "object",
+      "properties": {
+        "extension": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/extension"
+          }
+        }
+      },
+      "additionalProperties": false
+    },
     "groups": {
       "type": "object",
       "additionalProperties": {
diff --git a/src/import/importConfiguration.ts b/src/import/importConfiguration.ts
index 937e892..f1a3d9e 100644
--- a/src/import/importConfiguration.ts
+++ b/src/import/importConfiguration.ts
@@ -6,6 +6,7 @@ import {
   YAMLConfigurationMenuTree,
   YAMLConfigurationDependencyMap,
   YAMLConfigurationGlobalMap,
+  YAMLConfigurationDefinition,
   YAMLConfigurationGroupMap,
   YAMLConfigurationResourceMap,
   YAMLConfigurationPageTree,
@@ -20,6 +21,7 @@ import {
 import YAML_SCHEMA from './YAMLschema.json';
 import {
   Configuration,
+  ConfigurationDefinition,
   ConfigurationGroup,
   ConfigurationResource,
   ConfigurationMenuItem,
@@ -164,6 +166,7 @@ export function importConfiguration(yaml: YAMLConfiguration | string, file: stri
     dependencies: parseDependencies(yaml.dependencies),
     global: parseGlobal(yaml.global),
     groups: parseGroups(yaml.groups),
+    definition: parseDefinition(yaml.definition),
     resources: parseResources(yaml.resources, file),
     pages: parsePages(yaml.pages, file),
     parameters: parseParameters(yaml, yaml.FSHOnly, file),
@@ -624,6 +627,15 @@ function parseGroups(yamlGroups: YAMLConfigurationGroupMap): ConfigurationGroup[
   });
 }
 
+function parseDefinition(yamlDefinition: YAMLConfigurationDefinition): ConfigurationDefinition {
+  // NOTE: extension is the only property allowed to be set directly on definition in config file
+  if (yamlDefinition == null || yamlDefinition.extension == null) {
+    return;
+  }
+
+  return { extension: yamlDefinition.extension };
+}
+
 function parseResources(
   yamlResources: YAMLConfigurationResourceMap,
   file: string
@@ -930,6 +942,10 @@ function detectPotentialMistakes(yaml: YAMLConfiguration) {
           recommendation += ` If ${
             singular ? 'this is a page, it' : 'these are pages, they'
           } should end with .md, .xml, or .html.`;
+        } else if (instancePath.startsWith('/definition')) {
+          recommendation =
+            'Only the extension property is allowed under definition. All other definition ' +
+            'properties are represented at the top-level of the configuration.';
         }
         logger.warn(
           `Configuration ${parentProperty}contains unexpected ${


Test patch:
diff --git a/test/ig/IGExporter.IG.test.ts b/test/ig/IGExporter.IG.test.ts
index 9524774..0f3965b 100644
--- a/test/ig/IGExporter.IG.test.ts
+++ b/test/ig/IGExporter.IG.test.ts
@@ -128,6 +128,14 @@ describe('IGExporter', () => {
             ]
           }
         ],
+        definition: {
+          extension: [
+            {
+              url: 'http://example.org/example/ig-definition-ext',
+              valueBoolean: true
+            }
+          ]
+        },
         resources: [
           {
             reference: { reference: 'Patient/patient-example' },
@@ -262,6 +270,7 @@ describe('IGExporter', () => {
           }
         ],
         definition: {
+          extension: [{ url: 'http://example.org/example/ig-definition-ext', valueBoolean: true }],
           resource: [
             // resources are ordered by name (case-insensitive)
             {
@@ -1185,6 +1194,7 @@ describe('IGExporter', () => {
           }
         ],
         definition: {
+          // NOTE: No definition.extension added in config, so not included here
           resource: [
             {
               reference: {
diff --git a/test/ig/IGExporter.test.ts b/test/ig/IGExporter.test.ts
index 65d4ccc..c4bd588 100644
--- a/test/ig/IGExporter.test.ts
+++ b/test/ig/IGExporter.test.ts
@@ -58,6 +58,7 @@ describe('IGExporter', () => {
       const igContent = fs.readJSONSync(igPath);
 
       expect(igContent.id).toBe('fhir.us.minimal');
+      expect(igContent.definition.extension).toBeUndefined();
       expect(igContent.definition.resource).toHaveLength(0);
     });
   });
@@ -90,6 +91,9 @@ describe('IGExporter', () => {
         'ImplementationGuide-fhir.us.example.json'
       );
       const igContent = fs.readJSONSync(igPath);
+      expect(igContent.definition.extension).toEqual([
+        { url: 'http://example.org/example/ig-definition-ext', valueBoolean: true }
+      ]);
       expect(igContent.definition.grouping).toHaveLength(2);
       expect(igContent.definition.grouping[0].id).toBe('GroupA');
       expect(igContent.definition.grouping[0].name).toBe('Group A');
diff --git a/test/import/YAMLConfiguration.test.ts b/test/import/YAMLConfiguration.test.ts
index d438655..bede472 100644
--- a/test/import/YAMLConfiguration.test.ts
+++ b/test/import/YAMLConfiguration.test.ts
@@ -67,6 +67,14 @@ describe('YAMLConfiguration', () => {
         Patient: 'http://example.org/fhir/StructureDefinition/my-patient-profile',
         Encounter: 'http://example.org/fhir/StructureDefinition/my-encounter-profile'
       });
+      expect(config.definition).toEqual({
+        extension: [
+          {
+            url: 'http://example.org/example/ig-definition-ext',
+            valueBoolean: true
+          }
+        ]
+      });
       expect(config.resources).toEqual({
         'Patient/my-example-patient': {
           name: 'My Example Patient',
diff --git a/test/import/fixtures/example-config.yaml b/test/import/fixtures/example-config.yaml
index 1b64126..56d2557 100644
--- a/test/import/fixtures/example-config.yaml
+++ b/test/import/fixtures/example-config.yaml
@@ -86,6 +86,15 @@ global:
   Patient: http://example.org/fhir/StructureDefinition/my-patient-profile
   Encounter: http://example.org/fhir/StructureDefinition/my-encounter-profile
 
+# NOTE: All of the properties of IG.definition are abstracted to
+# individual top-level configuration properties (below). This
+# definition property should only be used to provide extensions
+# that have a context of IG.definition.
+definition:
+  extension:
+    - url: http://example.org/example/ig-definition-ext
+      valueBoolean: true
+
 # The resources property corresponds to IG.definition.resource.
 # SUSHI can auto-generate all of the resource entries based on
 # the FSH definitions and/or information in any user-provided
diff --git a/test/import/fixtures/maximal-config.yaml b/test/import/fixtures/maximal-config.yaml
index a515854..2371410 100644
--- a/test/import/fixtures/maximal-config.yaml
+++ b/test/import/fixtures/maximal-config.yaml
@@ -84,6 +84,15 @@ global:
   Patient: http://example.org/fhir/StructureDefinition/my-patient-profile
   Encounter: http://example.org/fhir/StructureDefinition/my-encounter-profile
 
+# NOTE: All of the properties of IG.definition are abstracted to
+# individual top-level configuration properties (below). This
+# definition property should only be used to provide extensions
+# that have a context of IG.definition.
+definition:
+  extension:
+    - url: http://example.org/example/ig-definition-ext
+      valueBoolean: true
+
 # The resources property corresponds to IG.definition.resource.
 # SUSHI can auto-generate all of the resource entries based on
 # the FSH definitions and/or information in any user-provided
diff --git a/test/import/importConfiguration.test.ts b/test/import/importConfiguration.test.ts
index 68b3a73..d7d61f8 100644
--- a/test/import/importConfiguration.test.ts
+++ b/test/import/importConfiguration.test.ts
@@ -115,6 +115,14 @@ describe('importConfiguration', () => {
           profile: 'http://example.org/fhir/StructureDefinition/my-encounter-profile'
         }
       ],
+      definition: {
+        extension: [
+          {
+            url: 'http://example.org/example/ig-definition-ext',
+            valueBoolean: true
+          }
+        ]
+      },
       resources: [
         {
           reference: { reference: 'Patient/my-example-patient' },
@@ -294,6 +302,14 @@ describe('importConfiguration', () => {
           profile: 'http://example.org/fhir/StructureDefinition/my-encounter-profile'
         }
       ],
+      definition: {
+        extension: [
+          {
+            url: 'http://example.org/example/ig-definition-ext',
+            valueBoolean: true
+          }
+        ]
+      },
       resources: [
         {
           reference: { reference: 'Patient/my-example-patient' },
@@ -488,6 +504,10 @@ describe('importConfiguration', () => {
     minYAML['index.md'] = {
       title: 'IG Home'
     };
+    minYAML.definition = {
+      // @ts-ignore
+      resource: [{ reference: { reference: 'Patient/my-example-patient' } }]
+    };
     const actual = importConfiguration(minYAML, 'test-config.yaml');
     const expected: Configuration = {
       filePath: 'test-config.yaml',
@@ -510,9 +530,13 @@ describe('importConfiguration', () => {
     };
     expect(actual).toEqual(expected);
     expect(loggerSpy.getAllLogs('error')).toHaveLength(0);
-    expect(loggerSpy.getLastMessage('warn')).toMatch(
+    expect(loggerSpy.getAllLogs('warn')).toHaveLength(2);
+    expect(loggerSpy.getMessageAtIndex(0, 'warn')).toMatch(
       'Configuration contains unexpected properties: cookie, index.md. Check that these properties are spelled, capitalized, and indented correctly.'
     );
+    expect(loggerSpy.getMessageAtIndex(1, 'warn')).toMatch(
+      'Configuration property definition contains unexpected property: resource. Only the extension property is allowed under definition. All other definition properties are represented at the top-level of the configuration.'
+    );
   });
 
   it('should report an error and throw on an invalid YAML config', () => {
@@ -1846,6 +1870,47 @@ describe('importConfiguration', () => {
     });
   });
 
+  describe('#definition', () => {
+    it('should convert the definition extensions to a list', () => {
+      minYAML.definition = {
+        extension: [{ url: 'http://example.org/example/ig-definition-ext', valueBoolean: true }]
+      };
+      const config = importConfiguration(minYAML, 'test-config.yaml');
+      expect(config.definition).toEqual({
+        extension: [
+          {
+            url: 'http://example.org/example/ig-definition-ext',
+            valueBoolean: true
+          }
+        ]
+      });
+    });
+    it('should not convert any extra definition properties', () => {
+      minYAML.definition = {
+        extension: [{ url: 'http://example.org/example/ig-definition-ext', valueBoolean: true }],
+        // @ts-ignore
+        reference: { reference: 'Patient/my-example-patient' }
+      };
+      const config = importConfiguration(minYAML, 'test-config.yaml');
+      expect(config.definition).toEqual({
+        extension: [
+          {
+            url: 'http://example.org/example/ig-definition-ext',
+            valueBoolean: true
+          }
+        ]
+      });
+    });
+    it('should not include a definition property if no extensions are provided', () => {
+      minYAML.definition = {
+        // @ts-ignore
+        reference: { reference: 'Patient/my-example-patient' }
+      };
+      const config = importConfiguration(minYAML, 'test-config.yaml');
+      expect(config.definition).toBeUndefined();
+    });
+  });
+
   describe('#groups', () => {
     it('should convert the groups map to a list', () => {
       minYAML.groups = {