This is an automated email from the ASF dual-hosted git repository.
hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new ed87e6ccb4 Issue #8255 : Allow ignoring expected lint findings on
transforms, actions and files (#8268)
ed87e6ccb4 is described below
commit ed87e6ccb43489296c77b32026fe2f3c390e6073
Author: Bart Maertens <[email protected]>
AuthorDate: Sat Sep 5 11:23:27 2026 +0200
Issue #8255 : Allow ignoring expected lint findings on transforms, actions
and files (#8268)
---
.../modules/ROOT/pages/linting/lint-rules.adoc | 33 ++
.../org/apache/hop/lint/ExplorerLintGuiPlugin.java | 280 +++++++++++++++
.../main/java/org/apache/hop/lint/HopLinter.java | 91 ++++-
.../apache/hop/lint/LintCanvasOverlayHelper.java | 25 ++
.../main/java/org/apache/hop/lint/LintPolicy.java | 52 ++-
.../org/apache/hop/lint/LintPolicyYamlWriter.java | 390 +++++++++++++++++++++
.../org/apache/hop/lint/LintResultsManager.java | 29 ++
.../org/apache/hop/lint/LintSuppressDialog.java | 261 ++++++++++++++
.../org/apache/hop/lint/LintSuppressGuiPlugin.java | 335 ++++++++++++++++++
.../org/apache/hop/lint/LinterConfigPlugin.java | 31 ++
.../PipelineLintTransformPainterExtension.java | 8 +
.../hop/lint/PipelineVerifyLintExtension.java | 12 +-
.../lint/WorkflowLintActionPainterExtension.java | 7 +
.../org/apache/hop/lint/registry/RuleRegistry.java | 2 +-
.../hop/lint/registry/YamlRulePackParser.java | 19 +-
.../hop/lint/messages/messages_en_US.properties | 58 +++
.../apache/hop/lint/LintPolicyYamlWriterTest.java | 251 +++++++++++++
.../hop/lint/LintSuppressionInEditorTest.java | 198 +++++++++++
18 files changed, 2061 insertions(+), 21 deletions(-)
diff --git a/docs/hop-user-manual/modules/ROOT/pages/linting/lint-rules.adoc
b/docs/hop-user-manual/modules/ROOT/pages/linting/lint-rules.adoc
index 5d9eeb343f..20bedd4a62 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/linting/lint-rules.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/linting/lint-rules.adoc
@@ -374,6 +374,39 @@ Entries missing either are refused and logged rather than
applied.
Exclusions and suppressions apply in Hop Gui as well as on the command line.
+*Explorer -> right-click a file or folder -> Exclude From Linting* writes an
`exclude` entry for you, keeping the rest of the file, comments included,
exactly as you wrote it, and your reason as a comment above the entry.
+On something already excluded the same item reads *Include In Linting Again*
and takes the entry back out, comment and all.
+Both are in *Tools -> Lint* as well.
+
+A file covered by a broader pattern, `templates/**` rather than its own name,
is left alone: which entry to change is a decision for you rather than a guess
by the menu.
+
+== Accepting findings on a single transform or action
+
+An exclusion covers a whole file, which is right for a template that is
dynamic from end to end and too much for one that is only partly dynamic.
+The usual example is a metadata injection template: its query and its fields
arrive at runtime, so design-time checks report the transforms that are
deliberately empty, on every open, while the rest of the pipeline is worth
checking as normal.
+
+Right-click the transform or action and choose *Ignore lint findings...*.
+You are asked whether to accept everything reported there or only the rules
reported right now, and for a reason, which is required.
+*Check this transform again* on the same menu takes the entries back out.
+Neither is offered on a file that is excluded from linting altogether: there
is nothing to accept when nothing is checked.
+
+Both write to the project's `hop-lint.yml`, never to the pipeline or workflow:
those files are opened by people who do not run the linter, and bookkeeping for
a plugin they do not have installed has no business in them.
+
+[source,yaml]
+----
+suppress:
+ - rule: "*"
+ path: "templates/load-customers.hpl"
+ source: "Fonte Sql"
+ reason: "Connection and SQL are injected at runtime"
+----
+
+`rule: "*"` accepts whatever is reported on that element, including a rule
that starts reporting on it later, which is what a transform filled in at
runtime needs.
+It is only accepted together with a `path` or a `source`: on its own it would
be the linter switched off under another name, and it is refused the same way
an entry without a rule is.
+
+An element whose findings are accepted is drawn with a muted outline on the
canvas rather than nothing at all, so the absence of a warning reads as
somebody's decision rather than as a check that never ran.
+Turn that off with *Mark Ignored Transforms and Actions* in the linter options.
+
== How the effective rule set is built
Rules are resolved by merging every installed rule pack in priority order, and
then applying the project's `hop-lint.yml` on top.
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/ExplorerLintGuiPlugin.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/ExplorerLintGuiPlugin.java
index d86bac1c7f..4c7988d96d 100644
---
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/ExplorerLintGuiPlugin.java
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/ExplorerLintGuiPlugin.java
@@ -17,6 +17,7 @@
package org.apache.hop.lint;
import java.io.File;
+import java.nio.file.Path;
import java.util.List;
import org.apache.commons.vfs2.FileObject;
import org.apache.hop.core.gui.plugin.GuiPlugin;
@@ -28,8 +29,11 @@ import org.apache.hop.core.logging.LogChannel;
import org.apache.hop.core.util.Utils;
import org.apache.hop.core.variables.IVariables;
import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.lint.registry.RuleRegistry;
import org.apache.hop.metadata.api.IHopMetadataProvider;
import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.ui.core.dialog.EnterStringDialog;
import org.apache.hop.ui.core.dialog.ErrorDialog;
import org.apache.hop.ui.core.dialog.MessageBox;
import org.apache.hop.ui.hopgui.BackgroundThreadFacade;
@@ -41,7 +45,10 @@ import
org.apache.hop.ui.hopgui.file.workflow.HopGuiWorkflowGraph;
import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerFile;
import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
import org.eclipse.swt.SWT;
+import org.eclipse.swt.events.MenuAdapter;
+import org.eclipse.swt.events.MenuEvent;
import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.MenuItem;
/** GUI plugin that adds lint actions to the Explorer perspective */
@GuiPlugin(
@@ -49,6 +56,8 @@ import org.eclipse.swt.widgets.Display;
description = "Hop Lint Checker Explorer Integration")
public class ExplorerLintGuiPlugin {
+ private static final Class<?> PKG = ExplorerLintGuiPlugin.class; // for i18n
purposes
+
private static final ILogChannel log = LogChannel.GENERAL;
/** Used when there is no GUI to own this: unit tests. */
@@ -139,6 +148,12 @@ public class ExplorerLintGuiPlugin {
private static final String CONTEXT_MENU_LINT_FILE =
"context-menu-lint-file";
private static final String CONTEXT_MENU_LINT_FOLDER =
"context-menu-lint-folder";
+ /**
+ * Menu items are ordered by id, so this one has to sort after {@link
#CONTEXT_MENU_LINT_FOLDER}
+ * to sit with the other two rather than being pushed above the separator
that starts the group.
+ */
+ private static final String CONTEXT_MENU_LINT_EXCLUDE =
"context-menu-lint-ignore-selection";
+
/** Lint selected file in explorer - context menu */
@GuiMenuElement(
root = ExplorerPerspective.GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
@@ -600,6 +615,271 @@ public class ExplorerLintGuiPlugin {
"HopLinter-Folder");
}
+ /**
+ * Keep the selected file or folder out of linting, on the record in the
project configuration.
+ *
+ * <p>The file-level counterpart to accepting a finding on a single
transform: a template that is
+ * dynamic from end to end has nothing worth checking at design time, and
saying so once beats
+ * marking every transform in it.
+ */
+ @GuiMenuElement(
+ root = ExplorerPerspective.GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+ parentId = ExplorerPerspective.GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+ id = CONTEXT_MENU_LINT_EXCLUDE,
+ type = GuiMenuElementType.MENU_ITEM,
+ label = "i18n::ExplorerLintGuiPlugin.Menu.ExcludeFromLinting.Label",
+ image = "lint-check.svg",
+ separator = false)
+ public void excludeFromLintingContext() {
+ excludeFromLinting();
+ }
+
+ /** Tools → Lint → Exclude From Linting, for the Explorer selection. */
+ @GuiMenuElement(
+ root = HopGui.ID_MAIN_MENU,
+ id = "lint-selected-ignore",
+ type = GuiMenuElementType.MENU_ITEM,
+ label = "i18n::ExplorerLintGuiPlugin.Menu.ExcludeFromLinting.Label",
+ parentId = LinterGuiPlugin.LINT_SUBMENU_ID,
+ image = "lint-check.svg")
+ public static void excludeFromLinting() {
+ try {
+ Selection selection = currentSelection();
+ if (selection == null) {
+ return;
+ }
+
+ if (selection.excluded()) {
+ includeInLintingAgain(selection);
+ } else {
+ excludeFromLinting(selection);
+ }
+ } catch (Exception e) {
+ log.logError("Error changing what is linted: " + e.getMessage(), e);
+ showErrorDialog(
+ BaseMessages.getString(PKG,
"ExplorerLintGuiPlugin.Exclude.Failed.Title"),
+ BaseMessages.getString(PKG,
"ExplorerLintGuiPlugin.Exclude.Failed.Message"),
+ e);
+ }
+ }
+
+ private static void excludeFromLinting(Selection selection) throws Exception
{
+ HopGui hopGui = HopGui.peekInstance();
+ String reason =
+ new EnterStringDialog(
+ hopGui.getShell(),
+ "",
+ BaseMessages.getString(PKG,
"ExplorerLintGuiPlugin.Exclude.Reason.Title"),
+ BaseMessages.getString(
+ PKG, "ExplorerLintGuiPlugin.Exclude.Reason.Message",
selection.pattern()))
+ .open();
+ if (reason == null) {
+ return;
+ }
+
+ LintPolicyYamlWriter.addExclude(selection.projectYaml().toPath(),
selection.pattern(), reason);
+ refreshAfterPolicyChange(selection.path());
+
+ showMessage(
+ BaseMessages.getString(PKG,
"ExplorerLintGuiPlugin.Exclude.Done.Title"),
+ BaseMessages.getString(
+ PKG,
+ "ExplorerLintGuiPlugin.Exclude.Done.Message",
+ selection.pattern(),
+ selection.projectYaml().getPath()),
+ SWT.ICON_INFORMATION);
+ }
+
+ private static void includeInLintingAgain(Selection selection) throws
Exception {
+ boolean removed =
+ LintPolicyYamlWriter.removeExclude(selection.projectYaml().toPath(),
selection.pattern());
+ if (!removed) {
+ // Excluded by a pattern somebody wrote by hand, "templates/**" rather
than this file: the
+ // entry to remove is a judgement call, so say where to look instead of
guessing.
+ showMessage(
+ BaseMessages.getString(PKG,
"ExplorerLintGuiPlugin.Include.ByPattern.Title"),
+ BaseMessages.getString(
+ PKG,
+ "ExplorerLintGuiPlugin.Include.ByPattern.Message",
+ selection.pattern(),
+ selection.projectYaml().getPath()),
+ SWT.ICON_INFORMATION);
+ return;
+ }
+
+ refreshAfterPolicyChange(selection.path());
+ BackgroundLintService.getInstance().scheduleFileLint(selection.path(),
true);
+
+ showMessage(
+ BaseMessages.getString(PKG,
"ExplorerLintGuiPlugin.Include.Done.Title"),
+ BaseMessages.getString(
+ PKG, "ExplorerLintGuiPlugin.Include.Done.Message",
selection.pattern()),
+ SWT.ICON_INFORMATION);
+ }
+
+ /** What is selected in the Explorer, as the project configuration would
record it. */
+ private record Selection(String path, File projectYaml, String pattern,
boolean excluded) {}
+
+ /**
+ * The Explorer selection resolved against the project configuration, or
null when there is
+ * nothing to act on. Complains to the user itself, so callers only have to
check for null.
+ */
+ private static Selection currentSelection() {
+ ExplorerPerspective perspective = HopGui.getExplorerPerspective();
+ ExplorerFile selectedFile = perspective == null ? null :
perspective.getSelectedFile();
+ if (selectedFile == null || Utils.isEmpty(selectedFile.getFilename())) {
+ showMessage(
+ BaseMessages.getString(PKG,
"ExplorerLintGuiPlugin.Exclude.NoSelection.Title"),
+ BaseMessages.getString(PKG,
"ExplorerLintGuiPlugin.Exclude.NoSelection.Message"),
+ SWT.ICON_INFORMATION);
+ return null;
+ }
+ Selection selection = resolveSelection(selectedFile.getFilename());
+ if (selection == null) {
+ explainWhyNot(LintPathUtils.normalizePath(selectedFile.getFilename()));
+ }
+ return selection;
+ }
+
+ /**
+ * Two different problems look the same to the caller: there is no project
configuration to write
+ * to, or there is one but the file sits outside the folder its patterns are
relative to. Saying
+ * which is which is the difference between a message someone can act on and
one they cannot.
+ */
+ private static void explainWhyNot(String path) {
+ File projectYaml = resolveProjectYaml(path);
+ if (projectYaml == null || projectYaml.getParentFile() == null) {
+ showMessage(
+ BaseMessages.getString(PKG,
"ExplorerLintGuiPlugin.Exclude.NoProject.Title"),
+ BaseMessages.getString(PKG,
"ExplorerLintGuiPlugin.Exclude.NoProject.Message"),
+ SWT.ICON_INFORMATION);
+ return;
+ }
+ showMessage(
+ BaseMessages.getString(PKG,
"ExplorerLintGuiPlugin.Exclude.OutsideProject.Title"),
+ BaseMessages.getString(
+ PKG,
+ "ExplorerLintGuiPlugin.Exclude.OutsideProject.Message",
+ projectYaml.getParentFile().getAbsolutePath()),
+ SWT.ICON_INFORMATION);
+ }
+
+ /** As above but silent, for deciding what the menu item should say. */
+ private static Selection resolveSelection(String filename) {
+ String path = LintPathUtils.normalizePath(filename);
+ File projectYaml = resolveProjectYaml(path);
+ if (projectYaml == null || projectYaml.getParentFile() == null) {
+ return null;
+ }
+ Path projectRoot = projectYaml.getParentFile().toPath().toAbsolutePath();
+ String pattern = LintPolicy.relativise(path, projectRoot);
+ if (new File(path).isAbsolute() && pattern.equals(path)) {
+ // relativise hands the path back untouched when it sits outside the
project, and an
+ // absolute path in a portable configuration file would only work on
this machine.
+ return null;
+ }
+ if (isFolder(filename)) {
+ pattern = pattern + "/**";
+ }
+ return new Selection(path, projectYaml, pattern, isExcluded(path));
+ }
+
+ private static boolean isExcluded(String path) {
+ try {
+ HopLinter linter = new HopLinter();
+ linter.loadConfigurationForContext(new File(path));
+ return linter.isExcluded(path);
+ } catch (Exception e) {
+ log.logDetailed("Could not read the lint configuration for " + path + ":
" + e.getMessage());
+ return false;
+ }
+ }
+
+ /** The findings on screen were computed under the old configuration. */
+ private static void refreshAfterPolicyChange(String path) {
+ BackgroundLintService.getInstance().getTracker().invalidate(path);
+ LintResultsManager.getInstance().updateResultsForFile(path, List.of());
+ LintProblemsBarManager.getInstance().updateProblemsBar(path);
+ LintCanvasOverlayRefresh.redrawOpenGraphs();
+ }
+
+ /**
+ * Keep the menu item saying what it will do.
+ *
+ * <p>The wording is decided when the menu opens rather than when it is
built, because it depends
+ * on what is selected: the same item excludes a file that is linted and
puts back one that is
+ * not. A one-way "Exclude From Linting" leaves people editing YAML to undo
a menu click.
+ */
+ @GuiCallback(callbackId =
ExplorerPerspective.GUI_CONTEXT_MENU_CREATED_CALLBACK_ID)
+ public void trackExplorerSelectionForLintMenu() {
+ ExplorerPerspective perspective = ExplorerPerspective.getInstance();
+ if (perspective == null || perspective.getMenuWidgets() == null) {
+ return;
+ }
+ MenuItem item =
perspective.getMenuWidgets().findMenuItem(CONTEXT_MENU_LINT_EXCLUDE);
+ if (item == null || item.isDisposed() || item.getParent() == null) {
+ return;
+ }
+ item.getParent()
+ .addMenuListener(
+ new MenuAdapter() {
+ @Override
+ public void menuShown(MenuEvent event) {
+ updateExcludeMenuItem(item);
+ }
+ });
+ }
+
+ private static void updateExcludeMenuItem(MenuItem item) {
+ if (item.isDisposed()) {
+ return;
+ }
+ try {
+ ExplorerPerspective perspective = HopGui.getExplorerPerspective();
+ ExplorerFile selectedFile = perspective == null ? null :
perspective.getSelectedFile();
+ Selection selection =
+ selectedFile == null || Utils.isEmpty(selectedFile.getFilename())
+ ? null
+ : resolveSelection(selectedFile.getFilename());
+ boolean excluded = selection != null && selection.excluded();
+ item.setText(
+ BaseMessages.getString(
+ PKG,
+ excluded
+ ? "ExplorerLintGuiPlugin.Menu.IncludeInLinting.Label"
+ : "ExplorerLintGuiPlugin.Menu.ExcludeFromLinting.Label"));
+ } catch (Exception e) {
+ log.logDetailed("Could not work out the lint menu wording: " +
e.getMessage());
+ }
+ }
+
+ /**
+ * The hop-lint.yml governing the selection, creating a path for one when
the project has none.
+ */
+ static File resolveProjectYaml(String selectedPath) {
+ File found = RuleRegistry.getInstance().findProjectYaml(new
File(selectedPath));
+ if (found != null) {
+ return found;
+ }
+ try {
+ String projectPath = LinterConfigPlugin.getInstance().getProjectPath();
+ if (!Utils.isEmpty(projectPath)) {
+ return new File(projectPath, "hop-lint.yml");
+ }
+ } catch (Exception e) {
+ log.logDetailed("No project configuration available: " + e.getMessage());
+ }
+ return null;
+ }
+
+ private static boolean isFolder(String filename) {
+ try (FileObject fileObject = HopVfs.getFileObject(filename)) {
+ return fileObject != null && fileObject.isFolder();
+ } catch (Exception e) {
+ return new File(filename).isDirectory();
+ }
+ }
+
private static void showMessage(String title, String message, int style) {
log.logBasic(title + ": " + message);
HopGui hopGui = HopGui.peekInstance();
diff --git a/plugins/misc/lint/src/main/java/org/apache/hop/lint/HopLinter.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/HopLinter.java
index b062ca9952..180d8f7581 100644
--- a/plugins/misc/lint/src/main/java/org/apache/hop/lint/HopLinter.java
+++ b/plugins/misc/lint/src/main/java/org/apache/hop/lint/HopLinter.java
@@ -25,6 +25,7 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.stream.Stream;
import org.apache.hop.core.ICheckResult;
import org.apache.hop.core.IProgressMonitor;
@@ -113,7 +114,9 @@ public class HopLinter {
private void applyEffectiveRuleSet(EffectiveRuleSet resolved) {
this.effectiveRuleSet = resolved;
this.config = resolved.getConfig();
- log.logBasic(
+ // Detailed, not basic: resolving happens on every lint of every file, and
now on every
+ // right-click that has to work out what the lint menu should offer.
+ log.logDetailed(
"Effective lint rules loaded: "
+ resolved.getRules().size()
+ " ("
@@ -486,8 +489,7 @@ public class HopLinter {
runPolicyRules(pipelineMeta, fileName), pipelineMeta));
}
- return LintResultDeduplicator.deduplicate(
- LintCheckResultAdapter.fromCheckResults(remarks, fileName));
+ return applyPolicy(LintCheckResultAdapter.fromCheckResults(remarks,
fileName), fileName);
}
/** Compute workflow lint results the same way as workflow verify plus
optional policy rules. */
@@ -512,8 +514,7 @@ public class HopLinter {
runPolicyRules(workflowMeta, fileName), workflowMeta));
}
- return LintResultDeduplicator.deduplicate(
- LintCheckResultAdapter.fromCheckResults(remarks, fileName));
+ return applyPolicy(LintCheckResultAdapter.fromCheckResults(remarks,
fileName), fileName);
}
private boolean shouldIncludeLintInWorkflowVerify() {
@@ -588,8 +589,84 @@ public class HopLinter {
}
// Suppressions are applied last, so they cover Hop's native remarks as
well as policy
// findings — a team accepting something should not have to care which
produced it.
- return getPolicy()
- .applySuppressions(LintResultDeduplicator.deduplicate(results),
projectRootFor(fileName));
+ return applyPolicy(results, fileName);
+ }
+
+ /**
+ * Deduplicate, then apply the project's {@code exclude:} and {@code
suppress:} configuration.
+ *
+ * <p>Every path that hands results to a caller ends here, the editor ones
included. A decision
+ * that only held on the command line would leave the finding on the canvas,
which is where the
+ * person who wrote it down is looking.
+ */
+ public List<LintResult> applyPolicy(List<LintResult> results, String
fileName) {
+ List<LintResult> deduplicated =
LintResultDeduplicator.deduplicate(results);
+ LintPolicy policy = getPolicy();
+ if (policy.isEmpty()) {
+ // Locating the project configuration walks the filesystem, and the
editor lints on a
+ // keystroke timer. Nothing is excluded or suppressed, so there is
nothing to root against.
+ LintResultsManager.getInstance().setMarkedElements(fileName, Set.of());
+ return deduplicated;
+ }
+
+ Path projectRoot = projectRootFor(fileName);
+ if (policy.isExcluded(fileName, projectRoot)) {
+ // Nothing about this file is checked, so nothing about it is accepted
either: marking a
+ // transform as "findings ignored here" would claim a decision nobody
made.
+ LintResultsManager.getInstance().setMarkedElements(fileName, Set.of());
+ // An excluded file is not linted, however the linter was asked to look
at it: through the
+ // project walk, through an open editor, or by name. Filtering the
findings away here rather
+ // than at each entry point is what keeps those answers the same.
+ log.logDetailed("Not linting " + fileName + ": excluded by the project
configuration");
+ return List.of();
+ }
+
+ LintResultsManager.getInstance()
+ .setMarkedElements(fileName, policy.markedElements(fileName,
projectRoot));
+ return policy.applySuppressions(deduplicated, projectRoot);
+ }
+
+ /**
+ * Whether the project has accepted the findings on this transform or action.
+ *
+ * <p>Read from the configuration rather than from the last run's results: a
menu has to be right
+ * the first time it is opened, including on a file this session has not
linted yet.
+ */
+ public boolean isMarkedElement(String fileName, String elementName) {
+ LintPolicy policy = getPolicy();
+ return !policy.getSuppressions().isEmpty()
+ && policy.markedElements(fileName,
projectRootFor(fileName)).contains(elementName);
+ }
+
+ /** Whether the project keeps this file out of linting entirely. */
+ public boolean isExcluded(String fileName) {
+ LintPolicy policy = getPolicy();
+ return !policy.getExcludes().isEmpty() && policy.isExcluded(fileName,
projectRootFor(fileName));
+ }
+
+ /**
+ * Drop the accepted findings from a list of Hop's own verify remarks, in
place.
+ *
+ * <p>Hop collects those itself, so they arrive here having passed no
suppression. Removing them
+ * keeps the verify tab, the Problems bar and the canvas telling the same
story: a finding the
+ * project has accepted is gone from all three, or from none.
+ */
+ public void removeSuppressed(List<ICheckResult> remarks, String fileName) {
+ if (remarks == null || remarks.isEmpty()) {
+ return;
+ }
+ LintPolicy policy = getPolicy();
+ if (policy.getSuppressions().isEmpty()) {
+ return;
+ }
+ // Resolved once: the project root is the same for every remark, and
finding it walks the
+ // filesystem.
+ Path projectRoot = projectRootFor(fileName);
+ remarks.removeIf(
+ remark -> {
+ LintResult result = LintCheckResultAdapter.fromCheckResult(remark,
fileName);
+ return result != null && policy.isSuppressed(result, projectRoot);
+ });
}
/**
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintCanvasOverlayHelper.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintCanvasOverlayHelper.java
index 4aec2ac15b..16f6ad71af 100644
---
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintCanvasOverlayHelper.java
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintCanvasOverlayHelper.java
@@ -240,6 +240,31 @@ public final class LintCanvasOverlayHelper {
return null;
}
+ /** Whether an element that no longer reports findings should still show it
was marked. */
+ public static boolean isShowingIgnoredMarkers() {
+ try {
+ LinterConfigPlugin config = LinterConfigPlugin.getInstance();
+ return config.isLinterEnabled() && config.isShowIgnoredMarkers();
+ } catch (Exception e) {
+ return true;
+ }
+ }
+
+ /**
+ * A muted outline on an element whose findings have been accepted.
+ *
+ * <p>Deliberately quiet: no badge, no colour that competes with a real
finding. It exists so that
+ * the absence of a warning reads as a decision rather than as a check that
never ran.
+ */
+ public static void drawIgnoredOverlay(IGc gc, int x, int y, int iconSize,
boolean selected) {
+ if (gc == null) {
+ return;
+ }
+ gc.setLineWidth(selected ? 2 : 1);
+ gc.setForeground(IGc.EColor.GRAY);
+ gc.drawRoundRectangle(x - 2, y - 2, iconSize + 3, iconSize + 3, 8, 8);
+ }
+
public static void drawOverlay(
IGc gc, int x, int y, int iconSize, boolean selected, String severity,
double magnification) {
if (gc == null || Utils.isEmpty(severity)) {
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintPolicy.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintPolicy.java
index 6bfa56b6df..31f54767cc 100644
--- a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintPolicy.java
+++ b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintPolicy.java
@@ -21,7 +21,9 @@ import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.nio.file.Paths;
import java.util.ArrayList;
+import java.util.LinkedHashSet;
import java.util.List;
+import java.util.Set;
import org.apache.hop.core.logging.LogChannel;
import org.apache.hop.core.util.Utils;
@@ -50,6 +52,9 @@ import org.apache.hop.core.util.Utils;
*/
public final class LintPolicy {
+ /** A suppression rule id standing for every rule, only valid together with
a path or a source. */
+ public static final String ALL_RULES = "*";
+
private static final LintPolicy EMPTY = new LintPolicy(List.of(), List.of());
private final List<String> excludes;
@@ -109,7 +114,30 @@ public final class LintPolicy {
return kept;
}
- private boolean isSuppressed(LintResult result, Path projectRoot) {
+ /**
+ * The transforms and actions this project has marked in the given file,
whatever rule reports on
+ * them.
+ *
+ * <p>Resolved once per lint run and remembered, so that the canvas can show
the silence is
+ * somebody's decision rather than a check that never ran, without reading
the configuration on
+ * every repaint.
+ */
+ public Set<String> markedElements(String file, Path projectRoot) {
+ if (suppressions.isEmpty()) {
+ return Set.of();
+ }
+ String relative = relativise(file, projectRoot);
+ Set<String> marked = new LinkedHashSet<>();
+ for (Suppression suppression : suppressions) {
+ if (!Utils.isEmpty(suppression.getSource()) &&
suppression.appliesToFile(relative)) {
+ marked.add(suppression.getSource());
+ }
+ }
+ return marked;
+ }
+
+ /** Whether this project has accepted the finding, and it should not be
reported. */
+ public boolean isSuppressed(LintResult result, Path projectRoot) {
String relative = relativise(result.getFileName(), projectRoot);
String sourceName = result.getSource() != null ?
result.getSource().getName() : null;
for (Suppression suppression : suppressions) {
@@ -197,15 +225,27 @@ public final class LintPolicy {
* A suppression must name a rule; path and source narrow it further, and
an omitted one matches
* anything. Suppressing every rule everywhere would be indistinguishable
from switching the
* linter off, so an entry without a rule id is rejected when the
configuration is read.
+ *
+ * <p>{@link LintPolicy#ALL_RULES} stands for "whatever is reported here",
which is what a
+ * transform filled in at runtime needs: its design-time findings are
noise today, and the rule
+ * that reports them tomorrow is noise too. It is only accepted alongside
a path or a source, so
+ * that it narrows to something rather than silencing the project.
*/
boolean matches(String candidateRuleId, String relativePath, String
sourceName) {
- if (!ruleId.equalsIgnoreCase(candidateRuleId)) {
- return false;
- }
- if (!Utils.isEmpty(path) && !LintPolicy.matches(path, relativePath)) {
+ if (!ALL_RULES.equals(ruleId) &&
!ruleId.equalsIgnoreCase(candidateRuleId)) {
return false;
}
- return Utils.isEmpty(source) || source.equalsIgnoreCase(sourceName);
+ return narrowsTo(relativePath, sourceName);
+ }
+
+ /** Whether the entry's path pattern, if it has one, covers this file. */
+ boolean appliesToFile(String relativePath) {
+ return Utils.isEmpty(path) || LintPolicy.matches(path, relativePath);
+ }
+
+ private boolean narrowsTo(String relativePath, String sourceName) {
+ return appliesToFile(relativePath)
+ && (Utils.isEmpty(source) || source.equalsIgnoreCase(sourceName));
}
}
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintPolicyYamlWriter.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintPolicyYamlWriter.java
new file mode 100644
index 0000000000..7aa8f69433
--- /dev/null
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintPolicyYamlWriter.java
@@ -0,0 +1,390 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.lint;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.core.util.Utils;
+import org.yaml.snakeyaml.Yaml;
+
+/**
+ * Adds an exclusion or a suppression to a project's {@code hop-lint.yml} from
the user interface.
+ *
+ * <p>The file is edited as text rather than parsed and rewritten. A project's
lint configuration is
+ * meant to be read and hand-edited — the documentation says as much — and
round-tripping it through
+ * a YAML dumper would return it stripped of every comment and with the keys
in whatever order the
+ * parser felt like. So the new entry is inserted into the block it belongs
to, or a block is
+ * appended when there is none, and the rest of the file is left exactly as
the user wrote it.
+ *
+ * <p>The result is parsed before it is saved. Text editing is the right call
here but it is also
+ * the kind that can produce a file nobody can load, and losing a project's
lint configuration to a
+ * convenience feature would be a poor trade.
+ */
+public final class LintPolicyYamlWriter {
+
+ private static final String EXCLUDE_KEY = "exclude";
+ private static final String SUPPRESS_KEY = "suppress";
+
+ private LintPolicyYamlWriter() {}
+
+ /** Keep a file or folder out of linting entirely. The pattern is
project-relative. */
+ public static void addExclude(Path yamlFile, String pattern, String comment)
throws IOException {
+ if (isBlank(pattern)) {
+ throw new IOException("An exclusion needs a path pattern");
+ }
+ List<String> entry = new ArrayList<>();
+ if (!Utils.isEmpty(comment)) {
+ entry.add(" # " + singleLine(comment));
+ }
+ entry.add(" - " + quote(pattern));
+ write(yamlFile, EXCLUDE_KEY, entry, pattern);
+ }
+
+ /**
+ * Accept a finding on the record.
+ *
+ * @param ruleId the rule to accept, required — a suppression without one
silences everything
+ * @param path project-relative path pattern to narrow it to, may be null
+ * @param source the transform or action name to narrow it to, may be null
+ * @param reason why, required, so the decision can be reviewed later
+ */
+ public static void addSuppression(
+ Path yamlFile, String ruleId, String path, String source, String reason)
throws IOException {
+ if (isBlank(ruleId)) {
+ throw new IOException("A suppression needs a rule id");
+ }
+ if (isBlank(reason)) {
+ throw new IOException("A suppression needs a reason");
+ }
+
+ List<String> entry = new ArrayList<>();
+ entry.add(" - rule: " + quote(ruleId));
+ if (!Utils.isEmpty(path)) {
+ entry.add(" path: " + quote(path));
+ }
+ if (!Utils.isEmpty(source)) {
+ entry.add(" source: " + quote(source));
+ }
+ entry.add(" reason: " + quote(reason));
+ write(yamlFile, SUPPRESS_KEY, entry, ruleId);
+ }
+
+ /**
+ * Put an excluded file or folder back under linting.
+ *
+ * <p>Written as a toggle rather than a one-way door: the menu that excluded
the file is the
+ * obvious place to look for the way back, and hunting through a YAML file
for the line you just
+ * added is not a way back anybody enjoys.
+ *
+ * @return true when an entry was removed
+ */
+ public static boolean removeExclude(Path yamlFile, String pattern) throws
IOException {
+ if (!Files.exists(yamlFile) || isBlank(pattern)) {
+ return false;
+ }
+ String original = Files.readString(yamlFile, StandardCharsets.UTF_8);
+ List<String> lines = new ArrayList<>(List.of(original.split("\n", -1)));
+
+ int keyLine = indexOfTopLevelKey(lines, EXCLUDE_KEY);
+ if (keyLine < 0) {
+ return false;
+ }
+ int blockEnd = endOfBlock(lines, keyLine);
+
+ List<String> kept = new ArrayList<>(lines.subList(0, keyLine + 1));
+ int removed = 0;
+ int survivors = 0;
+ for (List<String> item : listItems(lines, keyLine + 1, blockEnd)) {
+ if (isExcludeOf(item, pattern)) {
+ removed++;
+ } else {
+ survivors++;
+ kept.addAll(item);
+ }
+ }
+ if (removed == 0) {
+ return false;
+ }
+ kept.addAll(lines.subList(blockEnd, lines.size()));
+ if (survivors == 0) {
+ kept.remove(keyLine);
+ }
+
+ String updated = String.join("\n", kept);
+ try {
+ new Yaml().load(updated);
+ } catch (Exception e) {
+ throw new IOException(
+ "Removing the exclusion would have made hop-lint.yml unreadable: " +
e.getMessage(), e);
+ }
+ Files.writeString(yamlFile, updated, StandardCharsets.UTF_8);
+ return true;
+ }
+
+ /**
+ * Whether a list item is the exclusion of this pattern.
+ *
+ * <p>The comment written above an entry belongs to it, and travels with it
when it goes.
+ */
+ private static boolean isExcludeOf(List<String> item, String pattern) {
+ for (String line : item) {
+ String trimmed = line.trim();
+ if (trimmed.isEmpty() || trimmed.startsWith("#")) {
+ continue;
+ }
+ String value = trimmed.startsWith("- ") ? trimmed.substring(2).trim() :
trimmed;
+ return pattern.equals(unquote(value));
+ }
+ return false;
+ }
+
+ /**
+ * Remove the suppressions recorded for one transform or action, whatever
rule they name.
+ *
+ * <p>The counterpart of the dialog: taking a decision back has to be as
easy as making it, or
+ * people work around the linter instead of with it. Entries are matched on
path and source, and
+ * everything else in the file — other suppressions, rules, comments — is
left alone.
+ *
+ * @return how many entries were removed
+ */
+ public static int removeSuppressionsFor(Path yamlFile, String path, String
source)
+ throws IOException {
+ if (!Files.exists(yamlFile) || isBlank(source)) {
+ return 0;
+ }
+ String original = Files.readString(yamlFile, StandardCharsets.UTF_8);
+ List<String> lines = new ArrayList<>(List.of(original.split("\n", -1)));
+
+ int keyLine = indexOfTopLevelKey(lines, SUPPRESS_KEY);
+ if (keyLine < 0) {
+ return 0;
+ }
+ int blockEnd = endOfBlock(lines, keyLine);
+
+ List<String> kept = new ArrayList<>(lines.subList(0, keyLine + 1));
+ int removed = 0;
+ int survivors = 0;
+ for (List<String> item : listItems(lines, keyLine + 1, blockEnd)) {
+ if (matchesEntry(item, path, source)) {
+ removed++;
+ } else {
+ survivors++;
+ kept.addAll(item);
+ }
+ }
+ if (removed == 0) {
+ return 0;
+ }
+ kept.addAll(lines.subList(blockEnd, lines.size()));
+
+ // A suppress: key with nothing under it is valid YAML that reads as an
oversight, so the
+ // last entry takes the key with it.
+ if (survivors == 0) {
+ kept.remove(keyLine);
+ }
+
+ String updated = String.join("\n", kept);
+ try {
+ new Yaml().load(updated);
+ } catch (Exception e) {
+ throw new IOException(
+ "Removing the suppression would have made hop-lint.yml unreadable: "
+ e.getMessage(), e);
+ }
+ Files.writeString(yamlFile, updated, StandardCharsets.UTF_8);
+ return removed;
+ }
+
+ /**
+ * Split a block into its list items, each starting at a line whose first
token is "-".
+ *
+ * <p>A comment sitting above an entry describes it — that is where the
reason for an exclusion is
+ * written — so it is part of that item and goes when the item goes. A
comment with no entry under
+ * it belongs to nobody and becomes an item of its own, which nothing ever
matches.
+ */
+ private static List<List<String>> listItems(List<String> lines, int from,
int to) {
+ List<List<String>> items = new ArrayList<>();
+ List<String> pending = new ArrayList<>();
+ List<String> current = null;
+
+ for (int i = from; i < to; i++) {
+ String line = lines.get(i);
+ String trimmed = line.trim();
+
+ if (trimmed.startsWith("- ")) {
+ current = new ArrayList<>(pending);
+ pending.clear();
+ current.add(line);
+ items.add(current);
+ } else if (trimmed.isEmpty() || trimmed.startsWith("#")) {
+ // Held back: it belongs to the entry below it, if there is one.
+ pending.add(line);
+ } else if (current == null) {
+ current = new ArrayList<>(pending);
+ pending.clear();
+ current.add(line);
+ items.add(current);
+ } else {
+ current.addAll(pending);
+ pending.clear();
+ current.add(line);
+ }
+ }
+ if (!pending.isEmpty()) {
+ items.add(pending);
+ }
+ return items;
+ }
+
+ /** Whether a written entry is the one for this file and element. */
+ private static boolean matchesEntry(List<String> item, String path, String
source) {
+ String entryPath = null;
+ String entrySource = null;
+ boolean isEntry = false;
+ for (String line : item) {
+ String trimmed = line.trim();
+ if (trimmed.startsWith("#")) {
+ continue;
+ }
+ String withoutDash = trimmed.startsWith("- ") ?
trimmed.substring(2).trim() : trimmed;
+ if (withoutDash.startsWith("rule:")) {
+ // Whatever order the keys were written in: this is a suppression
entry.
+ isEntry = true;
+ }
+ if (withoutDash.startsWith("path:")) {
+ entryPath = unquote(withoutDash.substring("path:".length()).trim());
+ } else if (withoutDash.startsWith("source:")) {
+ entrySource =
unquote(withoutDash.substring("source:".length()).trim());
+ }
+ }
+ return isEntry
+ && source.equals(entrySource)
+ && (isBlank(path) ? entryPath == null : path.equals(entryPath));
+ }
+
+ private static String unquote(String value) {
+ String trimmed = value.trim();
+ if (trimmed.length() >= 2 && trimmed.startsWith("\"") &&
trimmed.endsWith("\"")) {
+ return trimmed.substring(1, trimmed.length() - 1).replace("\\\"",
"\"").replace("\\\\", "\\");
+ }
+ return trimmed;
+ }
+
+ private static void write(Path yamlFile, String key, List<String>
entryLines, String expected)
+ throws IOException {
+ String original =
+ Files.exists(yamlFile) ? Files.readString(yamlFile,
StandardCharsets.UTF_8) : "";
+ String updated = insert(original, key, entryLines);
+
+ verify(updated, key, expected);
+
+ if (yamlFile.getParent() != null) {
+ Files.createDirectories(yamlFile.getParent());
+ }
+ Files.writeString(yamlFile, updated, StandardCharsets.UTF_8);
+ }
+
+ static String insert(String original, String key, List<String> entryLines) {
+ List<String> lines = new ArrayList<>(List.of(original.split("\n", -1)));
+ int keyLine = indexOfTopLevelKey(lines, key);
+
+ if (keyLine < 0) {
+ StringBuilder appended = new StringBuilder(original);
+ if (!original.isEmpty() && !original.endsWith("\n")) {
+ appended.append("\n");
+ }
+ if (!original.isBlank()) {
+ appended.append("\n");
+ }
+ appended.append(key).append(":\n");
+ entryLines.forEach(line -> appended.append(line).append("\n"));
+ return appended.toString();
+ }
+
+ lines.addAll(endOfBlock(lines, keyLine), entryLines);
+ return String.join("\n", lines);
+ }
+
+ /** The line holding {@code key:} at the start of a line, or -1. */
+ private static int indexOfTopLevelKey(List<String> lines, String key) {
+ for (int i = 0; i < lines.size(); i++) {
+ String line = lines.get(i);
+ if (line.startsWith(key + ":") && line.substring(key.length() +
1).trim().isEmpty()) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * Where the block belonging to the key ends: the first line that starts a
new top-level key.
+ * Trailing blank lines and comments belong to whatever comes next, so they
stay below the entry.
+ */
+ private static int endOfBlock(List<String> lines, int keyLine) {
+ int lastContent = keyLine;
+ for (int i = keyLine + 1; i < lines.size(); i++) {
+ String line = lines.get(i);
+ if (line.isBlank()) {
+ continue;
+ }
+ boolean partOfBlock = line.startsWith(" ") || line.startsWith("\t") ||
line.startsWith("-");
+ if (!partOfBlock) {
+ break;
+ }
+ if (!line.trim().startsWith("#")) {
+ lastContent = i;
+ }
+ }
+ return lastContent + 1;
+ }
+
+ /** Refuse to save a file that no longer parses, or that lost the entry we
just added. */
+ private static void verify(String updated, String key, String expected)
throws IOException {
+ Object parsed;
+ try {
+ parsed = new Yaml().load(updated);
+ } catch (Exception e) {
+ throw new IOException(
+ "Editing hop-lint.yml would have made it unreadable: " +
e.getMessage(), e);
+ }
+ if (!(parsed instanceof Map)) {
+ throw new IOException("hop-lint.yml is not a YAML mapping, add the entry
by hand");
+ }
+ Object block = ((Map<?, ?>) parsed).get(key);
+ if (!(block instanceof List) || !((List<?>)
block).toString().contains(expected)) {
+ throw new IOException("The " + key + " entry did not survive the edit,
add it by hand");
+ }
+ }
+
+ /** Values are quoted rather than written bare: a pattern like {@code *.hpl}
is not valid YAML. */
+ private static String quote(String value) {
+ return "\"" + singleLine(value).replace("\\", "\\\\").replace("\"",
"\\\"") + "\"";
+ }
+
+ private static boolean isBlank(String value) {
+ return value == null || value.trim().isEmpty();
+ }
+
+ private static String singleLine(String value) {
+ return value.replace("\r", " ").replace("\n", " ").trim();
+ }
+}
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintResultsManager.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintResultsManager.java
index d101da665a..619dce6c01 100644
---
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintResultsManager.java
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintResultsManager.java
@@ -21,6 +21,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.hop.core.logging.ILogChannel;
import org.apache.hop.core.logging.LogChannel;
@@ -48,6 +49,15 @@ public class LintResultsManager {
private final Map<String, Map<String, List<LintResult>>> overlayIndexCache =
new ConcurrentHashMap<>();
+ /**
+ * Per file, the transforms and actions whose findings the project has
accepted.
+ *
+ * <p>Kept next to the findings because the canvas needs both and a painter
cannot afford to read
+ * the project configuration: it runs for every element on every repaint.
Resolved by the linter,
+ * which reads that configuration once per run anyway.
+ */
+ private final Map<String, Set<String>> markedElementsByFile = new
ConcurrentHashMap<>();
+
// Store all results for global view
private final List<LintResult> allResults = new ArrayList<>();
@@ -112,6 +122,25 @@ public class LintResultsManager {
notifyListeners();
}
+ /** Record which transforms or actions of a file carry a suppression. */
+ public void setMarkedElements(String filePath, Set<String> elementNames) {
+ String normalizedPath = LintPathUtils.normalizePath(filePath);
+ if (elementNames == null || elementNames.isEmpty()) {
+ markedElementsByFile.remove(normalizedPath);
+ } else {
+ markedElementsByFile.put(normalizedPath, Set.copyOf(elementNames));
+ }
+ }
+
+ /** Whether the project has accepted the findings on this transform or
action. */
+ public boolean isMarkedElement(String filePath, String elementName) {
+ if (elementName == null) {
+ return false;
+ }
+ Set<String> marked =
markedElementsByFile.get(LintPathUtils.normalizePath(filePath));
+ return marked != null && marked.contains(elementName);
+ }
+
/** Update results for a single file without clearing other file results. */
public synchronized void updateResultsForFile(String filePath,
List<LintResult> results) {
String normalizedPath = LintPathUtils.normalizePath(filePath);
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintSuppressDialog.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintSuppressDialog.java
new file mode 100644
index 0000000000..1a46f8fca2
--- /dev/null
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintSuppressDialog.java
@@ -0,0 +1,261 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.lint;
+
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.i18n.BaseMessages;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.events.SelectionAdapter;
+import org.eclipse.swt.events.SelectionEvent;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Dialog;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.MessageBox;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.Text;
+
+/**
+ * Asks what to accept on a single transform or action, and why.
+ *
+ * <p>The reason is required rather than optional. A finding that simply
vanished would leave the
+ * next person with the same question the reporter of this feature had — is
this checked and fine,
+ * or did somebody switch the check off? — so the answer is written down where
it can be reviewed.
+ *
+ * <p>The answer goes to the project's hop-lint.yml, never into the pipeline
or workflow: those
+ * files are opened by people who do not have this plugin installed, and lint
bookkeeping in them
+ * would be at best noise and at worst something another tool trips over.
+ */
+public class LintSuppressDialog extends Dialog {
+
+ private static final Class<?> PKG = LintSuppressDialog.class; // for i18n
purposes
+
+ /** What the user chose: which rules to accept on this element, and why. */
+ public record Suppression(Set<String> ruleIds, String reason) {}
+
+ private final Shell parent;
+ private final String elementName;
+ private final List<LintResult> findings;
+
+ private Shell shell;
+ private Button allRulesButton;
+ private Button listedRulesButton;
+ private Text reasonText;
+ private Suppression suppression;
+
+ /**
+ * @param elementName the transform or action the findings sit on
+ * @param findings what is currently reported on it
+ */
+ public LintSuppressDialog(Shell parent, String elementName, List<LintResult>
findings) {
+ super(parent, SWT.NONE);
+ this.parent = parent;
+ this.elementName = elementName;
+ this.findings = findings == null ? List.of() : findings;
+ }
+
+ /** Returns what to accept, or null when the user cancelled. */
+ public Suppression open() {
+ shell = new Shell(parent, SWT.DIALOG_TRIM | SWT.RESIZE |
SWT.APPLICATION_MODAL);
+ shell.setText(BaseMessages.getString(PKG,
"LintSuppressDialog.Shell.Title"));
+ shell.setLayout(new FormLayout());
+
+ createContents();
+
+ shell.setSize(560, 420);
+ shell.setLocation(
+ parent.getLocation().x + (parent.getSize().x - 560) / 2,
+ parent.getLocation().y + (parent.getSize().y - 420) / 2);
+ shell.open();
+
+ Display display = parent.getDisplay();
+ while (!shell.isDisposed()) {
+ if (!display.readAndDispatch()) {
+ display.sleep();
+ }
+ }
+ return suppression;
+ }
+
+ private void createContents() {
+ int margin = 10;
+
+ Label header = new Label(shell, SWT.LEFT);
+ header.setText(BaseMessages.getString(PKG,
"LintSuppressDialog.Label.Element", elementName));
+ FormData headerData = new FormData();
+ headerData.left = new FormAttachment(0, margin);
+ headerData.right = new FormAttachment(100, -margin);
+ headerData.top = new FormAttachment(0, margin);
+ header.setLayoutData(headerData);
+
+ Label findingsLabel = new Label(shell, SWT.LEFT);
+ findingsLabel.setText(BaseMessages.getString(PKG,
"LintSuppressDialog.Label.Findings"));
+ FormData findingsLabelData = new FormData();
+ findingsLabelData.left = new FormAttachment(0, margin);
+ findingsLabelData.top = new FormAttachment(header, margin);
+ findingsLabel.setLayoutData(findingsLabelData);
+
+ Text findingsText =
+ new Text(shell, SWT.BORDER | SWT.MULTI | SWT.V_SCROLL | SWT.WRAP |
SWT.READ_ONLY);
+ findingsText.setText(describeFindings());
+ FormData findingsData = new FormData();
+ findingsData.left = new FormAttachment(0, margin);
+ findingsData.right = new FormAttachment(100, -margin);
+ findingsData.top = new FormAttachment(findingsLabel, margin / 2);
+ findingsData.height = 90;
+ findingsText.setLayoutData(findingsData);
+
+ allRulesButton = new Button(shell, SWT.RADIO);
+ allRulesButton.setText(BaseMessages.getString(PKG,
"LintSuppressDialog.Scope.AllRules"));
+ allRulesButton.setToolTipText(
+ BaseMessages.getString(PKG,
"LintSuppressDialog.Scope.AllRules.ToolTip"));
+ FormData allRulesData = new FormData();
+ allRulesData.left = new FormAttachment(0, margin);
+ allRulesData.right = new FormAttachment(100, -margin);
+ allRulesData.top = new FormAttachment(findingsText, margin);
+ allRulesButton.setLayoutData(allRulesData);
+
+ listedRulesButton = new Button(shell, SWT.RADIO);
+ listedRulesButton.setText(
+ BaseMessages.getString(PKG, "LintSuppressDialog.Scope.ListedRules",
ruleList()));
+ listedRulesButton.setToolTipText(
+ BaseMessages.getString(PKG,
"LintSuppressDialog.Scope.ListedRules.ToolTip"));
+ listedRulesButton.setEnabled(!ruleIdsOfFindings().isEmpty());
+ FormData listedRulesData = new FormData();
+ listedRulesData.left = new FormAttachment(0, margin);
+ listedRulesData.right = new FormAttachment(100, -margin);
+ listedRulesData.top = new FormAttachment(allRulesButton, margin / 2);
+ listedRulesButton.setLayoutData(listedRulesData);
+
+ // Accepting everything on the element is the metadata injection case,
which is the reason
+ // this dialog exists; naming the rules is the careful option for anyone
who wants it.
+ allRulesButton.setSelection(true);
+
+ Label reasonLabel = new Label(shell, SWT.LEFT);
+ reasonLabel.setText(BaseMessages.getString(PKG,
"LintSuppressDialog.Label.Reason"));
+ FormData reasonLabelData = new FormData();
+ reasonLabelData.left = new FormAttachment(0, margin);
+ reasonLabelData.top = new FormAttachment(listedRulesButton, margin);
+ reasonLabel.setLayoutData(reasonLabelData);
+
+ Button okButton = new Button(shell, SWT.PUSH);
+ okButton.setText(BaseMessages.getString("System.Button.OK"));
+ Button cancelButton = new Button(shell, SWT.PUSH);
+ cancelButton.setText(BaseMessages.getString("System.Button.Cancel"));
+
+ FormData cancelData = new FormData();
+ cancelData.right = new FormAttachment(100, -margin);
+ cancelData.bottom = new FormAttachment(100, -margin);
+ cancelButton.setLayoutData(cancelData);
+
+ FormData okData = new FormData();
+ okData.right = new FormAttachment(cancelButton, -margin);
+ okData.bottom = new FormAttachment(100, -margin);
+ okButton.setLayoutData(okData);
+
+ reasonText = new Text(shell, SWT.BORDER | SWT.MULTI | SWT.V_SCROLL |
SWT.WRAP);
+ FormData reasonData = new FormData();
+ reasonData.left = new FormAttachment(0, margin);
+ reasonData.right = new FormAttachment(100, -margin);
+ reasonData.top = new FormAttachment(reasonLabel, margin / 2);
+ reasonData.bottom = new FormAttachment(okButton, -margin);
+ reasonText.setLayoutData(reasonData);
+
+ okButton.addSelectionListener(
+ new SelectionAdapter() {
+ @Override
+ public void widgetSelected(SelectionEvent event) {
+ ok();
+ }
+ });
+ cancelButton.addSelectionListener(
+ new SelectionAdapter() {
+ @Override
+ public void widgetSelected(SelectionEvent event) {
+ shell.dispose();
+ }
+ });
+
+ shell.setDefaultButton(okButton);
+ reasonText.setFocus();
+ }
+
+ private void ok() {
+ String reason = reasonText.getText().trim();
+ if (Utils.isEmpty(reason)) {
+ MessageBox box = new MessageBox(shell, SWT.ICON_WARNING | SWT.OK);
+ box.setText(BaseMessages.getString(PKG,
"LintSuppressDialog.ReasonRequired.Title"));
+ box.setMessage(BaseMessages.getString(PKG,
"LintSuppressDialog.ReasonRequired.Message"));
+ box.open();
+ reasonText.setFocus();
+ return;
+ }
+
+ Set<String> ruleIds =
+ allRulesButton.getSelection() ? Set.of(LintPolicy.ALL_RULES) :
ruleIdsOfFindings();
+ if (ruleIds.isEmpty()) {
+ MessageBox box = new MessageBox(shell, SWT.ICON_WARNING | SWT.OK);
+ box.setText(BaseMessages.getString(PKG,
"LintSuppressDialog.NothingToName.Title"));
+ box.setMessage(BaseMessages.getString(PKG,
"LintSuppressDialog.NothingToName.Message"));
+ box.open();
+ return;
+ }
+
+ suppression = new Suppression(ruleIds, reason);
+ shell.dispose();
+ }
+
+ private Set<String> ruleIdsOfFindings() {
+ Set<String> ruleIds = new LinkedHashSet<>();
+ for (LintResult finding : findings) {
+ if (!Utils.isEmpty(finding.getRuleId())) {
+ ruleIds.add(finding.getRuleId());
+ }
+ }
+ return ruleIds;
+ }
+
+ private String ruleList() {
+ Set<String> ruleIds = ruleIdsOfFindings();
+ return ruleIds.isEmpty() ? "-" : String.join(", ", ruleIds);
+ }
+
+ private String describeFindings() {
+ if (findings.isEmpty()) {
+ return BaseMessages.getString(PKG, "LintSuppressDialog.NoFindings");
+ }
+ StringBuilder text = new StringBuilder();
+ for (LintResult finding : findings) {
+ text.append("[")
+ .append(finding.getSeverity())
+ .append("] ")
+ .append(finding.getRuleId())
+ .append(": ")
+ .append(finding.getMessage())
+ .append(Const.CR);
+ }
+ return text.toString();
+ }
+}
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintSuppressGuiPlugin.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintSuppressGuiPlugin.java
new file mode 100644
index 0000000000..7f597f6dcb
--- /dev/null
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintSuppressGuiPlugin.java
@@ -0,0 +1,335 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.lint;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.core.action.GuiContextAction;
+import org.apache.hop.core.action.GuiContextActionFilter;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.action.GuiActionType;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.dialog.MessageBox;
+import org.apache.hop.ui.hopgui.file.pipeline.HopGuiPipelineGraph;
+import
org.apache.hop.ui.hopgui.file.pipeline.context.HopGuiPipelineTransformContext;
+import org.apache.hop.ui.hopgui.file.shared.HopGuiAbstractGraph;
+import org.apache.hop.ui.hopgui.file.workflow.HopGuiWorkflowGraph;
+import
org.apache.hop.ui.hopgui.file.workflow.context.HopGuiWorkflowActionContext;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.widgets.Shell;
+
+/**
+ * Accepting a lint finding from the canvas, on the transform or action it
points at.
+ *
+ * <p>The decision is written to the project's hop-lint.yml, next to the
exclusions and the rules,
+ * and never into the pipeline or workflow: those are opened by people who do
not have this plugin
+ * installed, and bookkeeping for a plugin they do not run has no business in
their files.
+ *
+ * <p>Two actions rather than one: a transform that is checked offers to stop
checking it, and one
+ * already marked offers to check it again. The state is visible in the menu,
so nobody has to open
+ * a dialog to find out which of the two they are looking at.
+ */
+@GuiPlugin(
+ id = "HopLintSuppressGuiPlugin",
+ description = "Ignore lint findings on a transform or action")
+public class LintSuppressGuiPlugin {
+
+ private static final Class<?> PKG = LintSuppressGuiPlugin.class; // for i18n
purposes
+
+ private static final String ACTION_IGNORE_TRANSFORM =
+ "pipeline-graph-transform-10900-lint-ignore";
+ private static final String ACTION_CHECK_TRANSFORM =
"pipeline-graph-transform-10901-lint-check";
+ private static final String ACTION_IGNORE_ACTION =
"workflow-graph-action-10900-lint-ignore";
+ private static final String ACTION_CHECK_ACTION =
"workflow-graph-action-10901-lint-check";
+
+ // ==================== PIPELINE TRANSFORMS ====================
+
+ @GuiContextAction(
+ id = ACTION_IGNORE_TRANSFORM,
+ parentId = HopGuiPipelineTransformContext.CONTEXT_ID,
+ type = GuiActionType.Modify,
+ name = "i18n::LintSuppressGuiPlugin.IgnoreTransform.Name",
+ tooltip = "i18n::LintSuppressGuiPlugin.IgnoreTransform.Tooltip",
+ image = "lint-check.svg",
+ category = "Lint",
+ categoryOrder = "8")
+ public void ignoreTransform(HopGuiPipelineTransformContext context) {
+ HopGuiPipelineGraph graph = context.getPipelineGraph();
+ ignore(
+ graph.getShell(),
+ context.getTransformMeta().getName(),
+ context.getPipelineMeta().getFilename(),
+ LintSourceRef.Kind.TRANSFORM,
+ graph);
+ }
+
+ @GuiContextAction(
+ id = ACTION_CHECK_TRANSFORM,
+ parentId = HopGuiPipelineTransformContext.CONTEXT_ID,
+ type = GuiActionType.Modify,
+ name = "i18n::LintSuppressGuiPlugin.CheckTransform.Name",
+ tooltip = "i18n::LintSuppressGuiPlugin.CheckTransform.Tooltip",
+ image = "lint-check.svg",
+ category = "Lint",
+ categoryOrder = "8")
+ public void checkTransformAgain(HopGuiPipelineTransformContext context) {
+ HopGuiPipelineGraph graph = context.getPipelineGraph();
+ checkAgain(
+ graph.getShell(),
+ context.getTransformMeta().getName(),
+ context.getPipelineMeta().getFilename(),
+ graph);
+ }
+
+ @GuiContextActionFilter(parentId = HopGuiPipelineTransformContext.CONTEXT_ID)
+ public boolean filterTransformActions(
+ String contextActionId, HopGuiPipelineTransformContext context) {
+ return filter(
+ contextActionId,
+ context.getPipelineMeta().getFilename(),
+ context.getTransformMeta().getName(),
+ LintSourceRef.Kind.TRANSFORM,
+ ACTION_IGNORE_TRANSFORM,
+ ACTION_CHECK_TRANSFORM);
+ }
+
+ // ==================== WORKFLOW ACTIONS ====================
+
+ @GuiContextAction(
+ id = ACTION_IGNORE_ACTION,
+ parentId = HopGuiWorkflowActionContext.CONTEXT_ID,
+ type = GuiActionType.Modify,
+ name = "i18n::LintSuppressGuiPlugin.IgnoreAction.Name",
+ tooltip = "i18n::LintSuppressGuiPlugin.IgnoreAction.Tooltip",
+ image = "lint-check.svg",
+ category = "Lint",
+ categoryOrder = "8")
+ public void ignoreAction(HopGuiWorkflowActionContext context) {
+ HopGuiWorkflowGraph graph = context.getWorkflowGraph();
+ ignore(
+ graph.getShell(),
+ context.getActionMeta().getName(),
+ context.getWorkflowMeta().getFilename(),
+ LintSourceRef.Kind.ACTION,
+ graph);
+ }
+
+ @GuiContextAction(
+ id = ACTION_CHECK_ACTION,
+ parentId = HopGuiWorkflowActionContext.CONTEXT_ID,
+ type = GuiActionType.Modify,
+ name = "i18n::LintSuppressGuiPlugin.CheckAction.Name",
+ tooltip = "i18n::LintSuppressGuiPlugin.CheckAction.Tooltip",
+ image = "lint-check.svg",
+ category = "Lint",
+ categoryOrder = "8")
+ public void checkActionAgain(HopGuiWorkflowActionContext context) {
+ HopGuiWorkflowGraph graph = context.getWorkflowGraph();
+ checkAgain(
+ graph.getShell(),
+ context.getActionMeta().getName(),
+ context.getWorkflowMeta().getFilename(),
+ graph);
+ }
+
+ @GuiContextActionFilter(parentId = HopGuiWorkflowActionContext.CONTEXT_ID)
+ public boolean filterWorkflowActions(
+ String contextActionId, HopGuiWorkflowActionContext context) {
+ return filter(
+ contextActionId,
+ context.getWorkflowMeta().getFilename(),
+ context.getActionMeta().getName(),
+ LintSourceRef.Kind.ACTION,
+ ACTION_IGNORE_ACTION,
+ ACTION_CHECK_ACTION);
+ }
+
+ // ==================== SHARED ====================
+
+ /**
+ * Offer the action that matches the state the element is in: accepting
findings when there are
+ * some to accept, taking that back when there is something to take back.
+ *
+ * <p>The state comes from the project configuration, read on the
right-click. Asking the last
+ * lint run instead made the menu depend on whether this session had
happened to lint the file,
+ * which is how "check this again" could appear once and then never come
back.
+ */
+ private boolean filter(
+ String contextActionId,
+ String fileName,
+ String elementName,
+ LintSourceRef.Kind kind,
+ String ignoreActionId,
+ String checkActionId) {
+ if (!ignoreActionId.equals(contextActionId) &&
!checkActionId.equals(contextActionId)) {
+ return true;
+ }
+
+ String filePath = LintPathUtils.normalizePath(fileName);
+ if (Utils.isEmpty(filePath) || Utils.isEmpty(elementName)) {
+ return false;
+ }
+
+ HopLinter linter = new HopLinter();
+ linter.loadConfigurationForContext(new File(filePath));
+ if (linter.isExcluded(filePath)) {
+ // The whole file is out of linting, so there is nothing to accept or to
take back here.
+ // Putting it back is the Explorer's job, on the file, where it was
excluded.
+ return false;
+ }
+
+ boolean marked = linter.isMarkedElement(filePath, elementName);
+ if (ignoreActionId.equals(contextActionId)) {
+ return !marked && !findingsFor(filePath, kind, elementName).isEmpty();
+ }
+ return marked;
+ }
+
+ private void ignore(
+ Shell shell,
+ String elementName,
+ String fileName,
+ LintSourceRef.Kind kind,
+ HopGuiAbstractGraph graph) {
+ String filePath = LintPathUtils.normalizePath(fileName);
+ ProjectConfig config = projectConfigFor(shell, filePath);
+ if (config == null) {
+ return;
+ }
+
+ LintSuppressDialog.Suppression suppression =
+ new LintSuppressDialog(shell, elementName, findingsFor(filePath, kind,
elementName)).open();
+ if (suppression == null) {
+ return;
+ }
+
+ try {
+ for (String ruleId : suppression.ruleIds()) {
+ LintPolicyYamlWriter.addSuppression(
+ config.yamlFile().toPath(),
+ ruleId,
+ config.relativePath(),
+ elementName,
+ suppression.reason());
+ }
+ } catch (IOException e) {
+ new ErrorDialog(
+ shell,
+ BaseMessages.getString(PKG,
"LintSuppressGuiPlugin.WriteFailed.Title"),
+ BaseMessages.getString(
+ PKG, "LintSuppressGuiPlugin.WriteFailed.Message",
config.yamlFile().getPath()),
+ e);
+ return;
+ }
+ relint(graph, filePath);
+ }
+
+ private void checkAgain(
+ Shell shell, String elementName, String fileName, HopGuiAbstractGraph
graph) {
+ String filePath = LintPathUtils.normalizePath(fileName);
+ ProjectConfig config = projectConfigFor(shell, filePath);
+ if (config == null) {
+ return;
+ }
+ try {
+ int removed =
+ LintPolicyYamlWriter.removeSuppressionsFor(
+ config.yamlFile().toPath(), config.relativePath(), elementName);
+ if (removed == 0) {
+ // Accepted by an entry that names a pattern rather than this file, or
one written by
+ // hand: which entry to take out is a judgement call, so say where to
look.
+ showWarning(
+ shell,
+ BaseMessages.getString(PKG,
"LintSuppressGuiPlugin.NothingRemoved.Title"),
+ BaseMessages.getString(
+ PKG,
+ "LintSuppressGuiPlugin.NothingRemoved.Message",
+ elementName,
+ config.yamlFile().getPath()));
+ return;
+ }
+ } catch (IOException e) {
+ new ErrorDialog(
+ shell,
+ BaseMessages.getString(PKG,
"LintSuppressGuiPlugin.WriteFailed.Title"),
+ BaseMessages.getString(
+ PKG, "LintSuppressGuiPlugin.WriteFailed.Message",
config.yamlFile().getPath()),
+ e);
+ return;
+ }
+ relint(graph, filePath);
+ }
+
+ /** The project configuration to write to, and the path of the file as it
will be written. */
+ private record ProjectConfig(File yamlFile, String relativePath) {}
+
+ private ProjectConfig projectConfigFor(Shell shell, String filePath) {
+ File yamlFile = ExplorerLintGuiPlugin.resolveProjectYaml(filePath);
+ if (yamlFile == null || yamlFile.getParentFile() == null) {
+ showWarning(
+ shell,
+ BaseMessages.getString(PKG, "LintSuppressGuiPlugin.NoProject.Title"),
+ BaseMessages.getString(PKG,
"LintSuppressGuiPlugin.NoProject.Message"));
+ return null;
+ }
+ Path projectRoot = yamlFile.getParentFile().toPath().toAbsolutePath();
+ String relativePath = LintPolicy.relativise(filePath, projectRoot);
+ if (Utils.isEmpty(relativePath) || relativePath.equals(filePath)) {
+ // Outside the project: an absolute path in a portable configuration
would only work here.
+ showWarning(
+ shell,
+ BaseMessages.getString(PKG,
"LintSuppressGuiPlugin.OutsideProject.Title"),
+ BaseMessages.getString(
+ PKG, "LintSuppressGuiPlugin.OutsideProject.Message",
projectRoot.toString()));
+ return null;
+ }
+ return new ProjectConfig(yamlFile, relativePath);
+ }
+
+ /** The configuration changed, so what is on screen is stale until the file
is linted again. */
+ private void relint(HopGuiAbstractGraph graph, String filePath) {
+ BackgroundLintService.getInstance().getTracker().invalidate(filePath);
+ if (graph != null) {
+ BackgroundLintService.getInstance().scheduleGraphLint(graph, true);
+ }
+ LintCanvasOverlayRefresh.redrawOpenGraphs();
+ }
+
+ private void showWarning(Shell shell, String title, String message) {
+ MessageBox box = new MessageBox(shell, SWT.ICON_WARNING | SWT.OK);
+ box.setText(title);
+ box.setMessage(message);
+ box.open();
+ }
+
+ private List<LintResult> findingsFor(
+ String filePath, LintSourceRef.Kind kind, String elementName) {
+ if (Utils.isEmpty(filePath) || Utils.isEmpty(elementName)) {
+ return List.of();
+ }
+ Map<String, List<LintResult>> byName =
+ LintResultsManager.getInstance().getOverlayIndex(filePath, kind);
+ List<LintResult> findings = byName.get(elementName);
+ return findings == null ? List.of() : findings;
+ }
+}
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LinterConfigPlugin.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LinterConfigPlugin.java
index d4250fbbef..dbad946f4a 100644
---
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LinterConfigPlugin.java
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LinterConfigPlugin.java
@@ -66,6 +66,7 @@ public class LinterConfigPlugin implements IConfigOptions,
IGuiPluginCompositeWi
private static final String KEY_PIPELINE_VERIFY =
"LinterIncludeInPipelineVerify";
private static final String KEY_WORKFLOW_VERIFY =
"LinterIncludeInWorkflowVerify";
private static final String KEY_NATIVE_CHECKS = "LinterIncludeNativeChecks";
+ private static final String KEY_SHOW_IGNORED = "LinterShowIgnoredMarkers";
/**
* Read the current settings.
@@ -93,6 +94,7 @@ public class LinterConfigPlugin implements IConfigOptions,
IGuiPluginCompositeWi
includeLintInPipelineVerify =
HopConfig.readOptionBoolean(KEY_PIPELINE_VERIFY, true);
includeLintInWorkflowVerify =
HopConfig.readOptionBoolean(KEY_WORKFLOW_VERIFY, true);
includeNativeChecks = HopConfig.readOptionBoolean(KEY_NATIVE_CHECKS,
true);
+ showIgnoredMarkers = HopConfig.readOptionBoolean(KEY_SHOW_IGNORED, true);
} catch (Exception e) {
// No readable configuration (a fresh install, or a CLI run outside a
Hop home) simply
// means the field defaults stand.
@@ -148,6 +150,8 @@ public class LinterConfigPlugin implements IConfigOptions,
IGuiPluginCompositeWi
includeLintInWorkflowVerify = ((Button) control).getSelection();
case "linter-include-native-checks" ->
includeNativeChecks = ((Button) control).getSelection();
+ case "linter-show-ignored-markers" ->
+ showIgnoredMarkers = ((Button) control).getSelection();
default -> {
// A widget this plugin does not own.
}
@@ -177,6 +181,7 @@ public class LinterConfigPlugin implements IConfigOptions,
IGuiPluginCompositeWi
putIfSet(options, KEY_PIPELINE_VERIFY, includeLintInPipelineVerify);
putIfSet(options, KEY_WORKFLOW_VERIFY, includeLintInWorkflowVerify);
putIfSet(options, KEY_NATIVE_CHECKS, includeNativeChecks);
+ putIfSet(options, KEY_SHOW_IGNORED, showIgnoredMarkers);
if (!options.isEmpty()) {
HopConfig.saveOptions(options);
}
@@ -223,6 +228,17 @@ public class LinterConfigPlugin implements IConfigOptions,
IGuiPluginCompositeWi
description = "Show the lint problems bar (default: true)")
private Boolean showProblemsBarEnabled;
+ @GuiWidgetElement(
+ id = "linter-show-ignored-markers",
+ parentId = ConfigPluginOptionsTab.GUI_WIDGETS_PARENT_ID,
+ type = GuiElementType.CHECKBOX,
+ label = "i18n::LinterConfigPlugin.Option.ShowIgnoredMarkers.Label",
+ toolTip = "i18n::LinterConfigPlugin.Option.ShowIgnoredMarkers.ToolTip")
+ @CommandLine.Option(
+ names = {"--lint-show-ignored-markers"},
+ description = "Mark transforms and actions whose findings are ignored
(default: true)")
+ private Boolean showIgnoredMarkers;
+
@GuiWidgetElement(
id = "linter-config-file",
parentId = ConfigPluginOptionsTab.GUI_WIDGETS_PARENT_ID,
@@ -349,6 +365,21 @@ public class LinterConfigPlugin implements IConfigOptions,
IGuiPluginCompositeWi
this.lintOnEditEnabled = lintOnEditEnabled;
}
+ /**
+ * Whether a transform or action whose findings are ignored is marked as
such on the canvas.
+ *
+ * <p>On by default. A finding that simply vanished is what makes static
validation confusing for
+ * the next person to open the pipeline; a muted marker says the silence was
somebody's decision,
+ * and its tooltip says whose reasoning.
+ */
+ public boolean isShowIgnoredMarkers() {
+ return showIgnoredMarkers == null || showIgnoredMarkers;
+ }
+
+ public void setShowIgnoredMarkers(boolean showIgnoredMarkers) {
+ this.showIgnoredMarkers = showIgnoredMarkers;
+ }
+
public boolean isShowProblemsBarEnabled() {
return showProblemsBarEnabled == null ? true : showProblemsBarEnabled;
}
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/PipelineLintTransformPainterExtension.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/PipelineLintTransformPainterExtension.java
index 4703fc5fc0..ce32ef65cb 100644
---
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/PipelineLintTransformPainterExtension.java
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/PipelineLintTransformPainterExtension.java
@@ -53,6 +53,14 @@ public class PipelineLintTransformPainterExtension
String severity =
LintCanvasOverlayHelper.worstSeverity(byTransform.get(ext.transformMeta.getName()));
if (severity == null) {
+ // Nothing to report. If that is because somebody accepted the findings
here, say so rather
+ // than leaving the next reader to wonder whether this transform was
checked at all.
+ if (LintCanvasOverlayHelper.isShowingIgnoredMarkers()
+ && LintResultsManager.getInstance()
+ .isMarkedElement(filePath, ext.transformMeta.getName())) {
+ LintCanvasOverlayHelper.drawIgnoredOverlay(
+ ext.gc, ext.x1, ext.y1, ext.iconSize,
ext.transformMeta.isSelected());
+ }
return;
}
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/PipelineVerifyLintExtension.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/PipelineVerifyLintExtension.java
index 586d82d8d7..71effe68b1 100644
---
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/PipelineVerifyLintExtension.java
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/PipelineVerifyLintExtension.java
@@ -57,11 +57,21 @@ public class PipelineVerifyLintExtension implements
IExtensionPoint<CheckTransfo
HopLinter linter = new HopLinter();
linter.loadConfigurationForContext(new java.io.File(fileName));
- List<LintResult> policyResults = linter.runPolicyRules(pipelineMeta,
fileName);
+ if (linter.isExcluded(fileName)) {
+ // The project keeps this file out of linting. Hop's own verify output
is left alone —
+ // the user asked for it — but nothing lint-related is added to it or
reported from it.
+ return;
+ }
+
+ List<LintResult> policyResults =
+ linter.applyPolicy(linter.runPolicyRules(pipelineMeta, fileName),
fileName);
extension
.getRemarks()
.addAll(LintCheckResultAdapter.toCheckResults(policyResults,
pipelineMeta));
+ // Hop collected its own remarks before this point, so they have passed
no suppression yet.
+ linter.removeSuppressed(extension.getRemarks(), fileName);
+
List<LintResult> verifyViewResults =
LintResultDeduplicator.deduplicate(
LintCheckResultAdapter.fromCheckResults(extension.getRemarks(),
fileName));
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/WorkflowLintActionPainterExtension.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/WorkflowLintActionPainterExtension.java
index 79e67573c4..91804ba3ad 100644
---
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/WorkflowLintActionPainterExtension.java
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/WorkflowLintActionPainterExtension.java
@@ -52,6 +52,13 @@ public class WorkflowLintActionPainterExtension
String severity =
LintCanvasOverlayHelper.worstSeverity(byAction.get(ext.actionMeta.getName()));
if (severity == null) {
+ // Nothing to report. If that is because somebody accepted the findings
here, say so rather
+ // than leaving the next reader to wonder whether this action was
checked at all.
+ if (LintCanvasOverlayHelper.isShowingIgnoredMarkers()
+ && LintResultsManager.getInstance().isMarkedElement(filePath,
ext.actionMeta.getName())) {
+ LintCanvasOverlayHelper.drawIgnoredOverlay(
+ ext.gc, ext.x1, ext.y1, ext.iconSize, ext.actionMeta.isSelected());
+ }
return;
}
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/RuleRegistry.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/RuleRegistry.java
index 3d6f267348..c838161e95 100644
---
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/RuleRegistry.java
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/RuleRegistry.java
@@ -151,7 +151,7 @@ public class RuleRegistry {
entry.getValue().applyTo(existing);
}
}
- LogChannel.GENERAL.logBasic(
+ LogChannel.GENERAL.logDetailed(
"Applied project lint overlay from: " +
projectYaml.getAbsolutePath());
} catch (Exception e) {
// The user owns this file: report it instead of silently falling back
to defaults.
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/YamlRulePackParser.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/YamlRulePackParser.java
index b4abed0c1e..111ab52f8c 100644
---
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/YamlRulePackParser.java
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/YamlRulePackParser.java
@@ -305,6 +305,18 @@ public final class YamlRulePackParser {
+ " every rule.");
continue;
}
+ String path = stringValue(entry.get("path"), null);
+ String source = stringValue(entry.get("source"), null);
+ if (LintPolicy.ALL_RULES.equals(ruleId) && Utils.isEmpty(path) &&
Utils.isEmpty(source)) {
+ LogChannel.GENERAL.logError(
+ "Ignoring suppress entry "
+ + index
+ + " in "
+ + projectYaml
+ + ": rule \"*\" needs a path or a source to narrow it to. On
its own it would"
+ + " silence every rule everywhere.");
+ continue;
+ }
if (Utils.isEmpty(reason)) {
LogChannel.GENERAL.logError(
"Ignoring suppression of "
@@ -314,12 +326,7 @@ public final class YamlRulePackParser {
+ ": it must give a reason, so the decision can be reviewed
later.");
continue;
}
- suppressions.add(
- new LintPolicy.Suppression(
- ruleId,
- stringValue(entry.get("path"), null),
- stringValue(entry.get("source"), null),
- reason));
+ suppressions.add(new LintPolicy.Suppression(ruleId, path, source,
reason));
}
}
diff --git
a/plugins/misc/lint/src/main/resources/org/apache/hop/lint/messages/messages_en_US.properties
b/plugins/misc/lint/src/main/resources/org/apache/hop/lint/messages/messages_en_US.properties
index b5b5ace59a..4b38845164 100644
---
a/plugins/misc/lint/src/main/resources/org/apache/hop/lint/messages/messages_en_US.properties
+++
b/plugins/misc/lint/src/main/resources/org/apache/hop/lint/messages/messages_en_US.properties
@@ -38,6 +38,8 @@ LinterConfigPlugin.Option.LintOnEdit.Label=Lint Automatically
While Editing
LinterConfigPlugin.Option.LintOnEdit.ToolTip=Re-run lint checks shortly after
you change an open pipeline or workflow. When disabled, files are only linted
on open, save, or on demand.
LinterConfigPlugin.Option.ShowIndicators.Label=Show Lint Indicators on Canvas
LinterConfigPlugin.Option.ShowIndicators.ToolTip=Show the lint totals overlay
(top-left of the canvas) and the severity badges on transforms/actions in open
pipeline and workflow editors
+LinterConfigPlugin.Option.ShowIgnoredMarkers.Label=Mark Ignored Transforms and
Actions
+LinterConfigPlugin.Option.ShowIgnoredMarkers.ToolTip=Draw a muted outline on a
transform or action whose lint findings have been marked as expected, so the
absence of a warning reads as a decision rather than as a check that never ran
LinterConfigPlugin.Option.ConfigFile.Label=Configuration File
LinterConfigPlugin.Option.ConfigFile.ToolTip=Path to hop-lint.yml
configuration file (leave empty for project-specific)
LinterConfigPlugin.Option.PreCommit.Label=Block Git Commits on Lint Failures
@@ -123,3 +125,59 @@ RuleBuilderDialog.Combinator.AnyOf=Any of the clauses below
RuleBuilderDialog.Button.AddClause=Add clause
RuleBuilderDialog.Button.RemoveClause=Remove clause
LintResultsPanel.Shell.TitleForFolder=Hop Lint Results \u2014 folder {0}
+
+# ---------------------------------------------------------------------------
+# Accepting a finding on a transform or action
+# ---------------------------------------------------------------------------
+LintSuppressGuiPlugin.IgnoreTransform.Name=Ignore lint findings...
+LintSuppressGuiPlugin.IgnoreTransform.Tooltip=Mark the findings on this
transform as expected, so they are no longer reported. The decision is recorded
in the project's hop-lint.yml with the reason you give for it.
+LintSuppressGuiPlugin.CheckTransform.Name=Check this transform again
+LintSuppressGuiPlugin.CheckTransform.Tooltip=Stop ignoring lint findings on
this transform and report them again
+LintSuppressGuiPlugin.IgnoreAction.Name=Ignore lint findings...
+LintSuppressGuiPlugin.IgnoreAction.Tooltip=Mark the findings on this action as
expected, so they are no longer reported. The decision is recorded in the
project's hop-lint.yml with the reason you give for it.
+LintSuppressGuiPlugin.CheckAction.Name=Check this action again
+LintSuppressGuiPlugin.CheckAction.Tooltip=Stop ignoring lint findings on this
action and report them again
+
+LintSuppressDialog.Shell.Title=Ignore Lint Findings
+LintSuppressDialog.Label.Element=Findings on ''{0}'' will no longer be
reported. The decision is recorded in the project''s hop-lint.yml.
+LintSuppressDialog.Label.Findings=Currently reported:
+LintSuppressDialog.NoFindings=Nothing is currently reported here. Anything
found later will be ignored as well.
+LintSuppressDialog.Scope.AllRules=Ignore every finding on this element, now
and later
+LintSuppressDialog.Scope.AllRules.ToolTip=Use this for an element that is
filled in at runtime, for example by metadata injection: whatever a check
reports about it at design time is expected.
+LintSuppressDialog.Scope.ListedRules=Ignore only these rules: {0}
+LintSuppressDialog.Scope.ListedRules.ToolTip=Keep checking this element, but
accept the rules reported right now. A new kind of problem here will still be
reported.
+LintSuppressDialog.Label.Reason=Reason (required):
+LintSuppressDialog.ReasonRequired.Title=Reason Required
+LintSuppressDialog.ReasonRequired.Message=Please say why these findings are
expected. It is stored with the pipeline or workflow, so whoever reads it next
knows this was a decision rather than an oversight.
+
+# ---------------------------------------------------------------------------
+# Keeping a file or folder out of linting
+# ---------------------------------------------------------------------------
+ExplorerLintGuiPlugin.Menu.ExcludeFromLinting.Label=Exclude From Linting...
+ExplorerLintGuiPlugin.Exclude.NoSelection.Title=Nothing Selected
+ExplorerLintGuiPlugin.Exclude.NoSelection.Message=Select a file or folder in
the Explorer first.
+ExplorerLintGuiPlugin.Exclude.NoProject.Title=No Project Configuration
+ExplorerLintGuiPlugin.Exclude.NoProject.Message=Exclusions are stored in the
project''s hop-lint.yml, and no project is open.
+ExplorerLintGuiPlugin.Exclude.OutsideProject.Title=Outside The Project
+ExplorerLintGuiPlugin.Exclude.OutsideProject.Message=Only files under the
project folder ({0}) can be excluded, because the patterns are stored relative
to it.
+ExplorerLintGuiPlugin.Exclude.Reason.Title=Exclude From Linting
+ExplorerLintGuiPlugin.Exclude.Reason.Message=Why should ''{0}'' not be linted?
The reason is kept in hop-lint.yml as a comment.
+ExplorerLintGuiPlugin.Exclude.Done.Title=Excluded From Linting
+ExplorerLintGuiPlugin.Exclude.Done.Message=''{0}'' is no longer linted. The
exclusion is recorded in {1} and applies to the command line as well.
+ExplorerLintGuiPlugin.Exclude.Failed.Title=Could Not Exclude
+ExplorerLintGuiPlugin.Exclude.Failed.Message=The exclusion could not be
written to the project configuration.
+LintSuppressDialog.NothingToName.Title=Nothing To Record
+LintSuppressDialog.NothingToName.Message=A suppression has to name a rule, and
nothing is reported here to name. Wait until the finding appears, or exclude
the whole file from linting instead.
+LintSuppressGuiPlugin.NoProject.Title=No Project Configuration
+LintSuppressGuiPlugin.NoProject.Message=Lint decisions are recorded in the
project''s hop-lint.yml, and no project configuration could be found for this
file.
+LintSuppressGuiPlugin.WriteFailed.Title=Could Not Write hop-lint.yml
+LintSuppressGuiPlugin.WriteFailed.Message=The suppression could not be written
to {0}.
+LintSuppressGuiPlugin.OutsideProject.Title=Outside The Project
+LintSuppressGuiPlugin.OutsideProject.Message=Only files under the project
folder ({0}) can be marked, because hop-lint.yml records them relative to it.
+ExplorerLintGuiPlugin.Menu.IncludeInLinting.Label=Include In Linting Again
+ExplorerLintGuiPlugin.Include.Done.Title=Linting Resumed
+ExplorerLintGuiPlugin.Include.Done.Message=''{0}'' is linted again.
+ExplorerLintGuiPlugin.Include.ByPattern.Title=Excluded By A Pattern
+ExplorerLintGuiPlugin.Include.ByPattern.Message=''{0}'' is not excluded by
name but by a pattern that covers it. Edit the exclude section of {1} to decide
what that pattern should still cover.
+LintSuppressGuiPlugin.NothingRemoved.Title=Nothing To Take Back
+LintSuppressGuiPlugin.NothingRemoved.Message=The findings on ''{0}'' are
accepted by an entry that does not name this file and transform on its own, so
it was left alone. Edit the suppress section of {1} to change it.
diff --git
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintPolicyYamlWriterTest.java
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintPolicyYamlWriterTest.java
new file mode 100644
index 0000000000..60e256b6c8
--- /dev/null
+++
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintPolicyYamlWriterTest.java
@@ -0,0 +1,251 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.lint;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import org.apache.hop.lint.registry.YamlRulePackParser;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Writing a decision into the project's lint configuration.
+ *
+ * <p>The file belongs to the user: it is version controlled, reviewed, and
usually commented. These
+ * pin the two things that would make the feature not worth having — losing
what is already in the
+ * file, and writing something that no longer parses.
+ */
+public class LintPolicyYamlWriterTest {
+
+ @TempDir private Path dir;
+
+ private Path yaml() {
+ return dir.resolve("hop-lint.yml");
+ }
+
+ private String read() throws IOException {
+ return Files.readString(yaml(), StandardCharsets.UTF_8);
+ }
+
+ private LintPolicy policy() throws IOException {
+ return YamlRulePackParser.parseProjectYaml(yaml().toFile()).getPolicy();
+ }
+
+ @Test
+ public void createsTheFileWhenThereIsNoneYet() throws Exception {
+ LintPolicyYamlWriter.addExclude(yaml(), "templates/**", "Metadata
injection templates");
+
+ assertEquals(List.of("templates/**"), policy().getExcludes());
+ }
+
+ @Test
+ public void addsToTheBlockThatIsAlreadyThere() throws Exception {
+ Files.writeString(
+ yaml(),
+ """
+ # Our project rules, reviewed 2026-01-14
+ exclude:
+ # generated by the importer
+ - "generated/**"
+
+ rules:
+ TRANS-002:
+ enabled: false
+ """,
+ StandardCharsets.UTF_8);
+
+ LintPolicyYamlWriter.addExclude(yaml(), "templates/**", null);
+
+ assertEquals(List.of("generated/**", "templates/**"),
policy().getExcludes());
+
+ String updated = read();
+ assertTrue(updated.contains("# Our project rules, reviewed 2026-01-14"),
updated);
+ assertTrue(updated.contains("# generated by the importer"), updated);
+ assertTrue(updated.contains("enabled: false"), updated);
+ }
+
+ @Test
+ public void suppressionCarriesRulePathSourceAndReason() throws Exception {
+ LintPolicyYamlWriter.addSuppression(
+ yaml(), "HOP-CHECK", "templates/load.hpl", "Fonte Sql", "Injected at
runtime");
+
+ List<LintPolicy.Suppression> suppressions = policy().getSuppressions();
+ assertEquals(1, suppressions.size());
+ assertEquals("HOP-CHECK", suppressions.get(0).getRuleId());
+ assertEquals("templates/load.hpl", suppressions.get(0).getPath());
+ assertEquals("Fonte Sql", suppressions.get(0).getSource());
+ assertEquals("Injected at runtime", suppressions.get(0).getReason());
+ }
+
+ /** A reason with a quote in it must not end the YAML string early. */
+ @Test
+ public void awkwardTextIsQuoted() throws Exception {
+ LintPolicyYamlWriter.addSuppression(
+ yaml(), "HOP-CHECK", "*.hpl", null, "The \"template\" pipeline:
fields\narrive later");
+
+ LintPolicy.Suppression suppression = policy().getSuppressions().get(0);
+ assertEquals("*.hpl", suppression.getPath());
+ assertEquals("The \"template\" pipeline: fields arrive later",
suppression.getReason());
+ }
+
+ @Test
+ public void bothBlocksCanGrowIndependently() throws Exception {
+ LintPolicyYamlWriter.addExclude(yaml(), "generated/**", null);
+ LintPolicyYamlWriter.addSuppression(yaml(), "DB-001", null, null, "Pinned
until the migration");
+ LintPolicyYamlWriter.addExclude(yaml(), "tests/**", null);
+
+ assertEquals(List.of("generated/**", "tests/**"), policy().getExcludes());
+ assertEquals(1, policy().getSuppressions().size());
+ }
+
+ @Test
+ public void removingASuppressionLeavesTheRestOfTheFileAlone() throws
Exception {
+ Files.writeString(
+ yaml(),
+ """
+ # Reviewed by the data team
+ suppress:
+ - rule: HOP-CHECK
+ path: "templates/load.hpl"
+ source: "Fonte Sql"
+ reason: "Injected at runtime"
+ - rule: DB-001
+ path: "legacy/old.hpl"
+ reason: "Pinned until the migration"
+
+ rules:
+ TRANS-002:
+ enabled: false
+ """,
+ StandardCharsets.UTF_8);
+
+ int removed =
+ LintPolicyYamlWriter.removeSuppressionsFor(yaml(),
"templates/load.hpl", "Fonte Sql");
+
+ assertEquals(1, removed);
+ List<LintPolicy.Suppression> left = policy().getSuppressions();
+ assertEquals(1, left.size());
+ assertEquals("DB-001", left.get(0).getRuleId());
+
+ String updated = read();
+ assertTrue(updated.contains("# Reviewed by the data team"), updated);
+ assertTrue(updated.contains("enabled: false"), updated);
+ }
+
+ /** Everything the dialog wrote for one element comes back out, whatever
rules it named. */
+ @Test
+ public void removingTakesOutEveryRuleRecordedForTheElement() throws
Exception {
+ LintPolicyYamlWriter.addSuppression(yaml(), "HOP-CHECK", "t.hpl", "Fonte
Sql", "why");
+ LintPolicyYamlWriter.addSuppression(yaml(), "TRANS-002", "t.hpl", "Fonte
Sql", "why");
+ LintPolicyYamlWriter.addSuppression(yaml(), "HOP-CHECK", "t.hpl", "Salva
S3", "why");
+
+ assertEquals(2, LintPolicyYamlWriter.removeSuppressionsFor(yaml(),
"t.hpl", "Fonte Sql"));
+
+ List<LintPolicy.Suppression> left = policy().getSuppressions();
+ assertEquals(1, left.size());
+ assertEquals("Salva S3", left.get(0).getSource());
+ }
+
+ /** An empty suppress: key reads as an oversight, so the last entry takes it
with it. */
+ @Test
+ public void removingTheLastEntryDropsTheBlock() throws Exception {
+ LintPolicyYamlWriter.addSuppression(yaml(), "HOP-CHECK", "t.hpl", "Fonte
Sql", "why");
+
+ LintPolicyYamlWriter.removeSuppressionsFor(yaml(), "t.hpl", "Fonte Sql");
+
+ assertEquals(List.of(), policy().getSuppressions());
+ assertTrue(!read().contains("suppress:"), read());
+ }
+
+ @Test
+ public void removingWhatIsNotThereChangesNothing() throws Exception {
+ LintPolicyYamlWriter.addExclude(yaml(), "generated/**", null);
+ String before = read();
+
+ assertEquals(0, LintPolicyYamlWriter.removeSuppressionsFor(yaml(),
"t.hpl", "Fonte Sql"));
+ assertEquals(before, read());
+ }
+
+ /** Excluding is a menu click, so putting the file back has to be one too. */
+ @Test
+ public void anExclusionCanBeTakenBackOut() throws Exception {
+ Files.writeString(
+ yaml(),
+ """
+ # Keep the importer output out of it
+ exclude:
+ - "generated/**"
+ # dynamic from end to end
+ - "templates/load.hpl"
+
+ rules:
+ TRANS-002:
+ enabled: false
+ """,
+ StandardCharsets.UTF_8);
+
+ assertTrue(LintPolicyYamlWriter.removeExclude(yaml(),
"templates/load.hpl"));
+
+ assertEquals(List.of("generated/**"), policy().getExcludes());
+ String updated = read();
+ assertTrue(updated.contains("# Keep the importer output out of it"),
updated);
+ assertTrue(updated.contains("enabled: false"), updated);
+ // The comment written above the entry described it, and goes with it.
+ assertTrue(!updated.contains("dynamic from end to end"), updated);
+ }
+
+ @Test
+ public void removingTheLastExclusionDropsTheBlock() throws Exception {
+ LintPolicyYamlWriter.addExclude(yaml(), "templates/load.hpl", "dynamic");
+
+ assertTrue(LintPolicyYamlWriter.removeExclude(yaml(),
"templates/load.hpl"));
+
+ assertEquals(List.of(), policy().getExcludes());
+ assertTrue(!read().contains("exclude:"), read());
+ }
+
+ /** A file covered by a broader pattern is left to the user: which entry to
change is a choice. */
+ @Test
+ public void anExclusionByPatternIsNotGuessedAt() throws Exception {
+ LintPolicyYamlWriter.addExclude(yaml(), "templates/**", null);
+
+ assertFalse(LintPolicyYamlWriter.removeExclude(yaml(),
"templates/load.hpl"));
+ assertEquals(List.of("templates/**"), policy().getExcludes());
+ }
+
+ /** The linter refuses a suppression without a rule or a reason; so does the
writer. */
+ @Test
+ public void incompleteSuppressionsAreRefused() {
+ assertThrows(
+ IOException.class,
+ () -> LintPolicyYamlWriter.addSuppression(yaml(), null, null, null,
"reason"));
+ assertThrows(
+ IOException.class,
+ () -> LintPolicyYamlWriter.addSuppression(yaml(), "HOP-CHECK", null,
null, " "));
+ assertTrue(!new File(yaml().toString()).exists(), "nothing should have
been written");
+ }
+}
diff --git
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintSuppressionInEditorTest.java
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintSuppressionInEditorTest.java
new file mode 100644
index 0000000000..1dea460569
--- /dev/null
+++
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintSuppressionInEditorTest.java
@@ -0,0 +1,198 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.lint;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * A suppression has to hold where the person who wrote it is looking.
+ *
+ * <p>The editor lints an open pipeline through {@code
lintPipelineLikeVerify}, a different path
+ * from the command line's {@code lintFile}. When only the latter applied the
project's {@code
+ * suppress:} configuration, a team could accept a finding, watch it disappear
from their build, and
+ * still have the red badge sitting on the canvas — which is exactly where
they wanted it gone.
+ */
+public class LintSuppressionInEditorTest {
+
+ private final IVariables variables = Variables.getADefaultVariableSpace();
+
+ @TempDir private Path projectDir;
+
+ /**
+ * Two transforms with no hop between them. Hop's own check reports each as
unused, with no
+ * transform plugin needed, so the finding under test is a native remark
rather than a lint rule.
+ */
+ private PipelineMeta pipelineWithUnusedTransforms(String fileName) {
+ PipelineMeta pipelineMeta = new PipelineMeta();
+ pipelineMeta.setName("template");
+ pipelineMeta.setFilename(fileName);
+
+ TransformMeta source = new TransformMeta();
+ source.setName("Fonte Sql");
+ source.setTransformPluginId("TableInput");
+ pipelineMeta.addTransform(source);
+
+ TransformMeta target = new TransformMeta();
+ target.setName("Salva S3");
+ target.setTransformPluginId("TextFileOutput");
+ pipelineMeta.addTransform(target);
+
+ return pipelineMeta;
+ }
+
+ private List<LintResult> lintAsEditor() throws Exception {
+ String fileName = projectDir.resolve("template.hpl").toString();
+ return new HopLinter()
+ .lintPipelineLikeVerify(pipelineWithUnusedTransforms(fileName),
fileName, null, variables);
+ }
+
+ private void writeProjectConfig(String yaml) throws Exception {
+ Files.writeString(projectDir.resolve("hop-lint.yml"), yaml,
StandardCharsets.UTF_8);
+ }
+
+ @Test
+ public void nativeFindingsReachTheEditorWhenNothingIsSuppressed() throws
Exception {
+ List<LintResult> results = lintAsEditor();
+
+ assertTrue(
+ results.stream().anyMatch(r -> "Fonte Sql".equals(sourceName(r))),
+ "expected a native finding on Fonte Sql, got: " + results);
+ assertTrue(
+ results.stream().anyMatch(r -> "Salva S3".equals(sourceName(r))),
+ "expected a native finding on Salva S3, got: " + results);
+ }
+
+ @Test
+ public void suppressedTransformIsSilentInTheEditor() throws Exception {
+ writeProjectConfig(
+ """
+ suppress:
+ - rule: HOP-CHECK
+ source: "Fonte Sql"
+ reason: "Fields are injected at runtime, nothing to check at
design time"
+ """);
+
+ List<LintResult> results = lintAsEditor();
+
+ assertEquals(
+ 0,
+ results.stream().filter(r -> "Fonte
Sql".equals(sourceName(r))).count(),
+ "the accepted finding should be gone from the editor: " + results);
+ assertTrue(
+ results.stream().anyMatch(r -> "Salva S3".equals(sourceName(r))),
+ "a suppression naming one transform must not silence the other: " +
results);
+ }
+
+ /**
+ * Path patterns are written against the project root, the folder holding
hop-lint.yml, and a
+ * suppression narrowed to one rule leaves the other findings on that file
alone.
+ */
+ @Test
+ public void suppressionPathIsRootedAtTheProjectConfig() throws Exception {
+ List<LintResult> before = lintAsEditor();
+ assertTrue(countOfRule(before, "HOP-CHECK") > 0, "no native findings to
suppress: " + before);
+
+ writeProjectConfig(
+ """
+ suppress:
+ - rule: HOP-CHECK
+ path: "template.hpl"
+ reason: "This whole template is driven by metadata injection"
+ """);
+
+ List<LintResult> after = lintAsEditor();
+
+ assertEquals(0, countOfRule(after, "HOP-CHECK"), "native findings should
be gone: " + after);
+ assertEquals(
+ before.size() - countOfRule(before, "HOP-CHECK"),
+ after.size(),
+ "only the named rule should have been silenced: " + after);
+ }
+
+ private long countOfRule(List<LintResult> results, String ruleId) {
+ return results.stream().filter(r -> ruleId.equals(r.getRuleId())).count();
+ }
+
+ /**
+ * An exclusion has to hold for a file the editor opens, not only for the
project-wide walk that
+ * discovers files. It did not: the file was excluded from the run that
lists what to lint, and
+ * then linted anyway the moment somebody opened it, so the badges came back
on every reopen.
+ */
+ @Test
+ public void excludedFilesAreNotLintedInTheEditor() throws Exception {
+ assertTrue(lintAsEditor().size() > 0, "nothing to exclude");
+
+ writeProjectConfig("""
+ exclude:
+ - "template.hpl"
+ """);
+
+ assertEquals(List.of(), lintAsEditor());
+ }
+
+ /** A rule of "*" accepts whatever is reported on the element, including
rules added later. */
+ @Test
+ public void wildcardSuppressionCoversEveryRuleOnTheElement() throws
Exception {
+ writeProjectConfig(
+ """
+ suppress:
+ - rule: "*"
+ source: "Fonte Sql"
+ reason: "Everything about this transform arrives at runtime"
+ """);
+
+ List<LintResult> results = lintAsEditor();
+
+ assertEquals(
+ 0,
+ results.stream().filter(r -> "Fonte
Sql".equals(sourceName(r))).count(),
+ "every finding on the element should be gone: " + results);
+ assertTrue(
+ results.stream().anyMatch(r -> "Salva S3".equals(sourceName(r))),
+ "and only on that element: " + results);
+ }
+
+ /** Without a path or a source, "*" is the linter switched off under another
name. */
+ @Test
+ public void bareWildcardSuppressionIsRefused() throws Exception {
+ writeProjectConfig(
+ """
+ suppress:
+ - rule: "*"
+ reason: "silence everything"
+ """);
+
+ assertTrue(lintAsEditor().size() > 0, "the bare wildcard should have been
ignored");
+ }
+
+ private String sourceName(LintResult result) {
+ return result.getSource() == null ? null : result.getSource().getName();
+ }
+}