From 37817db6f782847473175ec0f28cd4cab10fec91 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:18:09 +0200 Subject: [PATCH 1/6] build(deps): bump com.fasterxml.jackson.core:jackson-core (#32) Bumps [com.fasterxml.jackson.core:jackson-core](https://github.com/FasterXML/jackson-core) from 2.18.0 to 2.18.6. - [Commits](https://github.com/FasterXML/jackson-core/compare/jackson-core-2.18.0...jackson-core-2.18.6) --- updated-dependencies: - dependency-name: com.fasterxml.jackson.core:jackson-core dependency-version: 2.18.6 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- linter-core/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linter-core/pom.xml b/linter-core/pom.xml index 6186007..17ab91c 100644 --- a/linter-core/pom.xml +++ b/linter-core/pom.xml @@ -79,7 +79,7 @@ com.fasterxml.jackson.core jackson-core - 2.18.0 + 2.18.6 From b58d97b624caccf01e877b907df5f175a55400ee Mon Sep 17 00:00:00 2001 From: khalilmalla95 Date: Mon, 20 Apr 2026 15:16:48 +0200 Subject: [PATCH 2/6] Expand `LintingType` with additional error types for input coding validation. --- .../src/main/java/dev/dsf/linter/output/LintingType.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/linter-core/src/main/java/dev/dsf/linter/output/LintingType.java b/linter-core/src/main/java/dev/dsf/linter/output/LintingType.java index 374b8d2..2132fd7 100644 --- a/linter-core/src/main/java/dev/dsf/linter/output/LintingType.java +++ b/linter-core/src/main/java/dev/dsf/linter/output/LintingType.java @@ -177,7 +177,10 @@ public enum LintingType { FHIR_TASK_INPUT_INSTANCE_COUNT_EXCEEDS_MAX("Task input instance count exceeds maximum."), FHIR_TASK_INPUT_SLICE_COUNT_BELOW_SLICE_MIN("Task input slice count below slice minimum."), FHIR_TASK_INPUT_SLICE_COUNT_EXCEEDS_SLICE_MAX("Task input slice count exceeds slice maximum."), - FHIR_TASK_UNKNOWN_CODE("Task has unknown code."), + FHIR_TASK_UNKNOWN_CODE("Task has unknown code (outside Task.input.type.coding)."), + FHIR_TASK_INPUT_CODING_SYSTEM_UNKNOWN("Task.input.type.coding.system is not a known CodeSystem URI."), + FHIR_TASK_INPUT_CODING_SYSTEM_NOT_IN_VALUE_SET("Task.input.type.coding.system is not allowed by the expected ValueSet binding context."), + FHIR_TASK_INPUT_CODING_CODE_UNKNOWN_FOR_SYSTEM("Task.input.type.coding.code is unknown in the specified CodeSystem."), FHIR_TASK_REQUESTER_ID_NOT_EXIST("Task requester ID does not exist."), FHIR_TASK_REQUESTER_ID_NO_PLACEHOLDER("Task requester ID missing placeholder."), FHIR_TASK_RECIPIENT_ID_NOT_EXIST("Task recipient ID does not exist."), From 849432479f93a4747d183912f0286a38748eb275 Mon Sep 17 00:00:00 2001 From: khalilmalla95 Date: Mon, 20 Apr 2026 15:19:52 +0200 Subject: [PATCH 3/6] Enhance FHIR cache with ValueSet seeding and system tracking - Added support for loading ValueSet files and indexing `compose.include.system` URIs. - Introduced utilities for checking system inclusion in ValueSets and retrieving associated systems. - Refactored resource scanning logic to handle both CodeSystems and ValueSets. --- .../util/resource/FhirAuthorizationCache.java | 217 ++++++++++++++++-- 1 file changed, 200 insertions(+), 17 deletions(-) diff --git a/linter-core/src/main/java/dev/dsf/linter/util/resource/FhirAuthorizationCache.java b/linter-core/src/main/java/dev/dsf/linter/util/resource/FhirAuthorizationCache.java index 5d28635..68d972c 100644 --- a/linter-core/src/main/java/dev/dsf/linter/util/resource/FhirAuthorizationCache.java +++ b/linter-core/src/main/java/dev/dsf/linter/util/resource/FhirAuthorizationCache.java @@ -87,6 +87,12 @@ public final class FhirAuthorizationCache public static final String CS_ORG_ROLE = "http://dsf.dev/fhir/CodeSystem/organization-role"; + /** + * DSF core CodeSystem URI for BPMN message slices ({@code message-name}, {@code business-key}, + * {@code correlation-key}). + */ + public static final String CS_BPMN_MESSAGE = "http://dsf.dev/fhir/CodeSystem/bpmn-message"; + /** * FHIR Task URI for Task status values. */ @@ -96,6 +102,25 @@ public final class FhirAuthorizationCache private static final Map> CODES_BY_SYSTEM = new ConcurrentHashMap<>(); + /** + * Set of CodeSystem URIs that are referenced by at least one known ValueSet's + * {@code compose.include.system} entry. Populated during + * {@link #seedFromProjectAndClasspath(File)}. + */ + private static final Set SYSTEMS_IN_VALUE_SETS = ConcurrentHashMap.newKeySet(); + + /** + * Index of known ValueSets keyed by their canonical {@code url}. + * Each entry maps to the set of {@code compose.include.system} URIs + * declared by that ValueSet. Populated during + * {@link #seedFromProjectAndClasspath(File)}. + * + *

Used for binding-driven terminology checks, e.g., + * verifying that a {@code Task.input.type.coding.system} is allowed by the + * specific ValueSet referenced in a profile's {@code binding.valueSet}.

+ */ + private static final Map> SYSTEMS_PER_VALUE_SET = new ConcurrentHashMap<>(); + static { // Register official DSF codes (release v1.7) @@ -121,6 +146,10 @@ public final class FhirAuthorizationCache "draft", "requested", "received", "accepted", "rejected", "ready", "cancelled", "in-progress", "on-hold", "failed", "completed", "entered-in-error")); + register(CS_BPMN_MESSAGE, Set.of("message-name", "business-key", "correlation-key")); + + // The bpmn-message CodeSystem is always included in DSF's well-known ValueSets + SYSTEMS_IN_VALUE_SETS.add(CS_BPMN_MESSAGE); } private FhirAuthorizationCache() { /* Utility class – no instantiation */ } @@ -285,12 +314,14 @@ private static void dumpStatistics() public static void seedFromProjectAndClasspath(File projectRoot) { Objects.requireNonNull(projectRoot, "projectRoot"); + + // ---- CodeSystem seeding ---- Set allCodeSystemFiles = new LinkedHashSet<>(findCodeSystemsOnDisk(projectRoot)); // 2) Classpath scan: fhir/CodeSystem/*.xml and *.json from dependency JARs or directories try { ClassLoader cl = getOrCreateProjectClassLoader(projectRoot); - allCodeSystemFiles.addAll(findCodeSystemsOnClasspath(cl)); + allCodeSystemFiles.addAll(findResourcesOnClasspath(cl, "fhir/CodeSystem")); } catch (Exception e) { logger.debug("[CodeSystem-Cache] Failed to scan classpath: " + e.getMessage()); // keep going; disk results might still be sufficient @@ -301,36 +332,65 @@ public static void seedFromProjectAndClasspath(File projectRoot) loadCodeSystemFile(cs); } + // ---- ValueSet seeding (compose.include.system → SYSTEMS_IN_VALUE_SETS) ---- + Set allValueSetFiles = new LinkedHashSet<>(findValueSetsOnDisk(projectRoot)); + try { + ClassLoader cl = getOrCreateProjectClassLoader(projectRoot); + allValueSetFiles.addAll(findResourcesOnClasspath(cl, "fhir/ValueSet")); + } catch (Exception e) { + logger.debug("[ValueSet-Cache] Failed to scan classpath: " + e.getMessage()); + } + for (File vs : allValueSetFiles) { + loadValueSetFile(vs); + } + dumpStatistics(); } // ---- Helper methods ---- /** - * Returns CodeSystem files under typical project locations (no changes to your current logic). + * Returns CodeSystem files under typical project locations. */ - private static Collection findCodeSystemsOnDisk(File projectRoot) - { - List candidates = List.of( + private static Collection findCodeSystemsOnDisk(File projectRoot) { + return findFhirResourcesOnDisk(projectRoot, List.of( "src/main/resources/fhir/CodeSystem", "target/classes/fhir/CodeSystem", - "fhir/CodeSystem" // exploded plugin root case - ); + "fhir/CodeSystem")); + } + + /** + * Returns ValueSet files under typical project locations. + */ + private static Collection findValueSetsOnDisk(File projectRoot) { + return findFhirResourcesOnDisk(projectRoot, List.of( + "src/main/resources/fhir/ValueSet", + "target/classes/fhir/ValueSet", + "fhir/ValueSet")); + } + + /** + * Generic disk scanner: lists {@code .xml} and {@code .json} files under the given + * relative subdirectories of {@code projectRoot}. + */ + private static Collection findFhirResourcesOnDisk(File projectRoot, List candidates) { List out = new ArrayList<>(); for (String dir : candidates) { File d = new File(projectRoot, dir); - File[] xmls = d.isDirectory() ? d.listFiles(f -> f.isFile() && (f.getName().endsWith(".xml") || f.getName().endsWith(".json"))) : null; - if (xmls != null) out.addAll(Arrays.asList(xmls)); + File[] files = d.isDirectory() + ? d.listFiles(f -> f.isFile() && (f.getName().endsWith(".xml") || f.getName().endsWith(".json"))) + : null; + if (files != null) out.addAll(Arrays.asList(files)); } return out; } /** - * Finds CodeSystem XMLs and JSONs on the classpath under "fhir/CodeSystem" (both directories and JARs). + * Finds FHIR resource files on the classpath under {@code basePath} + * (both directories and JARs), materializing JAR entries to temp files. */ - private static Collection findCodeSystemsOnClasspath(ClassLoader cl) throws IOException + private static Collection findResourcesOnClasspath(ClassLoader cl, String basePath) throws IOException { - final String basePath = "fhir/CodeSystem"; List out = new ArrayList<>(); // A) enumerate basePath URLs (dirs or inside JARs) @@ -342,8 +402,9 @@ private static Collection findCodeSystemsOnClasspath(ClassLoader cl) throw if ("file".equals(protocol)) { // Directory on classpath -> list *.xml and *.json File dir = new File(url.getPath()); - File[] files = dir.isDirectory() ? dir.listFiles(f -> f.isFile() && - (f.getName().endsWith(".xml") || f.getName().endsWith(".json"))) : null; + File[] files = dir.isDirectory() + ? dir.listFiles(f -> f.isFile() && (f.getName().endsWith(".xml") || f.getName().endsWith(".json"))) + : null; if (files != null) out.addAll(Arrays.asList(files)); } else if ("jar".equals(protocol)) { // JAR -> iterate entries @@ -356,7 +417,7 @@ private static Collection findCodeSystemsOnClasspath(ClassLoader cl) throw && (e.getName().endsWith(".xml") || e.getName().endsWith(".json"))) { // materialize to temp file String fileName = Paths.get(e.getName()).getFileName().toString(); - Path tmp = Files.createTempFile("cs-", "-" + fileName); + Path tmp = Files.createTempFile("fhir-", "-" + fileName); try (InputStream in = jar.getInputStream(e)) { Files.copy(in, tmp, StandardCopyOption.REPLACE_EXISTING); } @@ -366,8 +427,7 @@ private static Collection findCodeSystemsOnClasspath(ClassLoader cl) throw } } } catch (IOException ioe) { - logger.debug("[CodeSystem-Cache] Failed to read JAR: " + ioe.getMessage()); - // ignore this JAR and keep going + logger.debug("[FHIR-Cache] Failed to read JAR (" + basePath + "): " + ioe.getMessage()); } } } @@ -376,6 +436,74 @@ private static Collection findCodeSystemsOnClasspath(ClassLoader cl) throw return out.stream().distinct().collect(Collectors.toList()); } + /** + * Parses a ValueSet file (XML or JSON) and registers each + * {@code compose.include.system} URI into {@link #SYSTEMS_IN_VALUE_SETS}. + */ + private static void loadValueSetFile(File f) { + String name = f.getName().toLowerCase(); + if (name.endsWith(".xml")) loadValueSetXml(f.toPath()); + else if (name.endsWith(".json")) loadValueSetJson(f.toPath()); + } + + private static void loadValueSetXml(Path xml) { + try (FileInputStream fis = new FileInputStream(xml.toFile())) { + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + Document doc = dbf.newDocumentBuilder().parse(fis); + if (!"ValueSet".equals(doc.getDocumentElement().getLocalName())) return; + + String vsUrl = (String) XPathFactory.newInstance().newXPath() + .compile("/*[local-name()='ValueSet']/*[local-name()='url']/@value") + .evaluate(doc, XPathConstants.STRING); + + NodeList systems = (NodeList) XPathFactory.newInstance().newXPath() + .compile("/*[local-name()='ValueSet']/*[local-name()='compose']" + + "/*[local-name()='include']/*[local-name()='system']/@value") + .evaluate(doc, XPathConstants.NODESET); + if (systems == null) return; + for (int i = 0; i < systems.getLength(); i++) { + String sys = systems.item(i).getTextContent(); + registerValueSetSystem(sys); + indexValueSetSystem(vsUrl, sys); + logger.debug("[Cache-DEBUG] ValueSet " + xml.getFileName() + " (" + vsUrl + ") includes system: " + sys); + } + } catch (Exception ignore) { /* invalid or non-parsable file */ } + } + + private static void loadValueSetJson(Path json) { + try (InputStream in = Files.newInputStream(json)) { + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(in); + if (root == null || !"ValueSet".equals(root.path("resourceType").asText())) return; + String vsUrl = root.path("url").asText(null); + com.fasterxml.jackson.databind.JsonNode includes = root.path("compose").path("include"); + if (includes.isArray()) { + for (com.fasterxml.jackson.databind.JsonNode inc : includes) { + String sys = inc.path("system").asText(null); + if (sys != null && !sys.isBlank()) { + registerValueSetSystem(sys); + indexValueSetSystem(vsUrl, sys); + logger.debug("[Cache-DEBUG] ValueSet " + json.getFileName() + " (" + vsUrl + ") includes system: " + sys); + } + } + } + } catch (Exception ignore) { /* invalid or non-parsable file */ } + } + + /** + * Indexes a single {@code compose.include.system} URI under the given ValueSet canonical URL. + * + * @param vsUrl canonical URL of the ValueSet (may be {@code null} or blank; in that case the entry is skipped) + * @param system CodeSystem URI referenced in {@code compose.include.system} + */ + private static void indexValueSetSystem(String vsUrl, String system) { + if (vsUrl == null || vsUrl.isBlank() || system == null || system.isBlank()) return; + SYSTEMS_PER_VALUE_SET + .computeIfAbsent(vsUrl, k -> ConcurrentHashMap.newKeySet()) + .add(system); + } + /** * Single-file load hook that delegates to existing XML/JSON parsing logic. */ @@ -389,6 +517,61 @@ private static void loadCodeSystemFile(File f) } } + // ---- ValueSet system tracking ---- + + /** + * Registers a CodeSystem URI as being referenced by a known ValueSet. + * Called during ValueSet scanning in {@link #seedFromProjectAndClasspath(File)}. + * + * @param system the CodeSystem URI referenced in a ValueSet's {@code compose.include.system} + */ + public static void registerValueSetSystem(String system) { + if (system != null && !system.isBlank()) + SYSTEMS_IN_VALUE_SETS.add(system); + } + + /** + * Returns {@code true} if the given CodeSystem URI is referenced by at least one + * known ValueSet's {@code compose.include.system}. + * + * @param system the CodeSystem URI to check + * @return {@code true} if the system is included in at least one known ValueSet + */ + public static boolean isSystemInAnyValueSet(String system) { + return system != null && SYSTEMS_IN_VALUE_SETS.contains(system); + } + + /** + * Returns {@code true} if a ValueSet with the given canonical URL has been loaded. + * Version suffixes (everything after {@code |}) are stripped before comparison. + * + * @param valueSetUrl canonical URL of the ValueSet, optionally versioned ({@code url|version}) + * @return {@code true} if the ValueSet is known to the cache + */ + public static boolean isValueSetLoaded(String valueSetUrl) { + if (valueSetUrl == null || valueSetUrl.isBlank()) return false; + return SYSTEMS_PER_VALUE_SET.containsKey(stripVersion(valueSetUrl)); + } + + /** + * Returns the set of CodeSystem URIs referenced by the given ValueSet's + * {@code compose.include.system} entries. Version suffixes on {@code valueSetUrl} + * are stripped before lookup. + * + * @param valueSetUrl canonical URL of the ValueSet, optionally versioned ({@code url|version}) + * @return an immutable view of the referenced system URIs; empty if the ValueSet is not loaded + */ + public static Set getSystemsInValueSet(String valueSetUrl) { + if (valueSetUrl == null || valueSetUrl.isBlank()) return Collections.emptySet(); + Set s = SYSTEMS_PER_VALUE_SET.get(stripVersion(valueSetUrl)); + return s == null ? Collections.emptySet() : Collections.unmodifiableSet(s); + } + + private static String stripVersion(String canonical) { + int pipe = canonical.indexOf('|'); + return pipe >= 0 ? canonical.substring(0, pipe) : canonical; + } + /** True if we have any codes cached for this CodeSystem URL. */ public static boolean containsSystem(String system) { return system != null && CODES_BY_SYSTEM.containsKey(system); From aae7f24261aa0f331e527009ff278c8f57f8a905 Mon Sep 17 00:00:00 2001 From: khalilmalla95 Date: Mon, 20 Apr 2026 15:33:46 +0200 Subject: [PATCH 4/6] Expand `FHIR_TASK_INPUT` validation with terminology and slice binding checks - Added granular error types to `LintingType` for `Task.input.type.coding` validation. - Introduced slice cardinality and terminology checks for `Task.input`. - Refactored `lintInputs` and implemented `lintInputTypeCodingTerminology` to handle `Task.input` coding validation. - Enhanced profile-loading logic to extract binding metadata and fixed coding constraints. --- .../dev/dsf/linter/fhir/FhirTaskLinter.java | 240 +++++++++++++++++- 1 file changed, 230 insertions(+), 10 deletions(-) diff --git a/linter-core/src/main/java/dev/dsf/linter/fhir/FhirTaskLinter.java b/linter-core/src/main/java/dev/dsf/linter/fhir/FhirTaskLinter.java index 304d400..8afab44 100644 --- a/linter-core/src/main/java/dev/dsf/linter/fhir/FhirTaskLinter.java +++ b/linter-core/src/main/java/dev/dsf/linter/fhir/FhirTaskLinter.java @@ -144,7 +144,10 @@ *
  • {@link LintingType#FHIR_TASK_INPUT_INSTANCE_COUNT_EXCEEDS_MAX} – too many {@code Task.input} elements
  • *
  • {@link LintingType#FHIR_TASK_INPUT_SLICE_COUNT_BELOW_SLICE_MIN} – slice occurrence below minimum
  • *
  • {@link LintingType#FHIR_TASK_INPUT_SLICE_COUNT_EXCEEDS_SLICE_MAX} – slice occurrence exceeds maximum
  • - *
  • {@link LintingType#FHIR_TASK_UNKNOWN_CODE} – unknown terminology code
  • + *
  • {@link LintingType#FHIR_TASK_UNKNOWN_CODE} – unknown terminology code (non-input codings)
  • + *
  • {@link LintingType#FHIR_TASK_INPUT_CODING_SYSTEM_UNKNOWN} – {@code Task.input.type.coding.system} not a known CodeSystem URI
  • + *
  • {@link LintingType#FHIR_TASK_INPUT_CODING_SYSTEM_NOT_IN_VALUE_SET} – {@code Task.input.type.coding.system} not allowed by the expected ValueSet binding context
  • + *
  • {@link LintingType#FHIR_TASK_INPUT_CODING_CODE_UNKNOWN_FOR_SYSTEM} – {@code Task.input.type.coding.code} unknown in the specified CodeSystem
  • *
  • {@link LintingType#FHIR_TASK_COULD_NOT_LOAD_PROFILE} – StructureDefinition could not be loaded (warning)
  • * *

    Successful validations are reported with {@link LinterSeverity#INFO} for completeness and traceability.

    @@ -177,7 +180,7 @@ public final class FhirTaskLinter extends AbstractFhirInstanceLinter { private static final String INPUT_XP = TASK_XP + "/*[local-name()='input']"; private static final String CODING_SYS_XP = "./*[local-name()='type']/*[local-name()='coding']/*[local-name()='system']/@value"; private static final String CODING_CODE_XP = "./*[local-name()='type']/*[local-name()='coding']/*[local-name()='code']/@value"; - private static final String SYSTEM_BPMN_MSG = "http://dsf.dev/fhir/CodeSystem/bpmn-message"; + private static final String SYSTEM_BPMN_MSG = FhirAuthorizationCache.CS_BPMN_MESSAGE; private static final String SYSTEM_ORG_ID = "http://dsf.dev/sid/organization-identifier"; private static final String TASK_IDENTIFIER_SID = "http://dsf.dev/sid/task-identifier"; private static final Set STATUSES_NEED_BIZKEY = Set.of("in-progress", "completed", "failed"); @@ -213,8 +216,14 @@ public List lint(Document doc, File resFile) { checkMetaAndBasic(doc, resFile, ref, issues); checkPlaceholders(doc, resFile, ref, issues); lintTaskIdentifier(doc, resFile, ref, issues); - lintInputs(doc, resFile, ref, issues); + + // Load slice metadata once and reuse for structural + terminology checks + String profileUrl = val(doc, TASK_XP + "/*[local-name()='meta']/*[local-name()='profile']/@value"); + Map cards = loadInputCardinality(determineProjectRoot(resFile), profileUrl); + + lintInputs(doc, resFile, ref, issues, cards); lintTerminology(doc, resFile, ref, issues); + lintInputTypeCodingTerminology(doc, resFile, ref, issues, cards); lintRequesterAuthorization(doc, resFile, ref, issues); lintRecipientAuthorization(doc, resFile, ref, issues); @@ -370,11 +379,9 @@ private void lintTaskIdentifier(Document doc, File f, String ref, List out) { - String profileUrl = val(doc, TASK_XP + "/*[local-name()='meta']/*[local-name()='profile']/@value"); - Map cards = loadInputCardinality(determineProjectRoot(f), profileUrl); - + private void lintInputs(Document doc, File f, String ref, List out, Map cards) { if (cards == null) { + String profileUrl = val(doc, TASK_XP + "/*[local-name()='meta']/*[local-name()='profile']/@value"); out.add(new FhirElementLintItem(LinterSeverity.WARN, LintingType.FHIR_TASK_COULD_NOT_LOAD_PROFILE, f, ref, "StructureDefinition for profile '" + profileUrl + "' not found → cardinality check skipped.")); } @@ -504,11 +511,23 @@ else if (cnt > card.max()) } } + /** + * Generic terminology check for all {@code coding} nodes in the Task document + * except {@code Task.input.type.coding} entries, which are validated + * separately with granular error types in {@link #lintInputTypeCodingTerminology}. + */ private void lintTerminology(Document doc, File f, String ref, List out) { NodeList codings = xp(doc, "//coding"); if (codings == null) return; for (int i = 0; i < codings.getLength(); i++) { Node c = codings.item(i); + // Skip Task.input.type.coding — handled by lintInputTypeCodingTerminology + Node parent = c.getParentNode(); + if (parent != null && "type".equals(parent.getLocalName())) { + Node grandParent = parent.getParentNode(); + if (grandParent != null && "input".equals(grandParent.getLocalName())) + continue; + } String sys = val(c, "./*[local-name()='system']/@value"); String code = val(c, "./*[local-name()='code']/@value"); if (FhirAuthorizationCache.isUnknown(sys, code)) @@ -517,6 +536,164 @@ private void lintTerminology(Document doc, File f, String ref, ListThree distinct checks are performed per input. Later checks depend on earlier ones:

    + *
      + *
    1. System known – {@code coding.system} must be registered in + * {@link FhirAuthorizationCache} (i.e., correspond to a loaded CodeSystem resource). + * If unknown, {@link LintingType#FHIR_TASK_INPUT_CODING_SYSTEM_UNKNOWN} is emitted + * and further checks for that input are skipped.
    2. + *
    3. System in expected ValueSet context – driven by the profile's + * StructureDefinition: + *
        + *
      • If the matching slice declares a {@code fixedUri} at + * {@code Task.input:sliceName.type.coding.system}, the input's system must + * equal it literally.
      • + *
      • Else, if the slice declares a {@code binding.valueSet} and that ValueSet is + * loaded, the input's system must appear in that ValueSet's + * {@code compose.include.system}.
      • + *
      • Else, if the binding context cannot be resolved, validation fails explicitly + * (no permissive fallback to unrelated ValueSets).
      • + *
      + * On mismatch, {@link LintingType#FHIR_TASK_INPUT_CODING_SYSTEM_NOT_IN_VALUE_SET} is emitted.
    4. + *
    5. Code valid for system – {@code coding.code} must be a known code + * under the given {@code coding.system}. Only executed if Check 2 passed. + * If not, {@link LintingType#FHIR_TASK_INPUT_CODING_CODE_UNKNOWN_FOR_SYSTEM} is emitted.
    6. + *
    + * + *

    Inputs that already failed structural validation (missing system or code) in + * {@link #lintInputs} are skipped here to avoid duplicate reporting.

    + * + * @param cards per-slice cardinality and binding metadata from the StructureDefinition + * (may be {@code null} if the profile could not be loaded) + */ + private void lintInputTypeCodingTerminology(Document doc, File f, String ref, + List out, + Map cards) { + NodeList inputs = xp(doc, INPUT_XP); + if (inputs == null || inputs.getLength() == 0) return; + + for (int i = 0; i < inputs.getLength(); i++) { + Node in = inputs.item(i); + String sys = val(in, CODING_SYS_XP); + String code = val(in, CODING_CODE_XP); + + // Structural errors (missing system/code) are already reported by lintInputs + if (blank(sys) || blank(code)) continue; + + // Check 1: coding.system must be a known CodeSystem + if (!FhirAuthorizationCache.containsSystem(sys)) { + out.add(new FhirElementLintItem(LinterSeverity.ERROR, + LintingType.FHIR_TASK_INPUT_CODING_SYSTEM_UNKNOWN, f, ref, + "Task.input.type.coding.system '" + sys + "' is not a known CodeSystem URI.")); + continue; // checks 2 and 3 require the system to be known + } + + // Check 2: coding.system must match the expected ValueSet context for this slice + SliceCard slice = findSliceByCode(cards, code); + if (!isSystemAllowedByBinding(slice, sys, out, f, ref)) { + // Check 3 is only executed when Check 2 passed. + continue; + } + + // Check 3: coding.code must be a valid code in the given system + if (FhirAuthorizationCache.isUnknown(sys, code)) { + out.add(new FhirElementLintItem(LinterSeverity.ERROR, + LintingType.FHIR_TASK_INPUT_CODING_CODE_UNKNOWN_FOR_SYSTEM, f, ref, + "Task.input.type.coding.code '" + code + "' is unknown in CodeSystem '" + sys + "'.")); + continue; + } + + out.add(ok(f, ref, "Task.input.type.coding: system='" + sys + "' code='" + code + "' OK.")); + } + } + + /** + * Locates the slice metadata that matches the given input code. + * + *

    Match strategy (first hit wins):

    + *
      + *
    1. Slice whose {@code fixedCode} at + * {@code Task.input:sliceName.type.coding.code} equals {@code inputCode}.
    2. + *
    3. Slice whose map key (slice name) equals {@code inputCode} - DSF convention + * where slice names mirror the code value (e.g., {@code message-name}).
    4. + *
    + * + * @param cards map of slice metadata loaded from the StructureDefinition, may be {@code null} + * @param inputCode value of {@code Task.input.type.coding.code} for the current input + * @return the matching {@link SliceCard}, or {@code null} if no slice matches + */ + private SliceCard findSliceByCode(Map cards, String inputCode) { + if (cards == null || inputCode == null) return null; + for (Map.Entry e : cards.entrySet()) { + if ("__BASE__".equals(e.getKey())) continue; + SliceCard c = e.getValue(); + if (inputCode.equals(c.fixedCode())) return c; + } + SliceCard byName = cards.get(inputCode); + return (byName != null && !"__BASE__".equals(inputCode)) ? byName : null; + } + + /** + * Binding-driven evaluation of Check 2. + * + *

    The decision order reflects how tightly the profile constrains the system:

    + *
      + *
    1. fixedUri on {@code .type.coding.system} - strict literal comparison.
    2. + *
    3. binding.valueSet resolvable in the cache - input system must be + * listed in the ValueSet's {@code compose.include.system}.
    4. + *
    5. binding.valueSet declared but not loaded - explicit validation + * failure because the expected context cannot be resolved.
    6. + *
    7. No binding info available - explicit validation failure because no expected + * ValueSet context is available.
    8. + *
    + * + *

    On mismatch, a {@link LintingType#FHIR_TASK_INPUT_CODING_SYSTEM_NOT_IN_VALUE_SET} + * error is appended to {@code out} and {@code false} is returned.

    + * + * @return {@code true} if the system is accepted by the resolved binding context, + * {@code false} otherwise + */ + private boolean isSystemAllowedByBinding(SliceCard slice, String sys, + List out, File f, String ref) { + // 1. Strict fixedUri match on Task.input:slice.type.coding.system + if (slice != null && slice.fixedSystem() != null && !slice.fixedSystem().isBlank()) { + if (sys.equals(slice.fixedSystem())) return true; + out.add(new FhirElementLintItem(LinterSeverity.ERROR, + LintingType.FHIR_TASK_INPUT_CODING_SYSTEM_NOT_IN_VALUE_SET, f, ref, + "Task.input.type.coding.system '" + sys + + "' does not match the slice's fixedUri '" + slice.fixedSystem() + "'.")); + return false; + } + + // 2. binding.valueSet declared on the slice + if (slice != null && slice.bindingValueSet() != null && !slice.bindingValueSet().isBlank()) { + String vsUrl = slice.bindingValueSet(); + if (FhirAuthorizationCache.isValueSetLoaded(vsUrl)) { + if (FhirAuthorizationCache.getSystemsInValueSet(vsUrl).contains(sys)) return true; + out.add(new FhirElementLintItem(LinterSeverity.ERROR, + LintingType.FHIR_TASK_INPUT_CODING_SYSTEM_NOT_IN_VALUE_SET, f, ref, + "Task.input.type.coding.system '" + sys + + "' is not referenced by the bound ValueSet '" + vsUrl + "'.")); + return false; + } + out.add(new FhirElementLintItem(LinterSeverity.ERROR, + LintingType.FHIR_TASK_INPUT_CODING_SYSTEM_NOT_IN_VALUE_SET, f, ref, + "Task.input.type.coding.system '" + sys + + "' cannot be validated against binding ValueSet '" + vsUrl + + "' because that ValueSet is not loaded.")); + return false; + } + + out.add(new FhirElementLintItem(LinterSeverity.ERROR, + LintingType.FHIR_TASK_INPUT_CODING_SYSTEM_NOT_IN_VALUE_SET, f, ref, + "Task.input.type.coding.system '" + sys + + "' has no resolvable expected ValueSet context (missing fixedUri and binding.valueSet).")); + return false; + } + private String computeReference(Document doc, File file) { String canon = val(doc, TASK_XP + "/*[local-name()='instantiatesCanonical']/@value"); if (!blank(canon)) return canon.split("\\|")[0]; @@ -559,7 +736,26 @@ private boolean instCanonDetermine(Document taskDoc, File taskFile) { return actFile == null; } - private record SliceCard(int min, int max) {} + /** + * Cardinality and binding metadata for a {@code Task.input} slice extracted from the + * profile's StructureDefinition. + * + * @param min minimum occurrences of the slice ({@code element.min}) + * @param max maximum occurrences of the slice ({@code element.max}; {@code *} maps to {@link Integer#MAX_VALUE}) + * @param fixedSystem value of {@code fixedUri} at + * {@code Task.input:sliceName.type.coding.system}, if present + * @param fixedCode value of {@code fixedCode} at + * {@code Task.input:sliceName.type.coding.code}, if present + * @param bindingValueSet canonical URL of the ValueSet bound to the slice's + * {@code Task.input:sliceName.type[.coding]}, if declared + */ + private record SliceCard(int min, int max, + String fixedSystem, String fixedCode, + String bindingValueSet) { + static SliceCard cardinalityOnly(int min, int max) { + return new SliceCard(min, max, null, null, null); + } + } private Map loadInputCardinality(File projectRoot, String profileUrl) { FhirResourceLocator locator = FhirResourceLocator.create(projectRoot); @@ -575,7 +771,7 @@ private Map loadInputCardinality(File projectRoot, String pro String maxBase = AbstractFhirInstanceLinter.extractSingleNodeValue(sd, "//*[local-name()='element' and @id='Task.input']/*[local-name()='max']/@value"); int baseMin = (minBase != null) ? Integer.parseInt(minBase) : 0; int baseMax = (maxBase == null || "*".equals(maxBase)) ? Integer.MAX_VALUE : Integer.parseInt(maxBase); - map.put("__BASE__", new SliceCard(baseMin, baseMax)); + map.put("__BASE__", SliceCard.cardinalityOnly(baseMin, baseMax)); NodeList slices = (NodeList) XPathFactory.newInstance().newXPath() .compile("//*[local-name()='element' and starts-with(@id,'Task.input:') and not(contains(@id,'.'))]") @@ -587,7 +783,31 @@ private Map loadInputCardinality(File projectRoot, String pro String ma = AbstractFhirInstanceLinter.extractSingleNodeValue(n, "./*[local-name()='max']/@value"); int sMin = (mi != null) ? Integer.parseInt(mi) : 0; int sMax = (ma == null || "*".equals(ma)) ? baseMax : Integer.parseInt(ma); - map.put(sliceName, new SliceCard(sMin, sMax)); + + String codingId = "Task.input:" + sliceName + ".type.coding"; + String typeId = "Task.input:" + sliceName + ".type"; + String codingSystemId = codingId + ".system"; + String codingCodeId = codingId + ".code"; + + // fixed constraints on .type.coding.system / .type.coding.code + String fixedSystem = AbstractFhirInstanceLinter.extractSingleNodeValue(sd, + "//*[local-name()='element' and @id='" + codingSystemId + "']" + + "/*[local-name()='fixedUri']/@value"); + String fixedCode = AbstractFhirInstanceLinter.extractSingleNodeValue(sd, + "//*[local-name()='element' and @id='" + codingCodeId + "']" + + "/*[local-name()='fixedCode']/@value"); + + // binding.valueSet: prefer .type, fall back to .type.coding + String binding = AbstractFhirInstanceLinter.extractSingleNodeValue(sd, + "//*[local-name()='element' and @id='" + typeId + "']" + + "/*[local-name()='binding']/*[local-name()='valueSet']/@value"); + if (binding == null || binding.isBlank()) { + binding = AbstractFhirInstanceLinter.extractSingleNodeValue(sd, + "//*[local-name()='element' and @id='" + codingId + "']" + + "/*[local-name()='binding']/*[local-name()='valueSet']/@value"); + } + + map.put(sliceName, new SliceCard(sMin, sMax, fixedSystem, fixedCode, binding)); } return map; } catch (Exception e) { return null; } From 703182368c05221e6be1cd438511b4d388a4a06b Mon Sep 17 00:00:00 2001 From: khalilmalla95 Date: Mon, 20 Apr 2026 15:47:48 +0200 Subject: [PATCH 5/6] Add unit tests for `Task.input.type.coding` terminology validation - Introduced `FhirTaskLinterInputCodingTerminologyTest` to verify terminology checks. - Added tests to validate coding system existence and binding context logic for `Task.input`. --- ...rTaskLinterInputCodingTerminologyTest.java | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 linter-core/src/test/java/dev/dsf/linter/fhir/FhirTaskLinterInputCodingTerminologyTest.java diff --git a/linter-core/src/test/java/dev/dsf/linter/fhir/FhirTaskLinterInputCodingTerminologyTest.java b/linter-core/src/test/java/dev/dsf/linter/fhir/FhirTaskLinterInputCodingTerminologyTest.java new file mode 100644 index 0000000..40cae67 --- /dev/null +++ b/linter-core/src/test/java/dev/dsf/linter/fhir/FhirTaskLinterInputCodingTerminologyTest.java @@ -0,0 +1,178 @@ +package dev.dsf.linter.fhir; + +import dev.dsf.linter.logger.Logger; +import dev.dsf.linter.output.LintingType; +import dev.dsf.linter.output.item.FhirElementLintItem; +import dev.dsf.linter.util.resource.FhirAuthorizationCache; +import dev.dsf.linter.util.resource.FhirResourceParser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Node; + +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathFactory; +import java.io.StringReader; +import java.io.File; +import java.nio.file.Path; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.List; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; +import javax.xml.parsers.DocumentBuilderFactory; +import org.xml.sax.InputSource; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FhirTaskLinterInputCodingTerminologyTest +{ + private static final Path BASE_TASK = Path.of( + "src/test/resources/fhir/examples/pingPongProcess/Task/dsf-task-start-ping.json"); + private static final File BASE_TASK_FILE = BASE_TASK.toFile(); + private static final String SECOND_INPUT_CODING_SYSTEM_XPATH = + "/*[local-name()='Task']/*[local-name()='input'][2]/*[local-name()='type']/*[local-name()='coding']/*[local-name()='system']/@value"; + private static final String CODE_UNKNOWN_TASK_XML = """ + + + + + + + + + + + + """; + + private FhirTaskLinter linter; + + @BeforeEach + void setUp() + { + linter = new FhirTaskLinter(); + FhirAuthorizationCache.setLogger(new SilentLogger()); + FhirAuthorizationCache.seedFromProjectAndClasspath(Path.of(".").toAbsolutePath().normalize().toFile()); + } + + @Test + void shouldReportSystemUnknownForTaskInputCodingSystem() throws Exception + { + Document doc = FhirResourceParser.parseFhirFile(BASE_TASK); + setSecondInputCodingSystemValue(doc, "http://example.org/fhir/CodeSystem/not-known"); + + List items = linter.lint(doc, BASE_TASK_FILE); + + assertTrue(containsType(items, LintingType.FHIR_TASK_INPUT_CODING_SYSTEM_UNKNOWN), + "Expected FHIR_TASK_INPUT_CODING_SYSTEM_UNKNOWN"); + } + + @Test + void shouldReportSystemNotInValueSetContextForTaskInputCodingSystem() throws Exception + { + Document doc = FhirResourceParser.parseFhirFile(BASE_TASK); + setSecondInputCodingSystemValue(doc, FhirAuthorizationCache.CS_READ_ACCESS); + + List items = linter.lint(doc, BASE_TASK_FILE); + + assertTrue(containsType(items, LintingType.FHIR_TASK_INPUT_CODING_SYSTEM_NOT_IN_VALUE_SET), + "Expected FHIR_TASK_INPUT_CODING_SYSTEM_NOT_IN_VALUE_SET"); + } + + @Test + void shouldReportCodeUnknownForSystemWhenBindingContextPasses() throws Exception + { + Document doc = parseXml(); + + Class sliceCardClass = Class.forName("dev.dsf.linter.fhir.FhirTaskLinter$SliceCard"); + Constructor ctor = sliceCardClass.getDeclaredConstructors()[0]; + ctor.setAccessible(true); + Object sliceCard = ctor.newInstance(0, 1, + FhirAuthorizationCache.CS_BPMN_MESSAGE, null, null); + + Map cards = new HashMap<>(); + cards.put("does-not-exist", sliceCard); + + List out = new ArrayList<>(); + Method method = FhirTaskLinter.class.getDeclaredMethod( + "lintInputTypeCodingTerminology", + Document.class, File.class, String.class, List.class, Map.class); + method.setAccessible(true); + method.invoke(linter, doc, new File("custom-task.xml"), "custom-ref", out, cards); + + String types = out.stream().map(i -> i.getType().name()).distinct().collect(Collectors.joining(", ")); + assertTrue(containsType(out, LintingType.FHIR_TASK_INPUT_CODING_CODE_UNKNOWN_FOR_SYSTEM), + "Expected FHIR_TASK_INPUT_CODING_CODE_UNKNOWN_FOR_SYSTEM, got: " + types); + assertFalse(containsType(out, LintingType.FHIR_TASK_INPUT_CODING_SYSTEM_UNKNOWN), + "System is known in the cache"); + assertFalse(containsType(out, LintingType.FHIR_TASK_INPUT_CODING_SYSTEM_NOT_IN_VALUE_SET), + "Binding context passes via fixed system in synthetic slice metadata"); + } + + private static boolean containsType(List items, LintingType type) + { + return items.stream().anyMatch(i -> i.getType() == type); + } + + private static void setSecondInputCodingSystemValue(Document doc, String value) throws Exception + { + Node node = (Node) XPathFactory.newInstance().newXPath() + .compile(SECOND_INPUT_CODING_SYSTEM_XPATH) + .evaluate(doc, XPathConstants.NODE); + if (node == null) + throw new IllegalStateException("XPath node not found: " + SECOND_INPUT_CODING_SYSTEM_XPATH); + node.setNodeValue(value); + } + + private static Document parseXml() throws Exception + { + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + return dbf.newDocumentBuilder().parse(new InputSource(new StringReader(CODE_UNKNOWN_TASK_XML))); + } + + private static final class SilentLogger implements Logger + { + @Override + public void info(String message) + { + } + + @Override + public void warn(String message) + { + } + + @Override + public void error(String message) + { + } + + @Override + public void error(String message, Throwable throwable) + { + } + + @Override + public void debug(String message) + { + } + + @Override + public boolean verbose() + { + return false; + } + + @Override + public boolean isVerbose() + { + return false; + } + } +} + From b3bcdef67dd84b9ed469974c0713baa7d136970e Mon Sep 17 00:00:00 2001 From: khalilmalla95 Date: Tue, 5 May 2026 17:04:57 +0200 Subject: [PATCH 6/6] Refine error message for unknown coding systems in `LintingType` and `FhirTaskLinter`. --- .../src/main/java/dev/dsf/linter/fhir/FhirTaskLinter.java | 2 +- .../src/main/java/dev/dsf/linter/output/LintingType.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/linter-core/src/main/java/dev/dsf/linter/fhir/FhirTaskLinter.java b/linter-core/src/main/java/dev/dsf/linter/fhir/FhirTaskLinter.java index 8afab44..ec44b2f 100644 --- a/linter-core/src/main/java/dev/dsf/linter/fhir/FhirTaskLinter.java +++ b/linter-core/src/main/java/dev/dsf/linter/fhir/FhirTaskLinter.java @@ -587,7 +587,7 @@ private void lintInputTypeCodingTerminology(Document doc, File f, String ref, if (!FhirAuthorizationCache.containsSystem(sys)) { out.add(new FhirElementLintItem(LinterSeverity.ERROR, LintingType.FHIR_TASK_INPUT_CODING_SYSTEM_UNKNOWN, f, ref, - "Task.input.type.coding.system '" + sys + "' is not a known CodeSystem URI.")); + "Task.input.type.coding.system '" + sys + "' was not found on the classpath or the project directory.")); continue; // checks 2 and 3 require the system to be known } diff --git a/linter-core/src/main/java/dev/dsf/linter/output/LintingType.java b/linter-core/src/main/java/dev/dsf/linter/output/LintingType.java index 2132fd7..0a838dc 100644 --- a/linter-core/src/main/java/dev/dsf/linter/output/LintingType.java +++ b/linter-core/src/main/java/dev/dsf/linter/output/LintingType.java @@ -178,7 +178,7 @@ public enum LintingType { FHIR_TASK_INPUT_SLICE_COUNT_BELOW_SLICE_MIN("Task input slice count below slice minimum."), FHIR_TASK_INPUT_SLICE_COUNT_EXCEEDS_SLICE_MAX("Task input slice count exceeds slice maximum."), FHIR_TASK_UNKNOWN_CODE("Task has unknown code (outside Task.input.type.coding)."), - FHIR_TASK_INPUT_CODING_SYSTEM_UNKNOWN("Task.input.type.coding.system is not a known CodeSystem URI."), + FHIR_TASK_INPUT_CODING_SYSTEM_UNKNOWN("Task.input.type.coding.system was not found on the classpath or the project directory."), FHIR_TASK_INPUT_CODING_SYSTEM_NOT_IN_VALUE_SET("Task.input.type.coding.system is not allowed by the expected ValueSet binding context."), FHIR_TASK_INPUT_CODING_CODE_UNKNOWN_FOR_SYSTEM("Task.input.type.coding.code is unknown in the specified CodeSystem."), FHIR_TASK_REQUESTER_ID_NOT_EXIST("Task requester ID does not exist."),