diff --git a/README.md b/README.md index 9c0c6bbb..9198a4cf 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ java -jar linter-cli/target/linter-cli-0.1.2.jar \ | `--html` | Generate HTML report | | `--json` | Generate JSON report | | `--report-path ` | Custom report directory | +| `--exclusions ` | Path to a JSON exclusion config (see [Excluding Issues](#excluding-issues)) | | `--verbose` | Verbose logging | | `--no-color` | Disable colored output (default: enabled) | | `--no-fail` | Exit 0 even on errors | @@ -135,6 +136,83 @@ java -jar linter-cli/target/linter-cli-0.1.2.jar \ | `TERM=dumb` | Disables colored output | | `WT_SESSION`, `ANSICON` | Windows color detection | +## Excluding Issues + +Users often encounter known, intentional, or external findings that clutter reports and make triage harder. The exclusion system lets you suppress specific lint items from HTML and JSON reports without modifying the plugin source. + +### Configuration file + +Create a file named **`dsf-linter-exclusions.json`** in the project root (auto-discovered) or point to it explicitly with `--exclusions`: + +```json +{ + "affectsExitStatus": false, + "rules": [ + { "type": "BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING" }, + { "severity": "WARN", "file": "update-allow-list.bpmn" }, + { "messageContains": "optional field" } + ] +} +``` + +### Rule fields + +Each rule is an **AND** combination of its non-null fields. Multiple rules are **OR**-combined — an item is excluded when *any* rule matches. + +| Field | Match type | Example | +|---|---|---| +| `type` | Exact (case-insensitive) match against the `LintingType` enum name | `"BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING"` | +| `severity` | Exact (case-insensitive) match against the severity level | `"WARN"`, `"ERROR"`, `"INFO"` | +| `file` | Case-insensitive substring match against the file name | `"update-allow-list"` | +| `messageContains` | Case-insensitive substring match against the issue description | `"optional field"` | + +Every rule must specify at least one field — a rule with no criteria is rejected on load. + +### Exit-status control + +| `affectsExitStatus` | Behaviour | +|---|---| +| `false` *(default)* | Excluded items are fully suppressed — they do **not** appear in reports and do **not** count towards the exit code | +| `true` | Excluded items are hidden from reports, but their error count **still** contributes to the exit code (non-zero exit on errors) | + +### Usage examples + +```bash + +# Explicit exclusion file +java -jar linter-cli-0.1.2.jar \ + --path plugin.jar --html \ + --exclusions /path/to/my-exclusions.json + +# Exclude only from reports, but still fail on excluded errors +``` + +Exclusion file with `affectsExitStatus: true`: +```json +{ + "affectsExitStatus": true, + "rules": [ + { "type": "BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING" } + ] +} +``` + +### Available `LintingType` values + +All available type names are defined in `LintingType.java`. A few common examples: + +| Type | Description | +|---|---| +| `BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING` | `historyTimeToLive` not set on process | +| `BPMN_PROCESS_NOT_EXECUTABLE` | Process `isExecutable` not set to `true` | +| `STRUCTURE_DEFINITION_SNAPSHOT_PRESENT` | StructureDefinition should not contain a snapshot | +| `FHIR_TASK_STATUS_NOT_DRAFT` | Task status is not `draft` | +| `PLUGIN_DEFINITION_MISSING_SERVICE_LOADER_REGISTRATION` | Plugin missing ServiceLoader registration | + +See `linter-core/src/main/java/dev/dsf/linter/output/LintingType.java` for the full list. + +--- + ## Project Structure ``` @@ -143,6 +221,7 @@ dsf-linter/ │ ├── src/main/java/dev/dsf/linter/ │ │ ├── analysis/ # Resource analysis │ │ ├── bpmn/ # BPMN parsing & validation +│ │ ├── exclusion/ # Exclusion rules & filtering │ │ ├── fhir/ # FHIR parsing & validation │ │ ├── service/ # Linting services (BPMN, FHIR, Plugin) │ │ ├── output/ # Lint item definitions @@ -189,10 +268,11 @@ dsf-linter/ ### Testing ```bash -mvn test # All tests -mvn test -Dtest=BpmnLoadingTest # Specific test -mvn test -X # Verbose -mvn clean package -DskipTests # Build without tests +mvn test # All tests +mvn test -Dtest=BpmnLoadingTest # Specific test +mvn test -Dtest=ExclusionFilterTest # Exclusion filter tests +mvn test -X # Verbose +mvn clean package -DskipTests # Build without tests ``` ### Development Workflow @@ -237,6 +317,8 @@ java -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005 \ | `FhirLintingService` | FHIR validation | | `PluginLintingService` | Plugin validation | | `LintingReportGenerator` | Report generation | +| `ExclusionFilter` | Suppresses lint items matched by exclusion rules | +| `ExclusionConfigLoader` | Loads `dsf-linter-exclusions.json` | ## Report Output diff --git a/linter-cli/src/main/java/dev/dsf/linter/LinterExecutor.java b/linter-cli/src/main/java/dev/dsf/linter/LinterExecutor.java index 2ea1c5e7..33451d5f 100644 --- a/linter-cli/src/main/java/dev/dsf/linter/LinterExecutor.java +++ b/linter-cli/src/main/java/dev/dsf/linter/LinterExecutor.java @@ -1,5 +1,6 @@ package dev.dsf.linter; +import dev.dsf.linter.exclusion.ExclusionConfig; import dev.dsf.linter.logger.Logger; import java.nio.file.Path; @@ -22,26 +23,29 @@ public class LinterExecutor { private final boolean generateHtmlReport; private final boolean generateJsonReport; private final boolean failOnErrors; + private final ExclusionConfig exclusionConfig; private final Logger logger; /** * Constructs a new LinterExecutor with the specified parameters. * - * @param projectPath the path to the project to lint - * @param reportPath the path where reports should be generated + * @param projectPath the path to the project to lint + * @param reportPath the path where reports should be generated * @param generateHtmlReport whether to generate an HTML report * @param generateJsonReport whether to generate a JSON report - * @param failOnErrors whether to fail (exit code 1) if errors are found - * @param logger the logger for output + * @param failOnErrors whether to fail (exit code 1) if errors are found + * @param exclusionConfig optional exclusion config; {@code null} disables exclusions + * @param logger the logger for output */ public LinterExecutor(Path projectPath, Path reportPath, boolean generateHtmlReport, boolean generateJsonReport, - boolean failOnErrors, Logger logger) { + boolean failOnErrors, ExclusionConfig exclusionConfig, Logger logger) { this.projectPath = projectPath; this.reportPath = reportPath; this.generateHtmlReport = generateHtmlReport; this.generateJsonReport = generateJsonReport; this.failOnErrors = failOnErrors; + this.exclusionConfig = exclusionConfig; this.logger = logger; } @@ -63,6 +67,7 @@ public DsfLinter.OverallLinterResult execute() throws Exception { generateHtmlReport, generateJsonReport, failOnErrors, + exclusionConfig, logger ); diff --git a/linter-cli/src/main/java/dev/dsf/linter/Main.java b/linter-cli/src/main/java/dev/dsf/linter/Main.java index d5cc06f2..80dcd505 100644 --- a/linter-cli/src/main/java/dev/dsf/linter/Main.java +++ b/linter-cli/src/main/java/dev/dsf/linter/Main.java @@ -1,5 +1,7 @@ package dev.dsf.linter; +import dev.dsf.linter.exclusion.ExclusionConfig; +import dev.dsf.linter.exclusion.ExclusionConfigLoader; import dev.dsf.linter.input.InputResolver; import dev.dsf.linter.logger.ConsoleLogger; import dev.dsf.linter.logger.Logger; @@ -79,6 +81,13 @@ public class Main implements Callable { description = "Disable colored console output. (Default: enabled)") private boolean disableColor = false; + @Option(names = "--exclusions", + description = "Path to a JSON file containing exclusion rules. " + + "Excluded items are hidden from reports. " + + "If omitted, the linter also looks for 'dsf-linter-exclusions.json' " + + "in the project root automatically.") + private Path exclusionsFile = null; + /** * Main entry point for the DSF Linter CLI application. @@ -177,6 +186,30 @@ public Integer call() { return 1; } + // Load exclusion config (explicit file wins over auto-discovery) + ExclusionConfig exclusionConfig = null; + try { + ExclusionConfigLoader exclusionLoader = new ExclusionConfigLoader(); + if (exclusionsFile != null) { + exclusionConfig = exclusionLoader.load(exclusionsFile); + logger.info("Loaded exclusion rules from: " + exclusionsFile); + } else { + Optional auto = exclusionLoader.loadFromProjectRoot(projectPath); + if (auto.isPresent()) { + exclusionConfig = auto.get(); + logger.info("Auto-discovered exclusion rules from: " + + projectPath.resolve(ExclusionConfigLoader.DEFAULT_FILENAME)); + } + } + if (exclusionConfig != null) { + logger.info("Exclusion rules loaded: " + exclusionConfig.getRules().size() + + " rule(s), affectsExitStatus=" + exclusionConfig.isAffectsExitStatus()); + } + } catch (IOException e) { + logger.error("ERROR: Failed to load exclusion config: " + e.getMessage(), e); + return 1; + } + try { // Execute linting LinterExecutor executor = new LinterExecutor( @@ -185,6 +218,7 @@ public Integer call() { generateHtmlReport, generateJsonReport, !noFailOnErrors, + exclusionConfig, logger ); diff --git a/linter-core/pom.xml b/linter-core/pom.xml index b67cdf5c..fd3be9b1 100644 --- a/linter-core/pom.xml +++ b/linter-core/pom.xml @@ -98,7 +98,7 @@ org.thymeleaf thymeleaf - 3.1.4.RELEASE + 3.1.5.RELEASE 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()); + } +}