{@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:
+ *
+ *
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.
+ *
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.
+ *
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.
+ *
+ *
+ *
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 + "' was not found on the classpath or the project directory."));
+ 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.
+ *
+ *
Slice whose map key (slice name) equals {@code inputCode} - DSF convention
+ * where slice names mirror the code value (e.g., {@code message-name}).
+ *
+ *
+ * @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:
+ *
+ *
fixedUri on {@code .type.coding.system} - strict literal comparison.
+ *
binding.valueSet resolvable in the cache - input system must be
+ * listed in the ValueSet's {@code compose.include.system}.
+ *
binding.valueSet declared but not loaded - explicit validation
+ * failure because the expected context cannot be resolved.
+ *
No binding info available - explicit validation failure because no expected
+ * ValueSet context is available.
+ *
+ *
+ *
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; }
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 374b8d21..0a838dc6 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 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."),
FHIR_TASK_REQUESTER_ID_NO_PLACEHOLDER("Task requester ID missing placeholder."),
FHIR_TASK_RECIPIENT_ID_NOT_EXIST("Task recipient ID does not exist."),
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 5d286354..68d972c7 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);
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 00000000..40cae67a
--- /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;
+ }
+ }
+}
+