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();
+  }
+}

Reply via email to