Repository: FHIR/sushi (ts)

Issue:
Support propagation of version, status, publisher to FSH-Only IGs
SUSHI 3.0 removed automatic propagation of version and status since the IG Publisher does this already (and in a more sophisticated manner). Authors using SUSHI in FSH-Only mode, however, don't use the IG Publisher, and therefore do not benefit from its built-in propagation of these properties.

We should consider adding back support for propagating these properties. In order to not introduce what might be deemed a breaking change, we should consider making this behavior opt-in via `sushi-config.yaml` configuration. We will need to determine the format of that config (multiple properties, single property w/ multiple values, etc) and whether it should apply only to FSH-Only IGs or to any IG that sets those config properties.

See: https://chat.fhir.org/#narrow/stream/179252-IG-creation/topic/Profiles.20Versioning/near/400510389

Pull request description:
Set version based on sushi-config.yaml in FSHOnly projects
Fixes #1380

This PR adds back support for using the version specified in `sushi-config.yaml` in Structure Definitions, Value Sets, and Code Systems as a default value in FSH Only projects. If the `version` is set with a rule, that will overwrite the version from the config file. This is only done in the case where `FSHOnly` is set to `true` in the config file because the IG Publisher will set the version when it is used.

Because `status` is already set using the value in `sushi-config.yaml` as a default in all projects, this should restore the functionality that was removed in SUSHI 3.x. As a reference, I was looking at PR #1143, which removed this functionality originally.

Patch:
diff --git a/src/export/CodeSystemExporter.ts b/src/export/CodeSystemExporter.ts
index 4a1a1e6..36ef050 100644
--- a/src/export/CodeSystemExporter.ts
+++ b/src/export/CodeSystemExporter.ts
@@ -38,7 +38,11 @@ export class CodeSystemExporter {
     }
     if (fshDefinition.title) codeSystem.title = fshDefinition.title;
     if (fshDefinition.description) codeSystem.description = fshDefinition.description;
-    delete codeSystem.version; // deleting to allow the IG Publisher default to take hold
+    if (this.tank.config.FSHOnly) {
+      codeSystem.version = this.tank.config.version;
+    } else {
+      delete codeSystem.version; // deleting to allow the IG Publisher default to take hold
+    }
     codeSystem.status = this.tank.config.status;
     codeSystem.url = `${this.tank.config.canonical}/CodeSystem/${codeSystem.id}`;
   }
diff --git a/src/export/StructureDefinitionExporter.ts b/src/export/StructureDefinitionExporter.ts
index 7c4a2f4..9b6a447 100644
--- a/src/export/StructureDefinitionExporter.ts
+++ b/src/export/StructureDefinitionExporter.ts
@@ -382,7 +382,11 @@ export class StructureDefinitionExporter implements Fishable {
       delete structDef.title;
     }
     structDef.status = this.tank.config.status;
-    delete structDef.version; // deleting to allow the IG Publisher default to take hold
+    if (this.tank.config.FSHOnly) {
+      structDef.version = this.tank.config.version;
+    } else {
+      delete structDef.version; // deleting to allow the IG Publisher default to take hold
+    }
     delete structDef.experimental;
     delete structDef.date;
     delete structDef.publisher;
diff --git a/src/export/ValueSetExporter.ts b/src/export/ValueSetExporter.ts
index bb34ae4..4e74631 100644
--- a/src/export/ValueSetExporter.ts
+++ b/src/export/ValueSetExporter.ts
@@ -52,7 +52,11 @@ export class ValueSetExporter {
     if (fshDefinition.description) {
       valueSet.description = fshDefinition.description;
     }
-    delete valueSet.version; // deleting to allow the IG Publisher default to take hold
+    if (this.tank.config.FSHOnly) {
+      valueSet.version = this.tank.config.version;
+    } else {
+      delete valueSet.version; // deleting to allow the IG Publisher default to take hold
+    }
     valueSet.status = this.tank.config.status;
     valueSet.url = `${this.tank.config.canonical}/ValueSet/${valueSet.id}`;
   }
diff --git a/src/import/importConfiguration.ts b/src/import/importConfiguration.ts
index f1a3d9e..e6da2cd 100644
--- a/src/import/importConfiguration.ts
+++ b/src/import/importConfiguration.ts
@@ -61,6 +61,7 @@ const MINIMAL_IG_ONLY_PROPERTIES = ['id', 'name', 'status', 'copyrightYear', 're
 const ALLOWED_FSH_ONLY_PROPERTIES = [
   ...MINIMAL_CONFIG_PROPERTIES,
   'version',
+  'status',
   'dependencies',
   'instanceOptions',
   'applyExtensionMetadataToRoot',


Test patch:
diff --git a/test/export/CodeSystemExporter.test.ts b/test/export/CodeSystemExporter.test.ts
index 165a14e..d1e5b9c 100644
--- a/test/export/CodeSystemExporter.test.ts
+++ b/test/export/CodeSystemExporter.test.ts
@@ -1,5 +1,6 @@
 import { loadFromPath } from 'fhir-package-loader';
 import path from 'path';
+import { cloneDeep } from 'lodash';
 import { CodeSystemExporter, Package } from '../../src/export';
 import { FSHDocument, FSHTank } from '../../src/import';
 import { FshCodeSystem, FshCode, RuleSet, Instance } from '../../src/fshtypes';
@@ -89,6 +90,33 @@ describe('CodeSystemExporter', () => {
     });
   });
 
+  it('should export a code system with status and version in FSHOnly mode', () => {
+    // Create a FSHOnly config with a status and version
+    const fshOnlyConfig = cloneDeep(minimalConfig);
+    fshOnlyConfig.FSHOnly = true;
+    fshOnlyConfig.version = '0.1.0';
+    fshOnlyConfig.status = 'active';
+    const input = new FSHTank([doc], fshOnlyConfig);
+    pkg = new Package(input.config);
+    const fisher = new TestFisher(input, defs, pkg);
+    exporter = new CodeSystemExporter(input, pkg, fisher);
+
+    const codeSystem = new FshCodeSystem('MyCodeSystem');
+    codeSystem.id = 'CodeSystem1';
+    doc.codeSystems.set(codeSystem.name, codeSystem);
+    const exported = exporter.export().codeSystems;
+    expect(exported.length).toBe(1);
+    expect(exported[0]).toEqual({
+      resourceType: 'CodeSystem',
+      name: 'MyCodeSystem',
+      id: 'CodeSystem1',
+      status: 'active',
+      version: '0.1.0',
+      content: 'complete',
+      url: 'http://hl7.org/fhir/us/minimal/CodeSystem/CodeSystem1'
+    });
+  });
+
   it('should export each code system once, even if export is called more than once', () => {
     const codeSystem = new FshCodeSystem('MyCodeSystem');
     doc.codeSystems.set(codeSystem.name, codeSystem);
diff --git a/test/export/StructureDefinitionExporter.test.ts b/test/export/StructureDefinitionExporter.test.ts
index ba72591..e66042e 100644
--- a/test/export/StructureDefinitionExporter.test.ts
+++ b/test/export/StructureDefinitionExporter.test.ts
@@ -38,6 +38,7 @@ import {
   StructureDefinitionMapping
 } from '../../src/fhirtypes';
 import path from 'path';
+import { cloneDeep } from 'lodash';
 import { withDebugLogging } from '../testhelpers/withDebugLogging';
 import { minimalConfig } from '../utils/minimalConfig';
 import { ValidationError } from '../../src/errors';
@@ -800,6 +801,33 @@ describe('StructureDefinitionExporter R4', () => {
       expect(exported.url).toBe('http://hl7.org/fhir/us/minimal/StructureDefinition/foo');
       expect(exported.type).toBe('Observation');
       expect(exported.baseDefinition).toBe('http://hl7.org/fhir/StructureDefinition/Observation');
+      expect(exported.version).toBeUndefined();
+      expect(exported.status).toBe('draft');
+    });
+
+    it('should set status and version metadata for a profile in FSHOnly mode', () => {
+      // Create a FSHOnly config with a status and version
+      const fshOnlyConfig = cloneDeep(minimalConfig);
+      fshOnlyConfig.FSHOnly = true;
+      fshOnlyConfig.version = '0.1.0';
+      fshOnlyConfig.status = 'active';
+      const input = new FSHTank([doc], fshOnlyConfig);
+      pkg = new Package(input.config);
+      const fisher = new TestFisher(input, defs, pkg);
+      exporter = new StructureDefinitionExporter(input, pkg, fisher);
+
+      const profile = new Profile('Foo');
+      profile.id = 'foo';
+      profile.parent = 'Observation';
+      doc.profiles.set(profile.name, profile);
+      exporter.exportStructDef(profile);
+      const exported = pkg.profiles[0];
+      expect(exported.name).toBe('Foo');
+      expect(exported.id).toBe('foo');
+      expect(exported.type).toBe('Observation');
+      expect(exported.baseDefinition).toBe('http://hl7.org/fhir/StructureDefinition/Observation');
+      expect(exported.version).toBe('0.1.0');
+      expect(exported.status).toBe('active');
     });
 
     it('should properly set/clear all metadata properties for a profile', () => {
@@ -822,6 +850,7 @@ describe('StructureDefinitionExporter R4', () => {
       expect(exported.name).toBe('Foo'); // provided by user
       expect(exported.title).toBeUndefined();
       expect(exported.status).toBe('draft'); // always draft
+      expect(exported.version).toBeUndefined();
       expect(exported.experimental).toBeUndefined();
       expect(exported.date).toBeUndefined();
       expect(exported.publisher).toBeUndefined();
@@ -1191,6 +1220,8 @@ describe('StructureDefinitionExporter R4', () => {
       expect(exported.description).toBe('foo bar foobar');
       expect(exported.elements[0].definition).toBe('foo bar foobar');
       expect(exported.url).toBe('http://hl7.org/fhir/us/minimal/StructureDefinition/foo');
+      expect(exported.status).toBe('draft');
+      expect(exported.version).toBeUndefined();
       expect(exported.type).toBe('Extension');
       expect(exported.baseDefinition).toBe('http://hl7.org/fhir/StructureDefinition/Extension');
       expect(exported.context).toEqual([
@@ -1214,6 +1245,28 @@ describe('StructureDefinitionExporter R4', () => {
       );
     });
 
+    it('should set status and version metadata for an extension in FSHOnly mode', () => {
+      // Create a FSHOnly config with a status and version
+      const fshOnlyConfig = cloneDeep(minimalConfig);
+      fshOnlyConfig.FSHOnly = true;
+      fshOnlyConfig.version = '0.1.0';
+      fshOnlyConfig.status = 'active';
+      const input = new FSHTank([doc], fshOnlyConfig);
+      pkg = new Package(input.config);
+      const fisher = new TestFisher(input, defs, pkg);
+      exporter = new StructureDefinitionExporter(input, pkg, fisher);
+
+      const extension = new Extension('Foo');
+      extension.id = 'foo';
+      doc.extensions.set(extension.name, extension);
+      exporter.exportStructDef(extension);
+      const exported = pkg.extensions[0];
+      expect(exported.name).toBe('Foo');
+      expect(exported.id).toBe('foo');
+      expect(exported.version).toBe('0.1.0');
+      expect(exported.status).toBe('active');
+    });
+
     it('should not set metadata on the root element when applyExtensionMetadataToRoot is false', () => {
       const config: Configuration = { ...minimalConfig, applyExtensionMetadataToRoot: false };
       const tank = new FSHTank([doc], config);
@@ -1512,11 +1565,35 @@ describe('StructureDefinitionExporter R4', () => {
       expect(exported.id).toBe('foo');
       expect(exported.title).toBe('Logical Foo Model');
       expect(exported.description).toBe('foo bar foobar');
+      expect(exported.status).toBe('draft');
+      expect(exported.version).toBeUndefined();
       expect(exported.url).toBe('http://hl7.org/fhir/us/minimal/StructureDefinition/foo');
       expect(exported.type).toBe('http://hl7.org/fhir/us/minimal/StructureDefinition/foo');
       expect(exported.baseDefinition).toBe('http://hl7.org/fhir/StructureDefinition/Base');
     });
 
+    it('should set status and version metadata for a logical model in FSHOnly mode', () => {
+      // Create a FSHOnly config with a status and version
+      const fshOnlyConfig = cloneDeep(minimalConfig);
+      fshOnlyConfig.FSHOnly = true;
+      fshOnlyConfig.version = '0.1.0';
+      fshOnlyConfig.status = 'active';
+      const input = new FSHTank([doc], fshOnlyConfig);
+      pkg = new Package(input.config);
+      const fisher = new TestFisher(input, defs, pkg);
+      exporter = new StructureDefinitionExporter(input, pkg, fisher);
+
+      const logical = new Logical('Foo');
+      logical.id = 'foo';
+      doc.logicals.set(logical.name, logical);
+      exporter.exportStructDef(logical);
+      const exported = pkg.logicals[0];
+      expect(exported.name).toBe('Foo');
+      expect(exported.id).toBe('foo');
+      expect(exported.version).toBe('0.1.0');
+      expect(exported.status).toBe('active');
+    });
+
     it('should properly set/clear all metadata properties for a logical model', () => {
       const logical = new Logical('Foo');
       logical.parent = 'AlternateIdentification';
@@ -1851,6 +1928,7 @@ describe('StructureDefinitionExporter R4', () => {
       expect(exported.title).toBe('Custom Foo Resource');
       expect(exported.description).toBe('foo bar foobar');
       expect(exported.url).toBe('http://hl7.org/fhir/us/minimal/StructureDefinition/foo');
+      expect(exported.status).toBe('draft');
       expect(exported.version).toBeUndefined();
       expect(exported.type).toBe('foo');
       expect(exported.baseDefinition).toBe(
@@ -1858,6 +1936,28 @@ describe('StructureDefinitionExporter R4', () => {
       );
     });
 
+    it('should set status and version metadata for a resource in FSHOnly mode', () => {
+      // Create a FSHOnly config with a status and version
+      const fshOnlyConfig = cloneDeep(minimalConfig);
+      fshOnlyConfig.FSHOnly = true;
+      fshOnlyConfig.version = '0.1.0';
+      fshOnlyConfig.status = 'active';
+      const input = new FSHTank([doc], fshOnlyConfig);
+      pkg = new Package(input.config);
+      const fisher = new TestFisher(input, defs, pkg);
+      exporter = new StructureDefinitionExporter(input, pkg, fisher);
+
+      const resource = new Resource('Foo');
+      resource.id = 'foo';
+      doc.resources.set(resource.name, resource);
+      exporter.exportStructDef(resource);
+      const exported = pkg.resources[0];
+      expect(exported.name).toBe('Foo');
+      expect(exported.id).toBe('foo');
+      expect(exported.version).toBe('0.1.0');
+      expect(exported.status).toBe('active');
+    });
+
     it('should properly set/clear all metadata properties for a resource', () => {
       const resource = new Resource('Foo');
       resource.parent = 'Resource';
diff --git a/test/export/ValueSetExporter.test.ts b/test/export/ValueSetExporter.test.ts
index d4e1483..3a7793b 100644
--- a/test/export/ValueSetExporter.test.ts
+++ b/test/export/ValueSetExporter.test.ts
@@ -13,6 +13,7 @@ import { loggerSpy } from '../testhelpers/loggerSpy';
 import { TestFisher } from '../testhelpers';
 import { FHIRDefinitions } from '../../src/fhirdefs';
 import path from 'path';
+import { cloneDeep } from 'lodash';
 import {
   CaretValueRule,
   InsertRule,
@@ -109,6 +110,31 @@ describe('ValueSetExporter', () => {
     });
   });
 
+  it('should export a value set with status and version in FSHOnly mode', () => {
+    // Create a FSHOnly config with a status and version
+    const fshOnlyConfig = cloneDeep(minimalConfig);
+    fshOnlyConfig.FSHOnly = true;
+    fshOnlyConfig.version = '0.1.0';
+    fshOnlyConfig.status = 'active';
+    const input = new FSHTank([doc], fshOnlyConfig);
+    pkg = new Package(input.config);
+    const fisher = new TestFisher(input, defs, pkg);
+    exporter = new ValueSetExporter(input, pkg, fisher);
+
+    const valueSet = new FshValueSet('BreakfastVS');
+    doc.valueSets.set(valueSet.name, valueSet);
+    const exported = exporter.export().valueSets;
+    expect(exported.length).toBe(1);
+    expect(exported[0]).toEqual({
+      resourceType: 'ValueSet',
+      name: 'BreakfastVS',
+      id: 'BreakfastVS',
+      status: 'active',
+      version: '0.1.0',
+      url: 'http://hl7.org/fhir/us/minimal/ValueSet/BreakfastVS'
+    });
+  });
+
   it('should warn when title and/or description is an empty string', () => {
     const valueSet = new FshValueSet('BreakfastVS');
     valueSet.title = '';
diff --git a/test/import/importConfiguration.test.ts b/test/import/importConfiguration.test.ts
index d7d61f8..cb8115f 100644
--- a/test/import/importConfiguration.test.ts
+++ b/test/import/importConfiguration.test.ts
@@ -2915,7 +2915,7 @@ describe('importConfiguration', () => {
       minYAML.FSHOnly = true;
       const config = importConfiguration(minYAML, 'test-config.yaml');
       expect(loggerSpy.getLastMessage('warn')).toMatch(
-        /The following properties are unused and only relevant for IG creation: id, name, status, copyrightYear, releaseLabel, template, menu, contained\..*File: test-config.yaml/s
+        /The following properties are unused and only relevant for IG creation: id, name, copyrightYear, releaseLabel, template, menu, contained\..*File: test-config.yaml/s
       );
       expect(config.FSHOnly).toBe(true);
     });