From fc787acea37ed6a8f14deff9a1f163a860814c2f Mon Sep 17 00:00:00 2001 From: abhinandan Date: Sun, 31 May 2026 23:07:25 +0530 Subject: [PATCH] Add non-must-support element presence check to validate client request compliance with Da Vinci CRD and PAS requirements --- lib/inferno/dsl/must_support_assessment.rb | 129 +++++++++++- .../dsl/must_support_metadata_extractor.rb | 56 +++++- .../dsl/must_support_assessment_spec.rb | 184 ++++++++++++++++++ 3 files changed, 365 insertions(+), 4 deletions(-) diff --git a/lib/inferno/dsl/must_support_assessment.rb b/lib/inferno/dsl/must_support_assessment.rb index bbbe78243..0278bacc2 100644 --- a/lib/inferno/dsl/must_support_assessment.rb +++ b/lib/inferno/dsl/must_support_assessment.rb @@ -32,6 +32,44 @@ def missing_must_support_elements(resources, profile_url, validator_name: :defau end end + # Find any non-Must-Support elements that ARE present in the given resources. + # + # Implements the Da Vinci Burden Reduction conformance rules (CRD conf-10/13, + # PAS conf-12) for client requests: a conforming client must not depend on + # non-must-support elements, which we verify by ensuring it does not populate + # them. Reports both populated non-MS elements and any extensions whose URLs + # are not defined as must-support in the profile. + # + # The metadata schema mirrors {#missing_must_support_elements}: entries in + # `must_supports[:elements]` are treated as non-MS when tagged with + # `must_support: false`. Entries without the flag are assumed to be MS, which + # preserves the behavior of existing metadata generated before this method + # was added. + # + # @param resources [Array] + # @param profile_url [String] + # @param validator_name [Symbol] + # @param metadata [Hash] Pre-built metadata (skips re-extraction) + # @param requirement_extension [String] + # @yield [Metadata] Customize the metadata before running the check + # @return [Array] non-MS element paths and unexpected extension URLs that were found + def non_must_support_elements_present(resources, profile_url, validator_name: :default, metadata: nil, + requirement_extension: nil, &) + debug_metadata = config.options[:debug_must_support_metadata] + + if metadata.present? + InternalMustSupportLogic.new + .perform_non_must_support_check_with_metadata(resources, metadata, debug_metadata:) + else + ig, profile = find_ig_and_profile(profile_url, validator_name) + test_impl = InternalMustSupportLogic.new + profile_metadata = test_impl.extract_metadata(profile, ig, requirement_extension:) + yield profile_metadata if block_given? + + test_impl.perform_non_must_support_check_with_metadata(resources, profile_metadata, debug_metadata:) + end + end + # perform_must_support_assessment allows customizing the metadata with a block. # Customizing the metadata may add, modify, or remove items. # For instance, US Core 3.1.1 Patient "Previous Name" is defined as MS only in narrative. @@ -187,6 +225,89 @@ def missing_must_support_strings missing_extensions.map { |extension_definition| extension_definition[:id] } end + # Run the non-must-support presence check (CRD conf-10/13, PAS conf-12). + # If the metadata has no entries explicitly flagged as must_support: false, + # the check is skipped entirely so that callers using pre-existing metadata + # (which never carried non-MS entries) continue to behave as before. + # @param resources [Array] + # @param profile_metadata [Metadata] + # @param debug_metadata [Boolean] + # @return [Array] paths/URLs of non-MS elements or undefined extensions that were found + def perform_non_must_support_check_with_metadata(resources, profile_metadata, debug_metadata: false) + return [] if resources.blank? + + @metadata = profile_metadata + + write_metadata_for_debugging if debug_metadata + + return [] unless metadata_opts_into_non_must_support_check? + + present_non_must_support_elements(resources) + unexpected_extensions(resources) + end + + # Metadata produced by the current extractor enumerates non-MS entries with + # `must_support: false`. Older metadata (or metadata customized to suppress + # the check) has no such entries, in which case we don't run the analysis. + def metadata_opts_into_non_must_support_check? + metadata.must_supports[:elements].any? { |element| element[:must_support] == false } + end + + # @return [Array] paths of non-MS elements found in any resource + def present_non_must_support_elements(resources) + present = non_must_support_elements.select do |element_definition| + resources.any? { |resource| resource_populates_element?(resource, element_definition) } + end + present.map { |element_definition| missing_element_string(element_definition) } + end + + # @return [Array] URLs of extensions present on any resource that are + # not declared as must-support in the profile metadata. Recursively walks + # sibling/child FHIR elements, but does NOT recurse into the internal + # structure of an extension - sub-extensions of a complex extension belong + # to the parent extension's definition and would otherwise produce noise + # (e.g., `ombCategory` inside `us-core-race`). + def unexpected_extensions(resources) + allowed_urls = must_support_extensions.to_set { |extension| normalized_extension_url(extension[:url]) } + + resources.flat_map { |resource| collect_extension_urls(resource) } + .uniq + .reject { |url| allowed_urls.include?(normalized_extension_url(url)) } + end + + def collect_extension_urls(element) + return [] unless element.is_a?(FHIR::Model) + + urls = direct_extension_urls(element) + + non_extension_child_models(element).each do |child| + urls.concat(collect_extension_urls(child)) + end + + urls + end + + def direct_extension_urls(element) + urls = [] + [:extension, :modifierExtension].each do |field| + next unless element.respond_to?(field) + + Array.wrap(element.public_send(field)).each do |extension| + urls << extension.url if extension.respond_to?(:url) && extension.url.present? + end + end + urls + end + + def non_extension_child_models(element) + element.instance_variables.flat_map do |name| + next [] if [:@extension, :@modifierExtension].include?(name) + + Array.wrap(element.instance_variable_get(name)).select do |value| + value.is_a?(FHIR::Model) && !value.is_a?(FHIR::Extension) + end + end + end + def missing_element_string(element_definition) if element_definition[:fixed_value].present? "#{element_definition[:path]}:#{element_definition[:fixed_value]}" @@ -196,7 +317,7 @@ def missing_element_string(element_definition) end def must_support_extensions - metadata.must_supports[:extensions] + metadata.must_supports[:extensions].reject { |extension| extension[:must_support] == false } end def missing_extensions(resources = []) @@ -223,7 +344,11 @@ def missing_extensions(resources = []) end def must_support_elements - metadata.must_supports[:elements] + metadata.must_supports[:elements].reject { |element| element[:must_support] == false } + end + + def non_must_support_elements + metadata.must_supports[:elements].select { |element| element[:must_support] == false } end def missing_elements(resources = []) diff --git a/lib/inferno/dsl/must_support_metadata_extractor.rb b/lib/inferno/dsl/must_support_metadata_extractor.rb index 12f009fcb..1dffb2a35 100644 --- a/lib/inferno/dsl/must_support_metadata_extractor.rb +++ b/lib/inferno/dsl/must_support_metadata_extractor.rb @@ -24,13 +24,18 @@ def initialize(profile_elements, profile, resource, ig_resources, requirement_ex self.requirement_extension_url = requirement_extension_url end - # Retrieval method for the must support metadata + # Retrieval method for the must support metadata. + # + # The :elements list now includes both must-support entries (no must_support flag, + # defaulting to true) and non-must-support entries (explicitly tagged + # must_support: false) - see {#non_must_support_elements}. Consumers that only + # want MS entries must filter on `entry[:must_support] != false`. # @return [Hash] def must_supports @must_supports ||= { extensions: must_support_extensions, slices: must_support_slices, - elements: must_support_elements + elements: must_support_elements + non_must_support_elements } end @@ -479,6 +484,53 @@ def remove_conflicting_metadata_without_fixed_value(must_support_elements_metada metadata[:path] == current_metadata[:path] && metadata[:fixed_value].blank? end end + + # Enumerate non-must-support, non-required profile elements that are direct + # children of either the resource root or a must-support element. These are + # the elements a conforming client request must NOT contain (see Da Vinci + # CRD conf-10 / conf-13 and PAS conf-12). + # + # Each entry is tagged `must_support: false` so consumers can distinguish + # them from the must-support entries that share the same list. + # @return [Array] + def non_must_support_elements + ms_element_ids = all_must_support_elements.to_set(&:id) + allowed_parent_ids = ms_element_ids + [resource] + + candidate_non_ms_elements.filter_map do |element| + parent_id = parent_element_id(element.id) + next unless allowed_parent_ids.include?(parent_id) + + { path: element.id.gsub("#{resource}.", ''), must_support: false } + end + end + + def candidate_non_ms_elements + profile_elements.reject { |element| skip_for_non_ms_check?(element) } + end + + def skip_for_non_ms_check?(element) + element.id == resource || + element.path.end_with?('xtension') || + element.sliceName.present? || + element.mustSupport || + by_requirement_extension_only?(element) || + required_element?(element) + end + + def required_element?(element) + element.min.is_a?(Integer) && element.min.positive? + end + + def parent_element_id(id) + # Drop the last '.' segment to get the parent id. Slice suffixes (":sliceName") + # remain attached to their segment, so allowed-parent comparisons still work + # because we built the allowed set from element ids. + parts = id.split('.') + return nil if parts.length <= 1 + + parts[0..-2].join('.') + end end end end diff --git a/spec/inferno/dsl/must_support_assessment_spec.rb b/spec/inferno/dsl/must_support_assessment_spec.rb index 7e6349817..adb39ff55 100644 --- a/spec/inferno/dsl/must_support_assessment_spec.rb +++ b/spec/inferno/dsl/must_support_assessment_spec.rb @@ -1402,4 +1402,188 @@ def add_previous_name_metadata(metadata) expect(result).to include('subject') end end + + describe 'non-must-support element check (CRD conf-10/13, PAS conf-12)' do + def run_non_ms_with_metadata(resources, metadata) + test_impl.non_must_support_elements_present(resources, nil, metadata:) + end + + let(:patient_metadata) do + OpenStruct.new( + must_supports: { + extensions: [ + { + id: 'Patient.extension:race', + path: 'extension', + url: 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-race' + } + ], + slices: [], + elements: [ + { path: 'identifier' }, + { path: 'name' }, + { path: 'name.family' }, + { path: 'gender' }, + { path: 'communication', must_support: false }, + { path: 'birthDate', must_support: false } + ] + } + ) + end + + let(:bare_patient) do + FHIR::Patient.new( + identifier: [{ system: 'system', value: 'value' }], + name: [{ family: 'family' }], + gender: 'male', + extension: [ + { + url: 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-race', + extension: [{ url: 'ombCategory', valueCoding: { display: 'display' } }] + } + ] + ) + end + + it 'passes when only must-support elements are populated' do + result = run_non_ms_with_metadata([bare_patient], patient_metadata) + expect(result).to be_empty + end + + it 'flags a populated non-must-support element' do + bare_patient.birthDate = '2020-01-01' + + result = run_non_ms_with_metadata([bare_patient], patient_metadata) + expect(result).to include('birthDate') + end + + it 'flags multiple populated non-must-support elements' do + bare_patient.birthDate = '2020-01-01' + bare_patient.communication = [{ language: { text: 'en' } }] + + result = run_non_ms_with_metadata([bare_patient], patient_metadata) + expect(result).to include('birthDate', 'communication') + end + + it 'passes when metadata has no explicit must_support flags (backwards compatibility)' do + legacy_metadata = OpenStruct.new( + must_supports: { + extensions: [], + slices: [], + elements: [ + { path: 'identifier' }, + { path: 'name' }, + { path: 'gender' } + ] + } + ) + + bare_patient.birthDate = '2020-01-01' + + result = run_non_ms_with_metadata([bare_patient], legacy_metadata) + expect(result).to be_empty + end + + it 'flags an extension whose url is not defined as must-support' do + bare_patient.extension << FHIR::Extension.new( + url: 'http://example.org/StructureDefinition/undefined-extension', + valueString: 'value' + ) + + result = run_non_ms_with_metadata([bare_patient], patient_metadata) + expect(result).to include('http://example.org/StructureDefinition/undefined-extension') + end + + it 'does not flag an extension defined as must-support' do + result = run_non_ms_with_metadata([bare_patient], patient_metadata) + expect(result).to_not include('http://hl7.org/fhir/us/core/StructureDefinition/us-core-race') + end + + it 'matches must-support extensions even when the url carries a canonical version' do + versioned_metadata = OpenStruct.new( + must_supports: { + extensions: [ + { + id: 'Patient.extension:race', + path: 'extension', + url: 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-race|1.0.0' + } + ], + slices: [], + elements: [] + } + ) + + result = run_non_ms_with_metadata([bare_patient], versioned_metadata) + expect(result).to_not include(a_string_starting_with('http://hl7.org/fhir/us/core/StructureDefinition/us-core-race')) + end + + it 'does not flag sub-extensions inside a known must-support complex extension' do + # `ombCategory` is a sub-slice of us-core-race; it is part of the extension's + # own definition, not a separate extension used by the client. + result = run_non_ms_with_metadata([bare_patient], patient_metadata) + expect(result).to_not include('ombCategory') + end + + it 'flags an unknown extension nested on a non-extension sub-element' do + bare_patient.name.first.extension = [ + FHIR::Extension.new( + url: 'http://example.org/StructureDefinition/name-quirk', + valueString: 'value' + ) + ] + + result = run_non_ms_with_metadata([bare_patient], patient_metadata) + expect(result).to include('http://example.org/StructureDefinition/name-quirk') + end + + it 'returns an empty array when no resources are provided' do + result = run_non_ms_with_metadata([], patient_metadata) + expect(result).to eq([]) + end + + it 'does not flag entries explicitly marked must_support: true' do + explicit_ms_metadata = OpenStruct.new( + must_supports: { + extensions: [], + slices: [], + elements: [ + { path: 'gender', must_support: true }, + { path: 'identifier', must_support: true }, + { path: 'name', must_support: true } + ] + } + ) + + result = run_non_ms_with_metadata([bare_patient], explicit_ms_metadata) + expect(result).to_not include('gender', 'identifier', 'name') + end + end + + describe 'non_must_support_elements via the metadata extractor' do + let(:patient_profile) { fixture('StructureDefinition-us-core-patient.json') } + let(:patient_extractor) do + Inferno::DSL::MustSupportMetadataExtractor.new( + patient_profile.snapshot.element, patient_profile, patient_profile.type, nil + ) + end + + it 'enumerates non-MS elements with the must_support: false flag' do + non_ms = patient_extractor.non_must_support_elements + + expect(non_ms).to be_an(Array) + expect(non_ms).to all(include(must_support: false)) + # US Core 3 patient does not mark `active` as MS; it should appear in the non-MS list. + expect(non_ms.map { |entry| entry[:path] }).to include('active') + end + + it 'merges must-support and non-must-support entries into must_supports[:elements]' do + elements = patient_extractor.must_supports[:elements] + ms_paths = elements.reject { |entry| entry[:must_support] == false }.map { |entry| entry[:path] } + non_ms_paths = elements.select { |entry| entry[:must_support] == false }.map { |entry| entry[:path] } + + expect(ms_paths).to include('identifier', 'name', 'gender') + expect(non_ms_paths).to include('active') + end + end end