entry : systemByParentPath.entrySet()) {
+ String parentPath = entry.getKey();
+ String fixedUri = entry.getValue();
+ String sliceRoot = extractSliceRoot(parentPath);
+
+ if (!FhirAuthorizationCache.containsSystem(fixedUri)) {
+ out.add(new FhirElementLintItem(LinterSeverity.WARN,
+ LintingType.STRUCTURE_DEFINITION_FIXED_URI_CODESYSTEM_NOT_FOUND,
+ file, ref,
+ "Slice '" + sliceRoot + "' (" + parentPath + "): fixedUri='" + fixedUri
+ + "' not found in project resources. fixedCode validation is skipped."));
+ continue;
+ }
+
+ String fixedCode = codeByParentPath.get(parentPath);
+ if (fixedCode != null) {
+ if (FhirAuthorizationCache.isUnknown(fixedUri, fixedCode)) {
+ out.add(new FhirElementLintItem(LinterSeverity.ERROR,
+ LintingType.STRUCTURE_DEFINITION_FIXED_CODE_NOT_IN_CODESYSTEM,
+ file, ref,
+ "Slice '" + sliceRoot + "' (" + parentPath + "): fixedCode='" + fixedCode
+ + "' is not a known code in CodeSystem '" + fixedUri + "'."));
+ } else {
+ out.add(ok(file, ref, "Slice '" + sliceRoot + "' (" + parentPath + "): fixedUri/fixedCode pair valid (OK)"));
+ }
+ } else {
+ out.add(ok(file, ref, "Slice '" + sliceRoot + "' (" + parentPath + "): fixedUri='" + fixedUri + "' is a known CodeSystem (OK)"));
+ }
+ }
+ }
+
+ /**
+ * Returns the direct parent path of an element ID by removing the last path segment.
+ *
+ * Used to pair a {@code fixedUri} on a {@code *.system} element with the matching
+ * {@code fixedCode} on the sibling {@code *.code} element that shares the same parent.
+ *
+ * Examples:
+ *
+ * - {@code "Task.input:target-endpoints.type.coding.system"} → {@code "Task.input:target-endpoints.type.coding"}
+ * - {@code "Task.output:data-set-status.value[x].system"} → {@code "Task.output:data-set-status.value[x]"}
+ *
+ *
+ * @param elementId the full element ID string
+ * @return the parent path (everything before the last {@code '.'}), or {@code null} if not applicable
+ */
+ private static String extractParentPath(String elementId) {
+ if (elementId == null) return null;
+ int lastDot = elementId.lastIndexOf('.');
+ if (lastDot < 0) return null;
+ return elementId.substring(0, lastDot);
+ }
+
+ /**
+ * Extracts the slice root from a StructureDefinition element ID.
+ *
+ * The slice root is the portion of the element ID up to (but not including) the first
+ * {@code '.'} character that appears after the slice discriminator {@code ':'}.
+ *
+ * Examples:
+ *
+ * - {@code "Task.input:target-endpoints.type.coding.system"} → {@code "Task.input:target-endpoints"}
+ * - {@code "Task.output:ping-status.value[x].system"} → {@code "Task.output:ping-status"}
+ *
+ *
+ * @param elementId the full element ID string
+ * @return the slice root, or {@code null} if the ID has no slice discriminator
+ */
+ private static String extractSliceRoot(String elementId) {
+ if (elementId == null) return null;
+ int colon = elementId.indexOf(':');
+ if (colon < 0) return null;
+ int dot = elementId.indexOf('.', colon + 1);
+ if (dot < 0) return null;
+ return elementId.substring(0, dot);
+ }
+
/* HELPERS */
/**
* Resolves the canonical reference for issue reporting from the StructureDefinition.
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 304d4009..180908b9 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
@@ -214,6 +214,7 @@ public List lint(Document doc, File resFile) {
checkPlaceholders(doc, resFile, ref, issues);
lintTaskIdentifier(doc, resFile, ref, issues);
lintInputs(doc, resFile, ref, issues);
+ lintFixedConstraints(doc, resFile, ref, issues);
lintTerminology(doc, resFile, ref, issues);
lintRequesterAuthorization(doc, resFile, ref, issues);
lintRecipientAuthorization(doc, resFile, ref, issues);
@@ -517,6 +518,200 @@ private void lintTerminology(Document doc, File f, String ref, ListValidation direction
+ * The check is intentionally Task → StructureDefinition:
+ *
+ * - Read all actual {@code (system, code)} pairs from {@code Task.input}.
+ * - Verify each pair against allowed pairs from the referenced StructureDefinition.
+ *
+ *
+ * What is checked
+ *
+ * - If an actual Task input code exists in SD constraints but with a different system
+ * → {@link LintingType#FHIR_TASK_INPUT_FIXED_URI_MISMATCH}
+ * - If an actual Task input system exists in SD constraints but with a different code
+ * → {@link LintingType#FHIR_TASK_INPUT_FIXED_CODE_MISMATCH}
+ * - If an actual Task input {@code (system, code)} pair is not defined by any SD
+ * fixedUri/fixedCode constraint (excluding BPMN message inputs)
+ * → {@link LintingType#FHIR_TASK_INPUT_PAIR_NOT_ALLOWED_BY_SD}
+ *
+ *
+ * @param doc the Task DOM document
+ * @param f the Task resource file
+ * @param ref a human-readable reference (e.g. instantiatesCanonical)
+ * @param out the list where linting results are appended
+ */
+ private void lintFixedConstraints(Document doc, File f, String ref, List out) {
+ String profileUrl = val(doc, TASK_XP + "/*[local-name()='meta']/*[local-name()='profile']/@value");
+ if (blank(profileUrl)) return;
+
+ List constraints = loadFixedCodingConstraints(determineProjectRoot(f), profileUrl);
+ if (constraints.isEmpty()) return;
+
+ NodeList ins = xp(doc, INPUT_XP);
+ if (ins == null || ins.getLength() == 0) return;
+
+ List actualPairs = new ArrayList<>();
+ for (int i = 0; i < ins.getLength(); i++) {
+ Node in = ins.item(i);
+ String sys = val(in, CODING_SYS_XP);
+ String code = val(in, CODING_CODE_XP);
+ if (!blank(sys) || !blank(code)) {
+ actualPairs.add(new String[]{sys, code});
+ }
+ }
+
+ Map> expectedSystemsByCode = new HashMap<>();
+ Map> expectedCodesBySystem = new HashMap<>();
+ Set allowedPairs = new HashSet<>();
+ for (FixedCoding constraint : constraints) {
+ expectedSystemsByCode
+ .computeIfAbsent(constraint.code(), k -> new HashSet<>())
+ .add(constraint.system());
+ expectedCodesBySystem
+ .computeIfAbsent(constraint.system(), k -> new HashSet<>())
+ .add(constraint.code());
+ allowedPairs.add(constraint.system() + "#" + constraint.code());
+ }
+
+ Set reported = new HashSet<>();
+ for (String[] actual : actualPairs) {
+ String actualSys = actual[0];
+ String actualCode = actual[1];
+ if (blank(actualSys) || blank(actualCode)) continue;
+ if (allowedPairs.contains(actualSys + "#" + actualCode)) continue;
+
+ boolean handled = false;
+
+ Set expectedSystems = expectedSystemsByCode.get(actualCode);
+ if (expectedSystems != null && !expectedSystems.contains(actualSys)) {
+ String key = "uri|" + actualSys + "|" + actualCode;
+ if (reported.add(key)) {
+ out.add(new FhirElementLintItem(LinterSeverity.ERROR,
+ LintingType.FHIR_TASK_INPUT_FIXED_URI_MISMATCH, f, ref,
+ "Task.input with code='" + actualCode + "': system='" + actualSys
+ + "' does not match expected fixedUri(s)=" + expectedSystems + "."));
+ }
+ handled = true;
+ }
+
+ if (!handled) {
+ Set expectedCodes = expectedCodesBySystem.get(actualSys);
+ if (expectedCodes != null && !expectedCodes.contains(actualCode)) {
+ String key = "code|" + actualSys + "|" + actualCode;
+ if (reported.add(key)) {
+ out.add(new FhirElementLintItem(LinterSeverity.ERROR,
+ LintingType.FHIR_TASK_INPUT_FIXED_CODE_MISMATCH, f, ref,
+ "Task.input with system='" + actualSys + "': code='" + actualCode
+ + "' does not match expected fixedCode(s)=" + expectedCodes + "."));
+ }
+ handled = true;
+ }
+ }
+
+ // pair is completely unrecognized by the SD's fixedUri/fixedCode constraints.
+ // Exclude bpmn-message inputs as those are validated separately in lintInputs().
+ if (!handled && !SYSTEM_BPMN_MSG.equals(actualSys)) {
+ String key = "unallowed|" + actualSys + "|" + actualCode;
+ if (reported.add(key)) {
+ out.add(new FhirElementLintItem(LinterSeverity.ERROR,
+ LintingType.FHIR_TASK_INPUT_PAIR_NOT_ALLOWED_BY_SD, f, ref,
+ "Task.input pair (system='" + actualSys + "', code='" + actualCode
+ + "') is not defined by any fixedUri/fixedCode constraint in the StructureDefinition."));
+ }
+ }
+ }
+ }
+
+ /**
+ * Parses the StructureDefinition identified by {@code profileUrl} and extracts all
+ * {@code (sliceName, fixedUri, fixedCode)} triples from {@code Task.input} slice elements.
+ *
+ * Only slices that declare both a {@code fixedUri} on the
+ * {@code Task.input:sliceName.type.coding.system} element and a {@code fixedCode} on the
+ * {@code Task.input:sliceName.type.coding.code} element are included in the result.
+ *
+ * @param projectRoot the project root used to locate the StructureDefinition file
+ * @param profileUrl the canonical URL of the Task profile
+ * @return an unmodifiable list of {@link FixedCoding} constraints; empty if the SD cannot
+ * be loaded or no matching constraints are found
+ */
+ private List loadFixedCodingConstraints(File projectRoot, String profileUrl) {
+ FhirResourceLocator locator = FhirResourceLocator.create(projectRoot);
+ File sdFile = locator.findStructureDefinitionFile(profileUrl, projectRoot);
+ if (sdFile == null) return List.of();
+
+ Document sd;
+ try {
+ try { sd = FhirResourceParser.parseXml(sdFile.toPath()); }
+ catch (Exception e) { sd = FhirResourceParser.parseJsonToXml(sdFile.toPath()); }
+ } catch (Exception e) {
+ return List.of();
+ }
+
+ Map systemBySlice = new HashMap<>();
+ Map codeBySlice = new HashMap<>();
+
+ try {
+ NodeList sysElems = (NodeList) XPathFactory.newInstance().newXPath()
+ .compile("//*[local-name()='element'" +
+ " and starts-with(@id,'Task.input:')" +
+ " and contains(@id,'.type.coding.system')]")
+ .evaluate(sd, XPathConstants.NODESET);
+ for (int i = 0; i < sysElems.getLength(); i++) {
+ String id = sysElems.item(i).getAttributes().getNamedItem("id").getNodeValue();
+ String fixedUri = AbstractFhirInstanceLinter.extractSingleNodeValue(
+ sysElems.item(i), "./*[local-name()='fixedUri']/@value");
+ if (fixedUri != null) {
+ String sliceName = extractSliceName(id);
+ if (sliceName != null) systemBySlice.put(sliceName, fixedUri);
+ }
+ }
+
+ NodeList codeElems = (NodeList) XPathFactory.newInstance().newXPath()
+ .compile("//*[local-name()='element'" +
+ " and starts-with(@id,'Task.input:')" +
+ " and contains(@id,'.type.coding.code')]")
+ .evaluate(sd, XPathConstants.NODESET);
+ for (int i = 0; i < codeElems.getLength(); i++) {
+ String id = codeElems.item(i).getAttributes().getNamedItem("id").getNodeValue();
+ String fixedCode = AbstractFhirInstanceLinter.extractSingleNodeValue(
+ codeElems.item(i), "./*[local-name()='fixedCode']/@value");
+ if (fixedCode != null) {
+ String sliceName = extractSliceName(id);
+ if (sliceName != null) codeBySlice.put(sliceName, fixedCode);
+ }
+ }
+ } catch (Exception e) {
+ return List.of();
+ }
+
+ return systemBySlice.entrySet().stream()
+ .filter(e -> codeBySlice.containsKey(e.getKey()))
+ .map(e -> new FixedCoding(e.getKey(), e.getValue(), codeBySlice.get(e.getKey())))
+ .toList();
+ }
+
+ /**
+ * Extracts the slice name from a StructureDefinition element ID of the form
+ * {@code Task.input:sliceName.some.path}.
+ *
+ * @param elementId the full element ID string
+ * @return the slice name, or {@code null} if the ID does not match the expected pattern
+ */
+ private static String extractSliceName(String elementId) {
+ final String prefix = "Task.input:";
+ if (elementId == null || !elementId.startsWith(prefix)) return null;
+ String afterColon = elementId.substring(prefix.length());
+ int dot = afterColon.indexOf('.');
+ if (dot < 0) return null;
+ return afterColon.substring(0, dot);
+ }
+
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,6 +754,12 @@ private boolean instCanonDetermine(Document taskDoc, File taskFile) {
return actFile == null;
}
+ /**
+ * Holds a (system, code) pair extracted from {@code fixedUri} / {@code fixedCode} constraints
+ * in a StructureDefinition slice definition.
+ */
+ private record FixedCoding(String sliceName, String system, String code) {}
+
private record SliceCard(int min, int max) {}
private Map loadInputCardinality(File projectRoot, String profileUrl) {
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..e065a406 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
@@ -142,6 +142,11 @@ public enum LintingType {
STRUCTURE_DEFINITION_SLICE_MAX_TOO_HIGH("StructureDefinition slice max exceeds base max."),
STRUCTURE_DEFINITION_SLICE_MIN_SUM_EXCEEDS_MAX("StructureDefinition slice min sum exceeds max."),
STRUCTURE_DEFINITION_SLICE_MIN_SUM_ABOVE_BASE_MIN("StructureDefinition slice min sum above base min."),
+ STRUCTURE_DEFINITION_BINDING_VALUESET_UNRESOLVED("StructureDefinition binding.valueSet (strength=required) references unknown ValueSet."),
+ STRUCTURE_DEFINITION_BINDING_VALUESET_UNRESOLVED_NON_REQUIRED("StructureDefinition binding.valueSet references unknown ValueSet."),
+ STRUCTURE_DEFINITION_FIXED_URI_CODESYSTEM_NOT_FOUND("StructureDefinition fixedUri not found in project; fixedCode validation is skipped."),
+ STRUCTURE_DEFINITION_FIXED_CODE_NOT_IN_CODESYSTEM("StructureDefinition fixedCode is not a known code in the referenced CodeSystem."),
+
// ==================== FHIR TASK ====================
FHIR_TASK_IDENTIFIER_INVALID_FORMAT("Task identifier with system 'http://dsf.dev/sid/task-identifier' has invalid format. Expected: {process-url}/{process-version}/{task-example-name}"),
@@ -182,6 +187,9 @@ public enum LintingType {
FHIR_TASK_REQUESTER_ID_NO_PLACEHOLDER("Task requester ID missing placeholder."),
FHIR_TASK_RECIPIENT_ID_NOT_EXIST("Task recipient ID does not exist."),
FHIR_TASK_RECIPIENT_ID_NO_PLACEHOLDER("Task recipient ID missing placeholder."),
+ FHIR_TASK_INPUT_FIXED_URI_MISMATCH("Task input coding.system does not match fixedUri from StructureDefinition."),
+ FHIR_TASK_INPUT_FIXED_CODE_MISMATCH("Task input coding.code does not match fixedCode from StructureDefinition."),
+ FHIR_TASK_INPUT_PAIR_NOT_ALLOWED_BY_SD("Task input (system, code) pair is not defined by any fixedUri/fixedCode constraint in the StructureDefinition."),
// ==================== FHIR VALUE SET ====================
FHIR_VALUE_SET_MISSING_URL("ValueSet is missing URL."),
diff --git a/linter-core/src/main/java/dev/dsf/linter/util/resource/FhirResourceExtractor.java b/linter-core/src/main/java/dev/dsf/linter/util/resource/FhirResourceExtractor.java
index b1c852b2..f8c0241a 100644
--- a/linter-core/src/main/java/dev/dsf/linter/util/resource/FhirResourceExtractor.java
+++ b/linter-core/src/main/java/dev/dsf/linter/util/resource/FhirResourceExtractor.java
@@ -132,6 +132,20 @@ public boolean activityDefinitionContainsInstantiatesCanonical(Document doc,
return evaluateXPathExists(doc, xpathExpr);
}
+ /**
+ * Checks whether the given ValueSet {@link Document} contains a {@code } element
+ * whose {@code value} attribute exactly matches the provided canonical URL.
+ *
+ * @param doc the parsed XML {@link Document} representing a FHIR ValueSet
+ * @param url the canonical URL to search for (without version suffix)
+ * @return {@code true} if a matching {@code } element exists; {@code false} otherwise
+ * @throws XPathExpressionException if an XPath evaluation error occurs
+ */
+ public boolean valueSetContainsUrl(Document doc, String url) throws XPathExpressionException {
+ return evaluateXPathExists(doc,
+ "/*[local-name()='ValueSet']/*[local-name()='url' and @value='" + url + "']");
+ }
+
/**
* Checks whether the given Questionnaire {@link Document} contains a {@code } element
* whose {@code value} attribute exactly matches the provided canonical URL.
diff --git a/linter-core/src/main/java/dev/dsf/linter/util/resource/FhirResourceLocator.java b/linter-core/src/main/java/dev/dsf/linter/util/resource/FhirResourceLocator.java
index d2421d69..5a6d526c 100644
--- a/linter-core/src/main/java/dev/dsf/linter/util/resource/FhirResourceLocator.java
+++ b/linter-core/src/main/java/dev/dsf/linter/util/resource/FhirResourceLocator.java
@@ -39,6 +39,7 @@ public final class FhirResourceLocator {
private static final String ACTIVITY_DEFINITION_DIR = "fhir/ActivityDefinition";
private static final String STRUCTURE_DEFINITION_DIR = "fhir/StructureDefinition";
private static final String QUESTIONNAIRE_DIR = "fhir/Questionnaire";
+ private static final String VALUE_SET_DIR = "fhir/ValueSet";
private final ResourceProvider provider;
private final FhirResourceExtractor extractor;
@@ -197,6 +198,24 @@ public boolean questionnaireExists(String formKey, File projectRoot) {
);
}
+ /**
+ * Checks if a ValueSet exists for the given canonical URL.
+ *
+ * Automatically removes version suffixes from the URL before searching.
+ *
+ *
+ * @param url the canonical URL of the ValueSet to search for
+ * @param projectRoot the project root directory (currently unused, kept for API compatibility)
+ * @return true if a ValueSet with the specified URL exists
+ */
+ public boolean valueSetExists(String url, File projectRoot) {
+ String base = ResourcePathNormalizer.removeVersionSuffix(url);
+ return searchInDirectories(
+ entry -> checkValueSetForUrl(entry, base),
+ VALUE_SET_DIR
+ );
+ }
+
/**
* Checks if any ActivityDefinition has the specified message name.
*
@@ -324,6 +343,17 @@ private boolean checkQuestionnaireForUrl(FhirResourceEntry entry, String url) {
});
}
+ private boolean checkValueSetForUrl(FhirResourceEntry entry, String url) {
+ return checkFhirResource(entry, "ValueSet",
+ doc -> {
+ try {
+ return extractor.valueSetContainsUrl(doc, url);
+ } catch (XPathExpressionException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
private boolean checkFhirResource(FhirResourceEntry entry,
String expectedRootName,
Predicate checkLogic) {