diff --git a/linter-core/src/main/java/dev/dsf/linter/DsfLinter.java b/linter-core/src/main/java/dev/dsf/linter/DsfLinter.java
index 7172a40e..ad05c30c 100644
--- a/linter-core/src/main/java/dev/dsf/linter/DsfLinter.java
+++ b/linter-core/src/main/java/dev/dsf/linter/DsfLinter.java
@@ -3,6 +3,8 @@
import dev.dsf.linter.analysis.LeftoverResourceDetector;
import dev.dsf.linter.exception.MissingServiceRegistrationException;
import dev.dsf.linter.exception.ResourceLinterException;
+import dev.dsf.linter.exclusion.ExclusionConfig;
+import dev.dsf.linter.exclusion.ExclusionFilter;
import dev.dsf.linter.logger.Console;
import dev.dsf.linter.logger.Logger;
import dev.dsf.linter.report.LintingReportGenerator;
@@ -51,12 +53,13 @@ public class DsfLinter {
/**
* Configuration for the DSF Linter.
*
- * @param projectPath the path to the project root directory
- * @param reportPath the path where linting reports should be generated
+ * @param projectPath the path to the project root directory
+ * @param reportPath the path where linting reports should be generated
* @param generateHtmlReport whether to generate an HTML report
* @param generateJsonReport whether to generate a JSON report
- * @param failOnErrors whether the linter should fail (exit code 1) when errors are found
- * @param logger the logger instance for output
+ * @param failOnErrors whether the linter should fail (exit code 1) when errors are found
+ * @param exclusionConfig optional exclusion configuration; {@code null} means no exclusions
+ * @param logger the logger instance for output
*/
public record Config(
Path projectPath,
@@ -64,24 +67,35 @@ public record Config(
boolean generateHtmlReport,
boolean generateJsonReport,
boolean failOnErrors,
+ ExclusionConfig exclusionConfig,
Logger logger
) {
+ /**
+ * Backward-compatible constructor without exclusion config.
+ */
+ public Config(Path projectPath, Path reportPath, boolean generateHtmlReport,
+ boolean generateJsonReport, boolean failOnErrors, Logger logger) {
+ this(projectPath, reportPath, generateHtmlReport, generateJsonReport,
+ failOnErrors, null, logger);
+ }
}
/**
* Linting result for a single plugin.
*
- * @param pluginName the name of the plugin
- * @param pluginClass the fully qualified class name of the plugin
- * @param apiVersion the DSF API version used by the plugin
- * @param output the detailed linting output (errors, warnings, etc.)
- * @param reportPath the path to the generated report for this plugin
+ * @param pluginName the name of the plugin
+ * @param pluginClass the fully qualified class name of the plugin
+ * @param apiVersion the DSF API version used by the plugin
+ * @param output the linting output after exclusions have been applied (used for reports)
+ * @param excludedErrorCount the number of ERROR-severity items that were excluded by exclusion rules
+ * @param reportPath the path to the generated report for this plugin
*/
public record PluginLinter(
String pluginName,
String pluginClass,
ApiVersion apiVersion,
LintingOutput output,
+ int excludedErrorCount,
Path reportPath
) {
}
@@ -128,12 +142,6 @@ public int getLeftoverCount() {
return leftoverAnalysis != null ? leftoverAnalysis.getTotalLeftoverCount() : 0;
}
- /**
- * Get total error count across all plugins and project-level leftovers.
- */
- public int getTotalErrors() {
- return getPluginErrors() + getLeftoverCount();
- }
}
private final Config config;
@@ -165,6 +173,9 @@ public DsfLinter(Config config) {
this.config = config;
this.logger = config.logger();
Console.init(logger);
+ ExclusionFilter exclusionFilter = config.exclusionConfig() != null
+ ? new ExclusionFilter(config.exclusionConfig())
+ : null;
this.setupHandler = new ProjectSetupHandler(logger);
this.discoveryService = new ResourceDiscoveryService(logger);
BpmnLintingService bpmnLinter = new BpmnLintingService(logger);
@@ -179,6 +190,7 @@ public DsfLinter(Config config) {
leftoverDetector,
reportGenerator,
config.reportPath(),
+ exclusionFilter,
logger
);
}
@@ -211,11 +223,7 @@ public OverallLinterResult lint() throws IOException {
// Phase 1: Project Setup
reportGenerator.printPhaseHeader("Phase 1: Project Setup");
ProjectSetupHandler.ProjectContext context;
- try {
- context = setupHandler.setupLintingEnvironment(config.projectPath());
- } catch (IOException e) {
- throw e;
- }
+ context = setupHandler.setupLintingEnvironment(config.projectPath());
// Execute all linting phases with temporary context classloader
return ClassLoaderUtils.withTemporaryContextClassLoader(context.projectClassLoader(), () -> {
@@ -253,11 +261,20 @@ public OverallLinterResult lint() throws IOException {
long executionTime = System.currentTimeMillis() - startTime;
reportGenerator.printSummary(pluginLinting, discovery, leftoverResults, executionTime, config);
- // Determine final success status
+ // Determine final success status.
+ // v.output().getErrorCount() reflects only included (non-excluded) items.
+ // When affectsExitStatus=true, add back the count of excluded errors.
+ boolean addExcludedToCount = config.exclusionConfig() != null
+ && config.exclusionConfig().isAffectsExitStatus();
+
int totalPluginErrors = pluginLinting.values().stream()
- .mapToInt(v -> v.output().getErrorCount())
+ .mapToInt(v -> {
+ int reported = v.output().getErrorCount();
+ int excluded = addExcludedToCount ? v.excludedErrorCount() : 0;
+ return reported + excluded;
+ })
.sum();
-
+
// Consider failed plugins as errors (partial success means non-zero exit code)
boolean hasFailedPlugins = discovery.hasFailedPlugins();
boolean success = !config.failOnErrors() || (totalPluginErrors == 0 && !hasFailedPlugins);
diff --git a/linter-core/src/main/java/dev/dsf/linter/exclusion/ExclusionConfig.java b/linter-core/src/main/java/dev/dsf/linter/exclusion/ExclusionConfig.java
new file mode 100644
index 00000000..c0c232b0
--- /dev/null
+++ b/linter-core/src/main/java/dev/dsf/linter/exclusion/ExclusionConfig.java
@@ -0,0 +1,77 @@
+package dev.dsf.linter.exclusion;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Top-level exclusion configuration loaded from {@code dsf-linter-exclusions.json}.
+ *
+ * Contains an ordered list of {@link ExclusionRule}s and an optional flag that controls
+ * whether excluded items still contribute to the exit status.
+ *
+ *
+ * Sample {@code dsf-linter-exclusions.json}
+ * {@code
+ * {
+ * "affectsExitStatus": false,
+ * "rules": [
+ * { "type": "BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING" },
+ * { "severity": "WARN", "file": "update-allow-list.bpmn" },
+ * { "messageContains": "optional field" }
+ * ]
+ * }
+ * }
+ *
+ * @see ExclusionRule
+ * @see ExclusionFilter
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ExclusionConfig {
+
+ /**
+ * When {@code false} (default), excluded items are also removed from exit-status
+ * error/warning counts — they are fully suppressed.
+ * When {@code true}, excluded items still count toward the exit status (they are only
+ * hidden from the generated reports).
+ */
+ private boolean affectsExitStatus = false;
+
+ private List rules = new ArrayList<>();
+
+ public ExclusionConfig() {
+ }
+
+ public ExclusionConfig(List rules, boolean affectsExitStatus) {
+ this.rules = new ArrayList<>(rules);
+ this.affectsExitStatus = affectsExitStatus;
+ }
+
+ public boolean isAffectsExitStatus() {
+ return affectsExitStatus;
+ }
+
+ public void setAffectsExitStatus(boolean affectsExitStatus) {
+ this.affectsExitStatus = affectsExitStatus;
+ }
+
+ public List getRules() {
+ return Collections.unmodifiableList(rules);
+ }
+
+ public void setRules(List rules) {
+ this.rules = rules != null ? new ArrayList<>(rules) : new ArrayList<>();
+ }
+
+ /** Returns {@code true} when there is at least one valid rule to evaluate. */
+ public boolean hasRules() {
+ return rules != null && rules.stream().anyMatch(ExclusionRule::isValid);
+ }
+
+ @Override
+ public String toString() {
+ return "ExclusionConfig{rules=" + rules.size() + ", affectsExitStatus=" + affectsExitStatus + "}";
+ }
+}
diff --git a/linter-core/src/main/java/dev/dsf/linter/exclusion/ExclusionConfigLoader.java b/linter-core/src/main/java/dev/dsf/linter/exclusion/ExclusionConfigLoader.java
new file mode 100644
index 00000000..e481d428
--- /dev/null
+++ b/linter-core/src/main/java/dev/dsf/linter/exclusion/ExclusionConfigLoader.java
@@ -0,0 +1,92 @@
+package dev.dsf.linter.exclusion;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Optional;
+
+/**
+ * Loads an {@link ExclusionConfig} from a JSON file.
+ *
+ * Auto-discovery
+ * {@link #loadFromProjectRoot(Path)} looks for a file named
+ * {@value #DEFAULT_FILENAME} in the project root directory. Use
+ * {@link #load(Path)} to specify an explicit path.
+ *
+ * File format
+ * {@code
+ * {
+ * "affectsExitStatus": false,
+ * "rules": [
+ * { "type": "BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING" },
+ * { "severity": "WARN", "file": "update-allow-list.bpmn" },
+ * { "messageContains": "optional field" }
+ * ]
+ * }
+ * }
+ *
+ * Unknown JSON fields are silently ignored. Rules with no criteria set are skipped.
+ */
+public class ExclusionConfigLoader {
+
+ /** Default file name searched in the project root. */
+ public static final String DEFAULT_FILENAME = "dsf-linter-exclusions.json";
+
+ private final ObjectMapper objectMapper;
+
+ public ExclusionConfigLoader() {
+ this.objectMapper = new ObjectMapper();
+ }
+
+ /**
+ * Looks for {@value #DEFAULT_FILENAME} inside {@code projectRoot}.
+ * Returns an empty Optional when no file is found.
+ *
+ * @param projectRoot the project root directory
+ * @return loaded config, or empty if the file does not exist
+ * @throws IOException if the file exists but cannot be parsed
+ */
+ public Optional loadFromProjectRoot(Path projectRoot) throws IOException {
+ if (projectRoot == null) return Optional.empty();
+
+ Path candidate = projectRoot.resolve(DEFAULT_FILENAME);
+ if (!Files.isRegularFile(candidate)) return Optional.empty();
+
+ return Optional.of(load(candidate));
+ }
+
+ /**
+ * Loads an {@link ExclusionConfig} from the given JSON file.
+ *
+ * @param configFile path to the JSON file
+ * @return the parsed configuration
+ * @throws IOException if the file cannot be read or parsed
+ */
+ public ExclusionConfig load(Path configFile) throws IOException {
+ if (configFile == null || !Files.isRegularFile(configFile)) {
+ throw new IOException("Exclusion config file not found: " + configFile);
+ }
+
+ ExclusionConfig config = objectMapper.readValue(configFile.toFile(), ExclusionConfig.class);
+ validate(config, configFile);
+ return config;
+ }
+
+ // -------------------------------------------------------------------------
+
+ private void validate(ExclusionConfig config, Path source) throws IOException {
+ if (config.getRules() == null) return;
+
+ for (int i = 0; i < config.getRules().size(); i++) {
+ ExclusionRule rule = config.getRules().get(i);
+ if (!rule.isValid()) {
+ throw new IOException(
+ "Exclusion rule #" + (i + 1) + " in '" + source.getFileName()
+ + "' has no criteria. Each rule must specify at least one of: "
+ + "type, severity, file, messageContains.");
+ }
+ }
+ }
+}
diff --git a/linter-core/src/main/java/dev/dsf/linter/exclusion/ExclusionFilter.java b/linter-core/src/main/java/dev/dsf/linter/exclusion/ExclusionFilter.java
new file mode 100644
index 00000000..eefe4ca0
--- /dev/null
+++ b/linter-core/src/main/java/dev/dsf/linter/exclusion/ExclusionFilter.java
@@ -0,0 +1,129 @@
+package dev.dsf.linter.exclusion;
+
+import dev.dsf.linter.output.item.AbstractLintItem;
+import dev.dsf.linter.output.item.BpmnLintItem;
+import dev.dsf.linter.output.item.FhirLintItem;
+import dev.dsf.linter.output.item.PluginLintItem;
+
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Evaluates {@link ExclusionRule}s against lint items and partitions them into
+ * included (visible in reports) and excluded (suppressed) sets.
+ *
+ * Matching logic
+ *
+ * - Rules are OR-combined: an item is excluded if any rule matches it.
+ * - Within a single rule, all non-null fields are AND-combined.
+ *
+ */
+public class ExclusionFilter {
+
+ private final ExclusionConfig config;
+
+ /**
+ * Creates an ExclusionFilter backed by the given configuration.
+ *
+ * @param config the exclusion configuration (must not be {@code null})
+ */
+ public ExclusionFilter(ExclusionConfig config) {
+ if (config == null) throw new IllegalArgumentException("ExclusionConfig must not be null");
+ this.config = config;
+ }
+
+ /**
+ * Returns the underlying configuration.
+ */
+ public ExclusionConfig getConfig() {
+ return config;
+ }
+
+ /**
+ * Returns {@code true} when at least one rule in the configuration matches {@code item}.
+ *
+ * @param item the lint item to test
+ * @return {@code true} if the item should be suppressed
+ */
+ public boolean isExcluded(AbstractLintItem item) {
+ if (item == null) return false;
+ return config.getRules().stream()
+ .filter(ExclusionRule::isValid)
+ .anyMatch(rule -> matches(rule, item));
+ }
+
+ /**
+ * Returns only the items that are NOT excluded.
+ *
+ * @param items the full list of lint items
+ * @return items that pass through (i.e. are visible in reports)
+ */
+ public List filter(List items) {
+ if (items == null || items.isEmpty()) return List.of();
+ return items.stream()
+ .filter(i -> !isExcluded(i))
+ .toList();
+ }
+
+ /**
+ * Returns only the items that ARE excluded by at least one rule.
+ *
+ * @param items the full list of lint items
+ * @return items that were suppressed
+ */
+ public List getExcluded(List items) {
+ if (items == null || items.isEmpty()) return List.of();
+ return items.stream()
+ .filter(this::isExcluded)
+ .toList();
+ }
+
+ // -------------------------------------------------------------------------
+ // Private helpers
+ // -------------------------------------------------------------------------
+
+ private boolean matches(ExclusionRule rule, AbstractLintItem item) {
+ if (hasValue(rule.getType())
+ && !rule.getType().equalsIgnoreCase(item.getType().name())) {
+ return false;
+ }
+
+ if (hasValue(rule.getSeverity())
+ && !rule.getSeverity().equalsIgnoreCase(item.getSeverity().name())) {
+ return false;
+ }
+
+ if (hasValue(rule.getFile())) {
+ String fileHint = resolveFileHint(item);
+ if (fileHint == null
+ || !fileHint.toLowerCase(Locale.ROOT)
+ .contains(rule.getFile().toLowerCase(Locale.ROOT))) {
+ return false;
+ }
+ }
+
+ if (hasValue(rule.getMessageContains())) {
+ String desc = item.getDescription();
+ return desc != null
+ && desc.toLowerCase(Locale.ROOT)
+ .contains(rule.getMessageContains().toLowerCase(Locale.ROOT));
+ }
+
+ return true;
+ }
+
+ /**
+ * Resolves the best available file-name hint for an item.
+ * Uses specific subclass accessors before falling back to {@code toString()}.
+ */
+ private String resolveFileHint(AbstractLintItem item) {
+ if (item instanceof BpmnLintItem b) return b.getBpmnFile();
+ if (item instanceof FhirLintItem f) return f.getFhirFile();
+ if (item instanceof PluginLintItem p) return p.getFileName();
+ return null;
+ }
+
+ private static boolean hasValue(String s) {
+ return s != null && !s.isBlank();
+ }
+}
diff --git a/linter-core/src/main/java/dev/dsf/linter/exclusion/ExclusionRule.java b/linter-core/src/main/java/dev/dsf/linter/exclusion/ExclusionRule.java
new file mode 100644
index 00000000..72e6d635
--- /dev/null
+++ b/linter-core/src/main/java/dev/dsf/linter/exclusion/ExclusionRule.java
@@ -0,0 +1,116 @@
+package dev.dsf.linter.exclusion;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+/**
+ * A single exclusion rule that describes which lint items to suppress from reports.
+ *
+ * All non-null fields are AND-combined: an item is excluded only when every specified
+ * field matches. Multiple rules in an {@link ExclusionConfig} are OR-combined.
+ *
+ *
+ * Matching semantics
+ *
+ * - {@code type} — exact, case-insensitive match against {@link dev.dsf.linter.output.LintingType#name()}
+ * - {@code severity} — exact, case-insensitive match against {@link dev.dsf.linter.output.LinterSeverity#name()}
+ * - {@code file} — case-insensitive substring match against the item's file name
+ * - {@code messageContains} — case-insensitive substring match against the item's description
+ *
+ *
+ * Example (JSON)
+ *
+ * { "type": "BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING" }
+ * { "severity": "WARN", "file": "update-allow-list.bpmn" }
+ * { "messageContains": "optional field" }
+ *
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ExclusionRule {
+
+ private String type;
+ private String severity;
+ private String file;
+ private String messageContains;
+
+ public ExclusionRule() {
+ }
+
+ public ExclusionRule(String type, String severity, String file, String messageContains) {
+ this.type = type;
+ this.severity = severity;
+ this.file = file;
+ this.messageContains = messageContains;
+ }
+
+ /**
+ * Returns the exact {@link dev.dsf.linter.output.LintingType} name to match,
+ * or {@code null} to match any type.
+ */
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String type) {
+ this.type = type;
+ }
+
+ /**
+ * Returns the exact {@link dev.dsf.linter.output.LinterSeverity} name to match,
+ * or {@code null} to match any severity.
+ */
+ public String getSeverity() {
+ return severity;
+ }
+
+ public void setSeverity(String severity) {
+ this.severity = severity;
+ }
+
+ /**
+ * Returns the file-name substring to match (case-insensitive),
+ * or {@code null} to match any file.
+ */
+ public String getFile() {
+ return file;
+ }
+
+ public void setFile(String file) {
+ this.file = file;
+ }
+
+ /**
+ * Returns the description substring to match (case-insensitive),
+ * or {@code null} to match any description.
+ */
+ public String getMessageContains() {
+ return messageContains;
+ }
+
+ public void setMessageContains(String messageContains) {
+ this.messageContains = messageContains;
+ }
+
+ /**
+ * Returns {@code true} when this rule has at least one non-null, non-blank criterion.
+ * An empty rule would match everything and is rejected by the loader.
+ */
+ public boolean isValid() {
+ return hasValue(type) || hasValue(severity) || hasValue(file) || hasValue(messageContains);
+ }
+
+ private static boolean hasValue(String s) {
+ return s != null && !s.isBlank();
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder("ExclusionRule{");
+ if (hasValue(type)) sb.append("type='").append(type).append("', ");
+ if (hasValue(severity)) sb.append("severity='").append(severity).append("', ");
+ if (hasValue(file)) sb.append("file='").append(file).append("', ");
+ if (hasValue(messageContains)) sb.append("messageContains='").append(messageContains).append("', ");
+ if (sb.charAt(sb.length() - 2) == ',') sb.setLength(sb.length() - 2);
+ sb.append("}");
+ return sb.toString();
+ }
+}
diff --git a/linter-core/src/main/java/dev/dsf/linter/service/PluginLintingOrchestrator.java b/linter-core/src/main/java/dev/dsf/linter/service/PluginLintingOrchestrator.java
index d3d199ec..b654fd7a 100644
--- a/linter-core/src/main/java/dev/dsf/linter/service/PluginLintingOrchestrator.java
+++ b/linter-core/src/main/java/dev/dsf/linter/service/PluginLintingOrchestrator.java
@@ -2,6 +2,7 @@
import dev.dsf.linter.DsfLinter;
import dev.dsf.linter.exception.ResourceLinterException;
+import dev.dsf.linter.exclusion.ExclusionFilter;
import dev.dsf.linter.output.LinterSeverity;
import dev.dsf.linter.analysis.LeftoverResourceDetector;
import dev.dsf.linter.exception.MissingServiceRegistrationException;
@@ -32,7 +33,7 @@ public class PluginLintingOrchestrator {
private final LeftoverResourceDetector leftoverDetector;
private final LintingReportGenerator reportGenerator;
private final Path reportBasePath;
- private final Logger logger;
+ private final ExclusionFilter exclusionFilter;
/**
* Context information for validating a plugin in a multi-plugin environment.
@@ -51,6 +52,7 @@ public PluginLintingOrchestrator(
LeftoverResourceDetector leftoverDetector,
LintingReportGenerator reportGenerator,
Path reportBasePath,
+ ExclusionFilter exclusionFilter,
Logger logger) {
this.bpmnLinter = bpmnLinter;
this.fhirLinter = fhirLinter;
@@ -58,7 +60,7 @@ public PluginLintingOrchestrator(
this.leftoverDetector = leftoverDetector;
this.reportGenerator = reportGenerator;
this.reportBasePath = reportBasePath;
- this.logger = logger;
+ this.exclusionFilter = exclusionFilter;
}
/**
@@ -248,6 +250,9 @@ private GroupedLintingItems groupAndFilterItems(
/**
* Builds the final PluginLinter result for a single plugin.
+ * If an {@link ExclusionFilter} is configured, excluded items are removed from the
+ * output that flows into reports, and their error count is stored separately so the
+ * caller can decide whether they should still affect the exit status.
*/
private DsfLinter.PluginLinter buildPluginLintResult(
String pluginName,
@@ -257,20 +262,32 @@ private DsfLinter.PluginLinter buildPluginLintResult(
List metadataItems,
List leftoverItems) throws IOException {
- List finalLintingItems = new ArrayList<>();
- finalLintingItems.addAll(itemsCollection.nonPluginItems);
- finalLintingItems.addAll(pluginResult.getItems());
+ List allItems = new ArrayList<>();
+ allItems.addAll(itemsCollection.nonPluginItems);
+ allItems.addAll(pluginResult.getItems());
// Add metadata Lint Items to final result
if (metadataItems != null && !metadataItems.isEmpty()) {
- finalLintingItems.addAll(metadataItems);
+ allItems.addAll(metadataItems);
}
-
if (leftoverItems != null && !leftoverItems.isEmpty()) {
- finalLintingItems.addAll(leftoverItems);
+ allItems.addAll(leftoverItems);
+ }
+
+ List reportItems;
+ int excludedErrorCount = 0;
+
+ if (exclusionFilter != null) {
+ List excluded = exclusionFilter.getExcluded(allItems);
+ reportItems = exclusionFilter.filter(allItems);
+ excludedErrorCount = (int) excluded.stream()
+ .filter(i -> i.getSeverity() == LinterSeverity.ERROR)
+ .count();
+ } else {
+ reportItems = allItems;
}
- LintingOutput finalOutput = new LintingOutput(finalLintingItems);
+ LintingOutput finalOutput = new LintingOutput(reportItems);
Path pluginReportPath = reportBasePath.resolve(pluginName);
Files.createDirectories(pluginReportPath);
@@ -280,6 +297,7 @@ private DsfLinter.PluginLinter buildPluginLintResult(
plugin.adapter().sourceClass().getName(),
plugin.apiVersion(),
finalOutput,
+ excludedErrorCount,
pluginReportPath
);
}
diff --git a/linter-core/src/test/java/dev/dsf/linter/exclusion/ExclusionFilterTest.java b/linter-core/src/test/java/dev/dsf/linter/exclusion/ExclusionFilterTest.java
new file mode 100644
index 00000000..7bc6f00f
--- /dev/null
+++ b/linter-core/src/test/java/dev/dsf/linter/exclusion/ExclusionFilterTest.java
@@ -0,0 +1,311 @@
+package dev.dsf.linter.exclusion;
+
+import dev.dsf.linter.output.LinterSeverity;
+import dev.dsf.linter.output.LintingType;
+import dev.dsf.linter.output.item.*;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Unit tests for {@link ExclusionFilter}.
+ *
+ * Tests cover: single-field matching, multi-field AND, multi-rule OR,
+ * case-insensitivity, no-match cases, and null/empty safety.
+ */
+class ExclusionFilterTest {
+
+ // -----------------------------------------------------------------------
+ // Helpers
+ // -----------------------------------------------------------------------
+
+ private static ExclusionFilter filterWith(ExclusionRule... rules) {
+ ExclusionConfig config = new ExclusionConfig(List.of(rules), false);
+ return new ExclusionFilter(config);
+ }
+
+ private static ExclusionRule ruleFor(String type, String severity, String file, String messageContains) {
+ return new ExclusionRule(type, severity, file, messageContains);
+ }
+
+ private static BpmnElementLintItem bpmnItem(LinterSeverity sev, LintingType type,
+ String bpmnFile, String description) {
+ return new BpmnElementLintItem(sev, type, "element-1", bpmnFile, "process-1", description);
+ }
+
+ private static FhirElementLintItem fhirItem(LinterSeverity sev, LintingType type,
+ String resourceFile, String description) {
+ return new FhirElementLintItem(sev, type, resourceFile, "http://example.com/ref", description);
+ }
+
+ private static PluginLintItem pluginItem(String fileName) {
+ return new PluginLintItem(LinterSeverity.ERROR, LintingType.PLUGIN_DEFINITION_MISSING_SERVICE_LOADER_REGISTRATION, new File(fileName), "location", "Missing registration");
+ }
+
+ // -----------------------------------------------------------------------
+ // 1. Type-only rule
+ // -----------------------------------------------------------------------
+
+ @Test
+ void excludeByType_matchesExact() {
+ ExclusionFilter filter = filterWith(
+ ruleFor("BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING", null, null, null));
+
+ BpmnElementLintItem item = bpmnItem(LinterSeverity.WARN,
+ LintingType.BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING,
+ "some.bpmn", "Missing historyTimeToLive");
+
+ assertTrue(filter.isExcluded(item), "Should be excluded by type rule");
+ }
+
+ @Test
+ void excludeByType_doesNotMatchOtherType() {
+ ExclusionFilter filter = filterWith(
+ ruleFor("BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING", null, null, null));
+
+ BpmnElementLintItem item = bpmnItem(LinterSeverity.ERROR,
+ LintingType.BPMN_PROCESS_ID_EMPTY,
+ "some.bpmn", "Process ID is empty");
+
+ assertFalse(filter.isExcluded(item), "Different type must not be excluded");
+ }
+
+ @Test
+ void excludeByType_caseInsensitive() {
+ ExclusionFilter filter = filterWith(
+ ruleFor("bpmn_process_history_time_to_live_missing", null, null, null));
+
+ BpmnElementLintItem item = bpmnItem(LinterSeverity.WARN,
+ LintingType.BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING,
+ "some.bpmn", "Missing historyTimeToLive");
+
+ assertTrue(filter.isExcluded(item), "Type match should be case-insensitive");
+ }
+
+ // -----------------------------------------------------------------------
+ // 2. Severity-only rule
+ // -----------------------------------------------------------------------
+
+ @Test
+ void excludeBySeverity_matchesWarn() {
+ ExclusionFilter filter = filterWith(ruleFor(null, "WARN", null, null));
+
+ BpmnElementLintItem warnItem = bpmnItem(LinterSeverity.WARN,
+ LintingType.BPMN_SERVICE_TASK_NAME_EMPTY, "x.bpmn", "msg");
+ BpmnElementLintItem errorItem = bpmnItem(LinterSeverity.ERROR,
+ LintingType.BPMN_SERVICE_TASK_NAME_EMPTY, "x.bpmn", "msg");
+
+ assertTrue(filter.isExcluded(warnItem));
+ assertFalse(filter.isExcluded(errorItem));
+ }
+
+ // -----------------------------------------------------------------------
+ // 3. File-only rule (substring, case-insensitive)
+ // -----------------------------------------------------------------------
+
+ @Test
+ void excludeByFile_substringMatchBpmn() {
+ ExclusionFilter filter = filterWith(ruleFor(null, null, "update-allow-list", null));
+
+ BpmnElementLintItem match = bpmnItem(LinterSeverity.WARN,
+ LintingType.BPMN_SERVICE_TASK_NAME_EMPTY,
+ "update-allow-list.bpmn", "msg");
+ BpmnElementLintItem noMatch = bpmnItem(LinterSeverity.WARN,
+ LintingType.BPMN_SERVICE_TASK_NAME_EMPTY,
+ "other-process.bpmn", "msg");
+
+ assertTrue(filter.isExcluded(match));
+ assertFalse(filter.isExcluded(noMatch));
+ }
+
+ @Test
+ void excludeByFile_substringMatchFhir() {
+ ExclusionFilter filter = filterWith(ruleFor(null, null, "task-example", null));
+
+ FhirElementLintItem match = fhirItem(LinterSeverity.ERROR,
+ LintingType.FHIR_TASK_MISSING_INPUT, "task-example.xml", "msg");
+ FhirElementLintItem noMatch = fhirItem(LinterSeverity.ERROR,
+ LintingType.FHIR_TASK_MISSING_INPUT, "activity-definition.xml", "msg");
+
+ assertTrue(filter.isExcluded(match));
+ assertFalse(filter.isExcluded(noMatch));
+ }
+
+ @Test
+ void excludeByFile_caseInsensitive() {
+ ExclusionFilter filter = filterWith(ruleFor(null, null, "UPDATE-ALLOW-LIST", null));
+
+ BpmnElementLintItem item = bpmnItem(LinterSeverity.WARN,
+ LintingType.BPMN_SERVICE_TASK_NAME_EMPTY,
+ "update-allow-list.bpmn", "msg");
+
+ assertTrue(filter.isExcluded(item));
+ }
+
+ // -----------------------------------------------------------------------
+ // 4. MessageContains-only rule
+ // -----------------------------------------------------------------------
+
+ @Test
+ void excludeByMessage_substringMatch() {
+ ExclusionFilter filter = filterWith(ruleFor(null, null, null, "optional field"));
+
+ FhirElementLintItem match = fhirItem(LinterSeverity.WARN,
+ LintingType.FHIR_VALUE_SET_MISSING_DESCRIPTION,
+ "vs.xml", "This is an optional field that may be omitted");
+ FhirElementLintItem noMatch = fhirItem(LinterSeverity.WARN,
+ LintingType.FHIR_VALUE_SET_MISSING_DESCRIPTION,
+ "vs.xml", "This field is required");
+
+ assertTrue(filter.isExcluded(match));
+ assertFalse(filter.isExcluded(noMatch));
+ }
+
+ @Test
+ void excludeByMessage_caseInsensitive() {
+ ExclusionFilter filter = filterWith(ruleFor(null, null, null, "OPTIONAL FIELD"));
+
+ FhirElementLintItem item = fhirItem(LinterSeverity.WARN,
+ LintingType.FHIR_VALUE_SET_MISSING_DESCRIPTION,
+ "vs.xml", "This is an optional field");
+
+ assertTrue(filter.isExcluded(item));
+ }
+
+ // -----------------------------------------------------------------------
+ // 5. Multi-field AND within a single rule
+ // -----------------------------------------------------------------------
+
+ @Test
+ void andCombination_allFieldsMustMatch() {
+ ExclusionRule rule = ruleFor("BPMN_SERVICE_TASK_NAME_EMPTY", "WARN", "update", null);
+ ExclusionFilter filter = filterWith(rule);
+
+ // Matches type + severity + file
+ BpmnElementLintItem fullMatch = bpmnItem(LinterSeverity.WARN,
+ LintingType.BPMN_SERVICE_TASK_NAME_EMPTY, "update-allow-list.bpmn", "msg");
+
+ // Matches type + severity but wrong file
+ BpmnElementLintItem wrongFile = bpmnItem(LinterSeverity.WARN,
+ LintingType.BPMN_SERVICE_TASK_NAME_EMPTY, "other.bpmn", "msg");
+
+ // Matches type + file but wrong severity
+ BpmnElementLintItem wrongSev = bpmnItem(LinterSeverity.ERROR,
+ LintingType.BPMN_SERVICE_TASK_NAME_EMPTY, "update-allow-list.bpmn", "msg");
+
+ assertTrue(filter.isExcluded(fullMatch));
+ assertFalse(filter.isExcluded(wrongFile));
+ assertFalse(filter.isExcluded(wrongSev));
+ }
+
+ // -----------------------------------------------------------------------
+ // 6. Multi-rule OR combination
+ // -----------------------------------------------------------------------
+
+ @Test
+ void orCombination_matchesEitherRule() {
+ ExclusionFilter filter = filterWith(
+ ruleFor("BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING", null, null, null),
+ ruleFor(null, "WARN", "other.bpmn", null)
+ );
+
+ BpmnElementLintItem matchFirstRule = bpmnItem(LinterSeverity.ERROR,
+ LintingType.BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING, "any.bpmn", "msg");
+
+ BpmnElementLintItem matchSecondRule = bpmnItem(LinterSeverity.WARN,
+ LintingType.BPMN_SERVICE_TASK_NAME_EMPTY, "other.bpmn", "msg");
+
+ BpmnElementLintItem noMatch = bpmnItem(LinterSeverity.ERROR,
+ LintingType.BPMN_SERVICE_TASK_NAME_EMPTY, "other.bpmn", "msg");
+
+ assertTrue(filter.isExcluded(matchFirstRule));
+ assertTrue(filter.isExcluded(matchSecondRule));
+ assertFalse(filter.isExcluded(noMatch));
+ }
+
+ // -----------------------------------------------------------------------
+ // 7. filter() and getExcluded() list operations
+ // -----------------------------------------------------------------------
+
+ @Test
+ void filterList_removesMatchingItems() {
+ ExclusionFilter filter = filterWith(ruleFor("BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING", null, null, null));
+
+ BpmnElementLintItem excluded = bpmnItem(LinterSeverity.WARN,
+ LintingType.BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING, "a.bpmn", "msg");
+ BpmnElementLintItem included = bpmnItem(LinterSeverity.ERROR,
+ LintingType.BPMN_PROCESS_ID_EMPTY, "a.bpmn", "msg");
+
+ List items = List.of(excluded, included);
+ List kept = filter.filter(items);
+ List dropped = filter.getExcluded(items);
+
+ assertEquals(1, kept.size());
+ assertSame(included, kept.getFirst());
+ assertEquals(1, dropped.size());
+ assertSame(excluded, dropped.getFirst());
+ }
+
+ @Test
+ void filterList_emptyInput_returnsEmpty() {
+ ExclusionFilter filter = filterWith(ruleFor("BPMN_PROCESS_ID_EMPTY", null, null, null));
+ assertTrue(filter.filter(List.of()).isEmpty());
+ assertTrue(filter.getExcluded(List.of()).isEmpty());
+ }
+
+ // -----------------------------------------------------------------------
+ // 8. No rules / null safety
+ // -----------------------------------------------------------------------
+
+ @Test
+ void noRules_nothingExcluded() {
+ ExclusionFilter filter = new ExclusionFilter(new ExclusionConfig(List.of(), false));
+
+ BpmnElementLintItem item = bpmnItem(LinterSeverity.ERROR,
+ LintingType.BPMN_PROCESS_ID_EMPTY, "a.bpmn", "msg");
+
+ assertFalse(filter.isExcluded(item));
+ }
+
+ @Test
+ void nullItemIsNotExcluded() {
+ ExclusionFilter filter = filterWith(ruleFor("BPMN_PROCESS_ID_EMPTY", null, null, null));
+ assertFalse(filter.isExcluded(null));
+ }
+
+ // -----------------------------------------------------------------------
+ // 9. PluginLintItem file matching
+ // -----------------------------------------------------------------------
+
+ @Test
+ void excludePluginItem_byFileSubstring() {
+ ExclusionFilter filter = filterWith(ruleFor(null, null, "services", null));
+
+ PluginLintItem match = pluginItem(
+ "target/classes/META-INF/services/some.Service");
+
+ PluginLintItem noMatch = pluginItem(
+ "SomePlugin.class");
+
+ assertTrue(filter.isExcluded(match));
+ assertFalse(filter.isExcluded(noMatch));
+ }
+
+ // -----------------------------------------------------------------------
+ // 10. affectsExitStatus is stored but does not change filter behavior
+ // -----------------------------------------------------------------------
+
+ @Test
+ void affectsExitStatus_storedCorrectly() {
+ ExclusionConfig withFlag = new ExclusionConfig(
+ List.of(ruleFor("BPMN_PROCESS_ID_EMPTY", null, null, null)), true);
+ assertTrue(withFlag.isAffectsExitStatus());
+
+ ExclusionConfig withoutFlag = new ExclusionConfig(
+ List.of(ruleFor("BPMN_PROCESS_ID_EMPTY", null, null, null)), false);
+ assertFalse(withoutFlag.isAffectsExitStatus());
+ }
+}