From 2350518807755bb39a40289026dd20361b462303 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Thu, 23 Jul 2026 16:10:25 +0400 Subject: [PATCH 1/2] fix(converter): derive choices only from the explicit type ceiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice names no longer populate the choice declaration's choices list. Slices constrain types the element already allows, so for a typed root they add nothing, and for an untyped root under open rules emitting them as choices misstates the allowed set — the inherited ceiling stays in effect. When the differential does not restate the element's type list, choices is omitted entirely. --- src/converter/choice-handler.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/converter/choice-handler.ts b/src/converter/choice-handler.ts index 7f88cd5..3ce2c5d 100644 --- a/src/converter/choice-handler.ts +++ b/src/converter/choice-handler.ts @@ -38,9 +38,15 @@ function isChoiceTypeSlicingRoot(element: StructureDefinitionElement): boolean { /** * Collapse type slicing on choice elements ($this type discriminator) into the * plain choice representation: the sliced element becomes the choice declaration - * (accumulating every variant in `choices` and keeping the slicing metadata so - * open/closed rules survive), and each slice becomes a regular typed variant - * element. Children of a slice are re-parented under its variant element. + * (keeping the slicing metadata so open/closed rules survive), and each slice + * becomes a regular typed variant element. Children of a slice are re-parented + * under its variant element. + * + * `choices` is derived only from the explicit ElementDefinition.type ceiling — + * never from slice names. Slices constrain types the element already allows, so + * for a typed root the slice set adds nothing, and for an untyped root under + * open rules the slices are additive documentation: emitting them as `choices` + * would misstate the allowed set (the inherited ceiling stays in effect). */ export function collapseChoiceTypeSlicing( elements: StructureDefinitionElement[], @@ -71,10 +77,8 @@ export function collapseChoiceTypeSlicing( const el = elements[j]; if (el.path === rootPath && el.sliceName && el.type?.length === 1) { const typeName = capitalize(canonicalToName(el.type[0].code)); - const variantName = fieldName + typeName; variantPath = basePath + typeName; - if (!choices.includes(variantName)) choices.push(variantName); - slicedVariants.add(variantName); + slicedVariants.add(fieldName + typeName); const { sliceName, ...sliceRest } = el; collapsed.push({ ...sliceRest, path: variantPath, choiceOf: fieldName }); } else if (el.path.startsWith(`${rootPath}.`) && variantPath) { @@ -85,7 +89,12 @@ export function collapseChoiceTypeSlicing( } } - result.push({ ...rootRest, path: basePath, choices, _choiceSlicing: slicing }); + result.push({ + ...rootRest, + path: basePath, + ...(choices.length > 0 ? { choices } : {}), + _choiceSlicing: slicing, + }); // Explicit root types without a matching slice keep plain variant elements, // mirroring expandChoiceElement for unsliced choice elements. From 9d113db12622d1b3e7f56c215fc6f511c1fd3fa7 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Thu, 23 Jul 2026 16:10:25 +0400 Subject: [PATCH 2/2] test(converter): cover untyped sliced roots (open and closed) and explicit type ceilings --- test/unit/converter.test.ts | 66 +++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/test/unit/converter.test.ts b/test/unit/converter.test.ts index 84fe596..d5d7a86 100644 --- a/test/unit/converter.test.ts +++ b/test/unit/converter.test.ts @@ -1024,14 +1024,44 @@ describe('Converter Algorithm Tests', () => { }, ]; - it('keeps every declared slice variant as a choice', () => { + it('materializes slice variants without narrowing choices for an untyped root', () => { const result = translate(createTestStructureDefinition(openOnsetSlicing)); - expect(result.elements?.onset?.choices).toEqual(['onsetPeriod', 'onsetAge']); + // Open slices are additive: without an explicit type ceiling on the root, + // the inherited choice set stays in effect, so no `choices` is emitted. + expect(result.elements?.onset?.choices).toBeUndefined(); expect(result.elements?.onsetPeriod).toMatchObject({ type: 'Period', choiceOf: 'onset' }); expect(result.elements?.onsetAge).toMatchObject({ type: 'Age', choiceOf: 'onset' }); }); + it('derives choices from the explicit type ceiling of a sliced root', () => { + const result = translate( + createTestStructureDefinition([ + { + path: 'Test.value[x]', + slicing: { discriminator: [{ type: 'type', path: '$this' }], rules: 'open' }, + type: [{ code: 'Quantity' }, { code: 'CodeableConcept' }], + }, + { + path: 'Test.value[x]', + sliceName: 'valueQuantity', + max: '1', + type: [{ code: 'Quantity' }], + }, + ]), + ); + + expect(result.elements?.value?.choices).toEqual(['valueQuantity', 'valueCodeableConcept']); + expect(result.elements?.valueQuantity).toMatchObject({ + type: 'Quantity', + choiceOf: 'value', + }); + expect(result.elements?.valueCodeableConcept).toMatchObject({ + type: 'CodeableConcept', + choiceOf: 'value', + }); + }); + it('preserves open slicing rules and discriminator on the choice element', () => { const result = translate(createTestStructureDefinition(openOnsetSlicing)); @@ -1062,5 +1092,37 @@ describe('Converter Algorithm Tests', () => { expect(result.elements?.value?.slicing?.rules).toBe('closed'); }); + + it('emits no choices for an untyped closed-sliced root', () => { + // Without a type restatement on the root, `choices` stays absent even + // under closed rules: the materialized slice variants plus rules: closed + // are the signal that unlisted variants are forbidden. Consumers must + // intersect with the variant elements, not with `choices`. + const result = translate( + createTestStructureDefinition([ + { + path: 'Test.onset[x]', + slicing: { discriminator: [{ type: 'type', path: '$this' }], rules: 'closed' }, + }, + { + path: 'Test.onset[x]', + sliceName: 'onsetPeriod', + max: '1', + type: [{ code: 'Period' }], + }, + { + path: 'Test.onset[x]', + sliceName: 'onsetAge', + max: '1', + type: [{ code: 'Age' }], + }, + ]), + ); + + expect(result.elements?.onset?.choices).toBeUndefined(); + expect(result.elements?.onset?.slicing?.rules).toBe('closed'); + expect(result.elements?.onsetPeriod).toMatchObject({ type: 'Period', choiceOf: 'onset' }); + expect(result.elements?.onsetAge).toMatchObject({ type: 'Age', choiceOf: 'onset' }); + }); }); });