This is an automated email from the ASF dual-hosted git repository.

bamaer 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 68b1e51233 Issue #8521 : Honor a disabled linter and file or script 
action sources (#8635)
68b1e51233 is described below

commit 68b1e51233172cb161f00415513ee8acc3bbdf63
Author: Matt Casters <[email protected]>
AuthorDate: Mon Sep 28 09:26:44 2026 +0200

    Issue #8521 : Honor a disabled linter and file or script action sources 
(#8635)
    
    * Issue #8521 : Honor a disabled linter and file or script action sources
    
    Switching the linter off left Explorer colors in place and still linted
    the open file. SQL, Shell and Set Variables also reported a missing
    script or variable list when the action was using the other source.
    
    * Issue #8521 : Drop lint results that arrive after the linter is switched 
off
    
    updateResultsForFile ignores findings while the linter is disabled, so an
    in-flight pass cannot refill the Problems tab. Turning the linter back on
    attaches the Problems bar to editors that were opened while it was off.
---
 .../actions/setvariables/ActionSetVariables.java   |  37 +++----
 .../messages/messages_en_US.properties             |   1 +
 .../setvariables/ActionSetVariablesCheckTest.java  |  81 ++++++++++++++
 .../hop/workflow/actions/shell/ActionShell.java    |  24 +++--
 .../shell/messages/messages_en_US.properties       |   1 +
 .../actions/shell/ActionShellCheckTest.java        |  86 +++++++++++++++
 .../apache/hop/workflow/actions/sql/ActionSql.java |  28 +++--
 .../actions/sql/messages/messages_en_US.properties |   1 +
 .../workflow/actions/sql/ActionSqlCheckTest.java   |  78 ++++++++++++++
 .../org/apache/hop/lint/BackgroundLintService.java |  12 ++-
 .../org/apache/hop/lint/EditorLintSupport.java     |   9 ++
 .../org/apache/hop/lint/LintResultsManager.java    |   5 +
 .../org/apache/hop/lint/LintStatusFilePainter.java | 107 +++++++++++++++---
 .../org/apache/hop/lint/LinterConfigPlugin.java    |  56 ++++++++++
 .../hop/lint/PipelineAfterOpenLintExtension.java   |   7 +-
 .../hop/lint/WorkflowAfterOpenLintExtension.java   |   7 +-
 .../org/apache/hop/lint/DisabledLinterTest.java    | 119 +++++++++++++++++++++
 17 files changed, 605 insertions(+), 54 deletions(-)

diff --git 
a/plugins/actions/setvariables/src/main/java/org/apache/hop/workflow/actions/setvariables/ActionSetVariables.java
 
b/plugins/actions/setvariables/src/main/java/org/apache/hop/workflow/actions/setvariables/ActionSetVariables.java
index fc12db0242..e1af116474 100644
--- 
a/plugins/actions/setvariables/src/main/java/org/apache/hop/workflow/actions/setvariables/ActionSetVariables.java
+++ 
b/plugins/actions/setvariables/src/main/java/org/apache/hop/workflow/actions/setvariables/ActionSetVariables.java
@@ -24,6 +24,7 @@ import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Properties;
+import org.apache.hop.core.CheckResult;
 import org.apache.hop.core.ICheckResult;
 import org.apache.hop.core.Result;
 import org.apache.hop.core.annotations.Action;
@@ -44,10 +45,6 @@ import org.apache.hop.resource.ResourceReference;
 import org.apache.hop.workflow.WorkflowMeta;
 import org.apache.hop.workflow.action.ActionBase;
 import org.apache.hop.workflow.action.IAction;
-import org.apache.hop.workflow.action.validator.AbstractFileValidator;
-import org.apache.hop.workflow.action.validator.ActionValidatorUtils;
-import org.apache.hop.workflow.action.validator.AndValidator;
-import org.apache.hop.workflow.action.validator.ValidatorContext;
 import org.apache.hop.workflow.engine.IWorkflowEngine;
 
 /** This defines a 'Set variables' action. */
@@ -353,22 +350,26 @@ public class ActionSetVariables extends ActionBase 
implements Cloneable, IAction
       WorkflowMeta workflowMeta,
       IVariables variables,
       IHopMetadataProvider metadataProvider) {
-    boolean res =
-        ActionValidatorUtils.andValidator()
-            .validate(
-                this,
-                "variableName",
-                remarks,
-                
AndValidator.putValidators(ActionValidatorUtils.notNullValidator()));
-
-    if (!res) {
-      return;
+    // Variables come from the list, from a properties file, or from both. 
Either source is enough.
+    if (Utils.isEmpty(filename) && !hasNamedVariable()) {
+      remarks.add(
+          new CheckResult(
+              ICheckResult.TYPE_RESULT_ERROR,
+              BaseMessages.getString(PKG, 
"ActionSetVariables.NoVariablesSpecified"),
+              this));
     }
+  }
 
-    ValidatorContext ctx = new ValidatorContext();
-    AbstractFileValidator.putVariableSpace(ctx, getVariables());
-    AndValidator.putValidators(
-        ctx, ActionValidatorUtils.notNullValidator(), 
ActionValidatorUtils.fileExistsValidator());
+  private boolean hasNamedVariable() {
+    if (variableDefinitions == null) {
+      return false;
+    }
+    for (VariableDefinition definition : variableDefinitions) {
+      if (definition != null && !Utils.isEmpty(definition.getName())) {
+        return true;
+      }
+    }
+    return false;
   }
 
   @Override
diff --git 
a/plugins/actions/setvariables/src/main/resources/org/apache/hop/workflow/actions/setvariables/messages/messages_en_US.properties
 
b/plugins/actions/setvariables/src/main/resources/org/apache/hop/workflow/actions/setvariables/messages/messages_en_US.properties
index 8cc26b1f04..ddb63f081c 100644
--- 
a/plugins/actions/setvariables/src/main/resources/org/apache/hop/workflow/actions/setvariables/messages/messages_en_US.properties
+++ 
b/plugins/actions/setvariables/src/main/resources/org/apache/hop/workflow/actions/setvariables/messages/messages_en_US.properties
@@ -30,6 +30,7 @@ ActionSetVariables.FileVariableType.Label=Variable scope
 ActionSetVariables.keyword=variable,environment,parameter,assign,property
 ActionSetVariables.Log.SetVariableToValue=Set variable {0} to value [{1}]
 ActionSetVariables.Name=Set variables
+ActionSetVariables.NoVariablesSpecified=Specify the variables to set, or a 
properties file that contains them.
 ActionSetVariables.Name.Default=Set variables
 ActionSetVariables.Name.Label=Action name
 ActionSetVariables.Settings.Label=Settings
diff --git 
a/plugins/actions/setvariables/src/test/java/org/apache/hop/workflow/actions/setvariables/ActionSetVariablesCheckTest.java
 
b/plugins/actions/setvariables/src/test/java/org/apache/hop/workflow/actions/setvariables/ActionSetVariablesCheckTest.java
new file mode 100644
index 0000000000..336f8c3acc
--- /dev/null
+++ 
b/plugins/actions/setvariables/src/test/java/org/apache/hop/workflow/actions/setvariables/ActionSetVariablesCheckTest.java
@@ -0,0 +1,81 @@
+/*
+ * 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.workflow.actions.setvariables;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.ICheckResult;
+import 
org.apache.hop.workflow.actions.setvariables.ActionSetVariables.VariableDefinition;
+import 
org.apache.hop.workflow.actions.setvariables.ActionSetVariables.VariableType;
+import org.junit.jupiter.api.Test;
+
+/** Variables are listed on the action or read from a properties file. Either 
source is valid. */
+class ActionSetVariablesCheckTest {
+
+  @Test
+  void aPropertiesFileDoesNotRequireListedVariables() {
+    ActionSetVariables action = new ActionSetVariables();
+    action.setFilename("${PROJECT_HOME}/retail.properties");
+
+    assertTrue(errors(action).isEmpty());
+  }
+
+  @Test
+  void listedVariablesDoNotRequireAPropertiesFile() {
+    ActionSetVariables action = new ActionSetVariables();
+    action
+        .getVariableDefinitions()
+        .add(new VariableDefinition("RETAIL_CSV_WAVE", "1", 
VariableType.CURRENT_WORKFLOW));
+
+    assertTrue(errors(action).isEmpty());
+  }
+
+  @Test
+  void anEmptyVariableRowIsIgnoredWhenAPropertiesFileIsSet() {
+    ActionSetVariables action = new ActionSetVariables();
+    action.setFilename("${PROJECT_HOME}/retail.properties");
+    action.getVariableDefinitions().add(new VariableDefinition("", "", 
VariableType.JVM));
+
+    assertTrue(errors(action).isEmpty());
+  }
+
+  @Test
+  void nothingToSetIsReported() {
+    ActionSetVariables action = new ActionSetVariables();
+
+    List<String> errors = errors(action);
+    assertEquals(1, errors.size());
+    assertTrue(errors.get(0).contains("properties file"));
+    assertTrue(errors.stream().noneMatch(text -> 
text.contains("variableName")));
+  }
+
+  private static List<String> errors(ActionSetVariables action) {
+    List<ICheckResult> remarks = new ArrayList<>();
+    action.check(remarks, null, null, null);
+    List<String> errors = new ArrayList<>();
+    for (ICheckResult remark : remarks) {
+      if (remark.getType() == ICheckResult.TYPE_RESULT_ERROR) {
+        errors.add(remark.getText());
+      }
+    }
+    return errors;
+  }
+}
diff --git 
a/plugins/actions/shell/src/main/java/org/apache/hop/workflow/actions/shell/ActionShell.java
 
b/plugins/actions/shell/src/main/java/org/apache/hop/workflow/actions/shell/ActionShell.java
index 99a82f0aa7..65021c75da 100644
--- 
a/plugins/actions/shell/src/main/java/org/apache/hop/workflow/actions/shell/ActionShell.java
+++ 
b/plugins/actions/shell/src/main/java/org/apache/hop/workflow/actions/shell/ActionShell.java
@@ -30,6 +30,7 @@ import lombok.Getter;
 import lombok.Setter;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.vfs2.FileObject;
+import org.apache.hop.core.CheckResult;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.ICheckResult;
 import org.apache.hop.core.Result;
@@ -665,12 +666,23 @@ public class ActionShell extends ActionBase implements 
ILegacyXml {
         ctx, ActionValidatorUtils.notBlankValidator(), 
ActionValidatorUtils.fileExistsValidator());
 
     ActionValidatorUtils.andValidator().validate(this, "workDirectory", 
remarks, ctx);
-    ActionValidatorUtils.andValidator()
-        .validate(
-            this,
-            "filename",
-            remarks,
-            
AndValidator.putValidators(ActionValidatorUtils.notBlankValidator()));
+    // The script is either typed on the Script tab or read from a file.
+    if (insertScript) {
+      if (StringUtils.isBlank(script)) {
+        remarks.add(
+            new CheckResult(
+                ICheckResult.TYPE_RESULT_ERROR,
+                BaseMessages.getString(PKG, "ActionShell.NoScriptSpecified"),
+                this));
+      }
+    } else {
+      ActionValidatorUtils.andValidator()
+          .validate(
+              this,
+              "filename",
+              remarks,
+              
AndValidator.putValidators(ActionValidatorUtils.notBlankValidator()));
+    }
 
     if (setLogfile) {
       ActionValidatorUtils.andValidator()
diff --git 
a/plugins/actions/shell/src/main/resources/org/apache/hop/workflow/actions/shell/messages/messages_en_US.properties
 
b/plugins/actions/shell/src/main/resources/org/apache/hop/workflow/actions/shell/messages/messages_en_US.properties
index 3f904ff854..8af24d7ff8 100644
--- 
a/plugins/actions/shell/src/main/resources/org/apache/hop/workflow/actions/shell/messages/messages_en_US.properties
+++ 
b/plugins/actions/shell/src/main/resources/org/apache/hop/workflow/actions/shell/messages/messages_en_US.properties
@@ -45,6 +45,7 @@ ActionShell.LogfileExtension.Label=Extension of logfile
 ActionShell.Loglevel.Label=Log level
 ActionShell.LogSettings.Group.Label=Logging settings
 ActionShell.NoScriptFileSpecified=Please specify script filename\!
+ActionShell.NoScriptSpecified=Please specify the script to execute.
 ActionShell.ReferencedObject.Description=Shell script
 ActionShell.Name=Shell
 ActionShell.Name.Label=Action name
diff --git 
a/plugins/actions/shell/src/test/java/org/apache/hop/workflow/actions/shell/ActionShellCheckTest.java
 
b/plugins/actions/shell/src/test/java/org/apache/hop/workflow/actions/shell/ActionShellCheckTest.java
new file mode 100644
index 0000000000..565874780b
--- /dev/null
+++ 
b/plugins/actions/shell/src/test/java/org/apache/hop/workflow/actions/shell/ActionShellCheckTest.java
@@ -0,0 +1,86 @@
+/*
+ * 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.workflow.actions.shell;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.ICheckResult;
+import org.junit.jupiter.api.Test;
+
+/** A shell action runs a script file or the text on the Script tab, not both. 
*/
+class ActionShellCheckTest {
+
+  @Test
+  void aScriptOnTheScriptTabDoesNotRequireAFilename() {
+    ActionShell action = shellWithWorkingDirectory();
+    action.setInsertScript(true);
+    action.setScript("echo hello");
+
+    assertTrue(errors(action).isEmpty());
+  }
+
+  @Test
+  void aScriptFileDoesNotRequireScriptText() {
+    ActionShell action = shellWithWorkingDirectory();
+    action.setFilename("${PROJECT_HOME}/bootstrap-retail-work.py");
+
+    assertTrue(errors(action).isEmpty());
+  }
+
+  @Test
+  void aMissingScriptIsReportedWhenTheScriptTabIsUsed() {
+    ActionShell action = shellWithWorkingDirectory();
+    action.setInsertScript(true);
+
+    List<String> errors = errors(action);
+    assertEquals(1, errors.size());
+    assertTrue(errors.get(0).contains("script to execute"));
+  }
+
+  @Test
+  void aMissingFilenameIsReportedWhenAFileIsUsed() {
+    ActionShell action = shellWithWorkingDirectory();
+
+    List<String> errors = errors(action);
+    assertEquals(1, errors.size());
+    assertTrue(errors.get(0).contains("filename"));
+  }
+
+  private static ActionShell shellWithWorkingDirectory() {
+    ActionShell action = new ActionShell();
+    // The working directory is checked on its own. Give it a value so these 
tests see only the
+    // script-or-file result.
+    action.setWorkDirectory(".");
+    return action;
+  }
+
+  private static List<String> errors(ActionShell action) {
+    List<ICheckResult> remarks = new ArrayList<>();
+    action.check(remarks, null, action, null);
+    List<String> errors = new ArrayList<>();
+    for (ICheckResult remark : remarks) {
+      if (remark.getType() == ICheckResult.TYPE_RESULT_ERROR) {
+        errors.add(remark.getText());
+      }
+    }
+    return errors;
+  }
+}
diff --git 
a/plugins/actions/sql/src/main/java/org/apache/hop/workflow/actions/sql/ActionSql.java
 
b/plugins/actions/sql/src/main/java/org/apache/hop/workflow/actions/sql/ActionSql.java
index dec3fd538e..1e5858d3ef 100644
--- 
a/plugins/actions/sql/src/main/java/org/apache/hop/workflow/actions/sql/ActionSql.java
+++ 
b/plugins/actions/sql/src/main/java/org/apache/hop/workflow/actions/sql/ActionSql.java
@@ -24,7 +24,9 @@ import java.io.InputStreamReader;
 import java.util.List;
 import lombok.Getter;
 import lombok.Setter;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.vfs2.FileObject;
+import org.apache.hop.core.CheckResult;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.ICheckResult;
 import org.apache.hop.core.Result;
@@ -48,8 +50,6 @@ import org.apache.hop.resource.ResourceReference;
 import org.apache.hop.workflow.WorkflowMeta;
 import org.apache.hop.workflow.action.ActionBase;
 import org.apache.hop.workflow.action.IAction;
-import org.apache.hop.workflow.action.validator.ActionValidatorUtils;
-import org.apache.hop.workflow.action.validator.AndValidator;
 
 /** This defines an SQL action. */
 @Action(
@@ -237,12 +237,24 @@ public class ActionSql extends ActionBase implements 
Cloneable, IAction {
       WorkflowMeta workflowMeta,
       IVariables variables,
       IHopMetadataProvider metadataProvider) {
-    ActionValidatorUtils.andValidator()
-        .validate(
-            this,
-            "SQL",
-            remarks,
-            
AndValidator.putValidators(ActionValidatorUtils.notBlankValidator()));
+    // The statement is either typed in or read from a file. Only the source 
in use has to be set.
+    if (sqlFromFile) {
+      if (StringUtils.isBlank(sqlFilename)) {
+        remarks.add(
+            new CheckResult(
+                ICheckResult.TYPE_RESULT_ERROR,
+                BaseMessages.getString(PKG, "ActionSQL.NoSQLFileSpecified"),
+                this));
+      }
+      return;
+    }
+    if (StringUtils.isBlank(sql)) {
+      remarks.add(
+          new CheckResult(
+              ICheckResult.TYPE_RESULT_ERROR,
+              BaseMessages.getString(PKG, "ActionSQL.NoSQLSpecified"),
+              this));
+    }
   }
 
   @Override
diff --git 
a/plugins/actions/sql/src/main/resources/org/apache/hop/workflow/actions/sql/messages/messages_en_US.properties
 
b/plugins/actions/sql/src/main/resources/org/apache/hop/workflow/actions/sql/messages/messages_en_US.properties
index c912da207b..4dadbe05b4 100644
--- 
a/plugins/actions/sql/src/main/resources/org/apache/hop/workflow/actions/sql/messages/messages_en_US.properties
+++ 
b/plugins/actions/sql/src/main/resources/org/apache/hop/workflow/actions/sql/messages/messages_en_US.properties
@@ -35,6 +35,7 @@ ActionSQL.Name.Default=SQL
 ActionSQL.Name.Label=Action name
 ActionSQL.NoDatabaseConnection=No database connection is defined.
 ActionSQL.NoSQLFileSpecified=Please specify SQL filename\!
+ActionSQL.NoSQLSpecified=Please specify the SQL to execute.
 ActionSQL.ReferencedObject.Description=SQL file
 ActionSQL.Position.Label=Line {0} Column {1}
 ActionSQL.Script.Label=SQL Script\: 
diff --git 
a/plugins/actions/sql/src/test/java/org/apache/hop/workflow/actions/sql/ActionSqlCheckTest.java
 
b/plugins/actions/sql/src/test/java/org/apache/hop/workflow/actions/sql/ActionSqlCheckTest.java
new file mode 100644
index 0000000000..d9e0c3a4ce
--- /dev/null
+++ 
b/plugins/actions/sql/src/test/java/org/apache/hop/workflow/actions/sql/ActionSqlCheckTest.java
@@ -0,0 +1,78 @@
+/*
+ * 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.workflow.actions.sql;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.ICheckResult;
+import org.junit.jupiter.api.Test;
+
+/** SQL comes from the script field or from a file. Checking the unused one is 
a false positive. */
+class ActionSqlCheckTest {
+
+  @Test
+  void sqlFromAFileDoesNotRequireTheScript() {
+    ActionSql action = new ActionSql();
+    action.setSqlFromFile(true);
+    action.setSqlFilename("${PROJECT_HOME}/create-staging-views.sql");
+
+    assertTrue(errors(action).isEmpty());
+  }
+
+  @Test
+  void aTypedScriptDoesNotRequireAFile() {
+    ActionSql action = new ActionSql();
+    action.setSql("drop table staging_customer");
+
+    assertTrue(errors(action).isEmpty());
+  }
+
+  @Test
+  void aMissingFileIsReportedWhenSqlComesFromAFile() {
+    ActionSql action = new ActionSql();
+    action.setSqlFromFile(true);
+
+    List<String> errors = errors(action);
+    assertEquals(1, errors.size());
+    assertTrue(errors.get(0).contains("SQL filename"));
+  }
+
+  @Test
+  void aMissingScriptIsReportedWhenSqlIsTyped() {
+    ActionSql action = new ActionSql();
+
+    List<String> errors = errors(action);
+    assertEquals(1, errors.size());
+    assertTrue(errors.get(0).contains("SQL to execute"));
+  }
+
+  private static List<String> errors(ActionSql action) {
+    List<ICheckResult> remarks = new ArrayList<>();
+    action.check(remarks, null, null, null);
+    List<String> errors = new ArrayList<>();
+    for (ICheckResult remark : remarks) {
+      if (remark.getType() == ICheckResult.TYPE_RESULT_ERROR) {
+        errors.add(remark.getText());
+      }
+    }
+    return errors;
+  }
+}
diff --git 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/BackgroundLintService.java
 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/BackgroundLintService.java
index 63753adb0c..9b101a71a9 100644
--- 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/BackgroundLintService.java
+++ 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/BackgroundLintService.java
@@ -118,7 +118,9 @@ public class BackgroundLintService {
   }
 
   public void scheduleGraphLint(HopGuiAbstractGraph graph, boolean force) {
-    if (graph == null) {
+    // Opening or editing a file schedules this even when the linter is 
switched off. Without the
+    // check, that one open file is still linted and stays marked in the 
Explorer.
+    if (!isEnabled() || graph == null) {
       return;
     }
     String graphId = graph.getId();
@@ -192,6 +194,10 @@ public class BackgroundLintService {
    */
   private void lintGraphInternal(HopGuiAbstractGraph graph, boolean force) {
     String graphId = graph.getId();
+    if (!isEnabled()) {
+      deferredGenerations.remove(graphId);
+      return;
+    }
     String filename = LintEditorGraphHelper.getFilename(graph);
     if (!LintEditorGraphHelper.isLintableFilename(filename)) {
       deferredGenerations.remove(graphId);
@@ -210,6 +216,10 @@ public class BackgroundLintService {
     submit(
         () -> {
           try {
+            // Switched off while this pass was waiting or running: do not 
publish what it found.
+            if (!isEnabled()) {
+              return;
+            }
             HopGui hopGui = HopGui.peekInstance();
             IHopMetadataProvider metadataProvider =
                 hopGui != null ? hopGui.getMetadataProvider() : null;
diff --git 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/EditorLintSupport.java 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/EditorLintSupport.java
index 2a7527adea..6bb3888da7 100644
--- a/plugins/misc/lint/src/main/java/org/apache/hop/lint/EditorLintSupport.java
+++ b/plugins/misc/lint/src/main/java/org/apache/hop/lint/EditorLintSupport.java
@@ -27,6 +27,9 @@ final class EditorLintSupport {
   private EditorLintSupport() {}
 
   static void onNewGraph(HopGuiAbstractGraph graph) {
+    if (!LinterConfigPlugin.getInstance().isLinterEnabled()) {
+      return;
+    }
     if (graph == null || graph.isDisposed()) {
       return;
     }
@@ -35,6 +38,9 @@ final class EditorLintSupport {
   }
 
   static void onGraphUpdate(HopGuiAbstractGraph graph) {
+    if (!LinterConfigPlugin.getInstance().isLinterEnabled()) {
+      return;
+    }
     if (graph == null || graph.isDisposed()) {
       return;
     }
@@ -53,6 +59,9 @@ final class EditorLintSupport {
   }
 
   static void onFileSaved(String filename) {
+    if (!LinterConfigPlugin.getInstance().isLinterEnabled()) {
+      return;
+    }
     if (!LintEditorGraphHelper.isLintableFilename(filename)) {
       return;
     }
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 619dce6c01..cbf89c2792 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
@@ -143,6 +143,11 @@ public class LintResultsManager {
 
   /** Update results for a single file without clearing other file results. */
   public synchronized void updateResultsForFile(String filePath, 
List<LintResult> results) {
+    // One choke point for every producer. An in-flight pass must not publish 
after the linter was
+    // switched off and clearResults() has already run.
+    if (!LinterConfigPlugin.getInstance().isLinterEnabled()) {
+      return;
+    }
     String normalizedPath = LintPathUtils.normalizePath(filePath);
 
     // Capture the previous results for this file so we only notify (and 
trigger canvas/Explorer
diff --git 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintStatusFilePainter.java
 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintStatusFilePainter.java
index ec02dde8e9..c45ededfe5 100644
--- 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintStatusFilePainter.java
+++ 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintStatusFilePainter.java
@@ -54,6 +54,15 @@ public class LintStatusFilePainter implements 
IExplorerFilePaintListener {
   private static final String BASE_ICON_KEY = "lintBaseIcon";
   private static final String APPLIED_STATUS_KEY = "lintAppliedStatus";
 
+  /**
+   * The file this item was painted for.
+   *
+   * <p>The Explorer calls this painter when it builds a tree item, not on 
every redraw. Remembering
+   * the path is what lets a later result change, or switching the linter off, 
update the item that
+   * is already on screen.
+   */
+  private static final String LINT_PATH_KEY = "lintPath";
+
   /** Size of the status badge, matching the small icons the Explorer tree 
draws. */
   private static final int BADGE_SIZE = 12;
 
@@ -130,19 +139,60 @@ public class LintStatusFilePainter implements 
IExplorerFilePaintListener {
   }
 
   /**
-   * Repaint lint icons in the Explorer. Prefers a lightweight tree redraw 
(which re-runs this
-   * painter while preserving selection and expansion) and only falls back to 
a full perspective
-   * refresh when we have not painted a tree yet.
+   * Whether Explorer files should carry a lint mark.
+   *
+   * <p>The canvas overlays already follow this switch. The Explorer used to 
keep painting from
+   * whatever the last run had stored, so a file stayed red after the linter 
was switched off.
+   */
+  static boolean decoratesExplorerFiles() {
+    try {
+      return LinterConfigPlugin.getInstance().isLinterEnabled();
+    } catch (Exception e) {
+      return true;
+    }
+  }
+
+  /**
+   * Re-apply lint marks on the items already in the Explorer.
+   *
+   * <p>A redraw of the tree does not ask this painter again: the color and 
icon were set when the
+   * item was created. Walking those items keeps the selection and the 
expanded folders, and is what
+   * clears a mark when the linter is switched off or a file no longer has 
findings.
    */
   public void repaintExplorerIcons() {
-    Tree tree = lastPaintedTree;
-    if (tree != null && !tree.isDisposed()) {
-      tree.redraw();
+    // Callers hop to the UI thread first. Doing it again here loops when this 
thread has no
+    // display to report, which is how a dead Hop Web session answers.
+    try {
+      Tree tree = lastPaintedTree;
+      if (tree != null && !tree.isDisposed()) {
+        reapply(tree);
+        return;
+      }
+      ExplorerPerspective perspective = HopGui.getExplorerPerspective();
+      if (perspective != null) {
+        perspective.refresh();
+      }
+    } catch (Exception e) {
+      log.logError("Error repainting explorer lint icons: " + e.getMessage(), 
e);
+    }
+  }
+
+  private void reapply(Tree tree) {
+    for (TreeItem item : tree.getItems()) {
+      reapply(tree, item);
+    }
+  }
+
+  private void reapply(Tree tree, TreeItem item) {
+    if (item == null || item.isDisposed()) {
       return;
     }
-    ExplorerPerspective perspective = HopGui.getExplorerPerspective();
-    if (perspective != null) {
-      perspective.refresh();
+    String path = (String) item.getData(LINT_PATH_KEY);
+    if (path != null) {
+      applyResolvedStatus(tree, item, item.getText(), path);
+    }
+    for (TreeItem child : item.getItems()) {
+      reapply(tree, child);
     }
   }
 
@@ -156,18 +206,45 @@ public class LintStatusFilePainter implements 
IExplorerFilePaintListener {
         return;
       }
 
-      LintStatus status = resolveStatus(absolutePath);
-      if (status == LintStatus.UNKNOWN) {
-        return;
-      }
-
-      applyStatusStyle(tree, treeItem, name, absolutePath, status);
+      treeItem.setData(LINT_PATH_KEY, absolutePath);
+      applyResolvedStatus(tree, treeItem, name, absolutePath);
     } catch (Exception e) {
       log.logError(
           "Error painting lint status for file " + path + "/" + name + ": " + 
e.getMessage(), e);
     }
   }
 
+  private void applyResolvedStatus(Tree tree, TreeItem treeItem, String name, 
String absolutePath) {
+    if (!decoratesExplorerFiles()) {
+      clearStatusStyle(tree, treeItem);
+      return;
+    }
+    LintStatus status = resolveStatus(absolutePath);
+    if (status == LintStatus.UNKNOWN) {
+      clearStatusStyle(tree, treeItem);
+      return;
+    }
+    applyStatusStyle(tree, treeItem, name, absolutePath, status);
+  }
+
+  /** Put the item back the way the Explorer built it, when a lint mark is no 
longer called for. */
+  private void clearStatusStyle(Tree tree, TreeItem treeItem) {
+    if (treeItem.getData(APPLIED_STATUS_KEY) == null) {
+      return;
+    }
+    org.eclipse.swt.graphics.Color darkGray = 
tree.getDisplay().getSystemColor(SWT.COLOR_DARK_GRAY);
+    org.eclipse.swt.graphics.Color current = treeItem.getForeground();
+    if (current == null || !current.equals(darkGray)) {
+      treeItem.setForeground(null);
+    }
+    Image base = (Image) treeItem.getData(BASE_ICON_KEY);
+    if (base != null && !base.isDisposed()) {
+      treeItem.setImage(base);
+    }
+    treeItem.setData(APPLIED_STATUS_KEY, null);
+    treeItem.setData("lintTooltip", null);
+  }
+
   private void applyStatusStyle(
       Tree tree, TreeItem treeItem, String name, String absolutePath, 
LintStatus status) {
     org.eclipse.swt.graphics.Color systemDarkGray =
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 dbad946f4a..9f5585efde 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
@@ -39,7 +39,10 @@ import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
 import org.apache.hop.ui.core.gui.IGuiPluginCompositeWidgetsListener;
 import org.apache.hop.ui.core.widget.TextVar;
 import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.file.shared.HopGuiAbstractGraph;
+import org.apache.hop.ui.hopgui.perspective.TabItemHandler;
 import 
org.apache.hop.ui.hopgui.perspective.configuration.tabs.ConfigPluginOptionsTab;
+import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
 import org.eclipse.swt.widgets.Button;
 import org.eclipse.swt.widgets.Control;
 import picocli.CommandLine;
@@ -130,6 +133,7 @@ public class LinterConfigPlugin implements IConfigOptions, 
IGuiPluginCompositeWi
    */
   @Override
   public void persistContents(GuiCompositeWidgets compositeWidgets) {
+    boolean enabledBefore = isLinterEnabled();
     for (String widgetId : compositeWidgets.getWidgetsMap().keySet()) {
       Control control = compositeWidgets.getWidgetsMap().get(widgetId);
       switch (widgetId) {
@@ -158,6 +162,9 @@ public class LinterConfigPlugin implements IConfigOptions, 
IGuiPluginCompositeWi
       }
     }
     saveToHopConfig();
+    if (enabledBefore != isLinterEnabled()) {
+      applyEnabledState();
+    }
   }
 
   /**
@@ -188,6 +195,55 @@ public class LinterConfigPlugin implements IConfigOptions, 
IGuiPluginCompositeWi
     return options;
   }
 
+  /**
+   * Drop marks the Explorer and open editors are still showing, or lint the 
project again.
+   *
+   * <p>Called after the new value has been saved. Reading the option back has 
to see it: the
+   * Explorer painter and the background service load a fresh instance rather 
than this one.
+   */
+  void applyEnabledState() {
+    if (!isLinterEnabled()) {
+      LintResultsManager.getInstance().clearResults();
+      try {
+        LintProblemsBarManager.getInstance().refreshAllOpenEditors();
+      } catch (Exception | LinkageError e) {
+        log.logDetailed("No open editor to clear lint marks from: " + 
e.getMessage());
+      }
+      return;
+    }
+    try {
+      HopGui hopGui = HopGui.peekInstance();
+      if (hopGui == null) {
+        return;
+      }
+      attachOpenEditors();
+      BackgroundLintService.getInstance()
+          .lintProjectAsync(getProjectPath(), hopGui.getMetadataProvider(), 
hopGui.getVariables());
+    } catch (Exception | LinkageError e) {
+      log.logDetailed("Could not lint the project after enabling the linter: " 
+ e.getMessage());
+    }
+  }
+
+  /**
+   * Editors opened while the linter was off never registered a Problems bar. 
Re-enabling lints the
+   * project, but the bar only comes back once those graphs are attached.
+   */
+  private static void attachOpenEditors() {
+    try {
+      ExplorerPerspective perspective = HopGui.getExplorerPerspective();
+      if (perspective == null) {
+        return;
+      }
+      for (TabItemHandler item : perspective.getItems()) {
+        if (item.getTypeHandler() instanceof HopGuiAbstractGraph graph && 
!graph.isDisposed()) {
+          EditorLintSupport.onNewGraph(graph);
+        }
+      }
+    } catch (Exception | LinkageError e) {
+      log.logDetailed("Could not attach the problems bar to open editors: " + 
e.getMessage());
+    }
+  }
+
   private static void putIfSet(Map<String, Object> options, String key, Object 
value) {
     if (value != null) {
       options.put(key, value);
diff --git 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/PipelineAfterOpenLintExtension.java
 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/PipelineAfterOpenLintExtension.java
index 434149c519..e5b5db9959 100644
--- 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/PipelineAfterOpenLintExtension.java
+++ 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/PipelineAfterOpenLintExtension.java
@@ -34,9 +34,10 @@ public class PipelineAfterOpenLintExtension implements 
IExtensionPoint<PipelineM
       org.apache.hop.core.variables.IVariables variables,
       PipelineMeta pipelineMeta)
       throws HopException {
-    if (pipelineMeta != null) {
-      
LintEditorGraphHelper.scheduleAttachForFilename(pipelineMeta.getFilename());
-      
BackgroundLintService.getInstance().scheduleFileLint(pipelineMeta.getFilename(),
 true);
+    if (pipelineMeta == null || 
!LinterConfigPlugin.getInstance().isLinterEnabled()) {
+      return;
     }
+    
LintEditorGraphHelper.scheduleAttachForFilename(pipelineMeta.getFilename());
+    
BackgroundLintService.getInstance().scheduleFileLint(pipelineMeta.getFilename(),
 true);
   }
 }
diff --git 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/WorkflowAfterOpenLintExtension.java
 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/WorkflowAfterOpenLintExtension.java
index 5368127f9f..1b267e7329 100644
--- 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/WorkflowAfterOpenLintExtension.java
+++ 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/WorkflowAfterOpenLintExtension.java
@@ -34,9 +34,10 @@ public class WorkflowAfterOpenLintExtension implements 
IExtensionPoint<WorkflowM
       org.apache.hop.core.variables.IVariables variables,
       WorkflowMeta workflowMeta)
       throws HopException {
-    if (workflowMeta != null) {
-      
LintEditorGraphHelper.scheduleAttachForFilename(workflowMeta.getFilename());
-      
BackgroundLintService.getInstance().scheduleFileLint(workflowMeta.getFilename(),
 true);
+    if (workflowMeta == null || 
!LinterConfigPlugin.getInstance().isLinterEnabled()) {
+      return;
     }
+    
LintEditorGraphHelper.scheduleAttachForFilename(workflowMeta.getFilename());
+    
BackgroundLintService.getInstance().scheduleFileLint(workflowMeta.getFilename(),
 true);
   }
 }
diff --git 
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/DisabledLinterTest.java 
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/DisabledLinterTest.java
new file mode 100644
index 0000000000..16d5acaf59
--- /dev/null
+++ 
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/DisabledLinterTest.java
@@ -0,0 +1,119 @@
+/*
+ * 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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+import java.util.List;
+import org.apache.hop.ui.hopgui.file.shared.HopGuiAbstractGraph;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Switching the linter off has to stop marking files.
+ *
+ * <p>The canvas overlays already followed the switch. The Explorer and an 
open editor did not: the
+ * file that was open kept being linted, and a file that had already been 
marked stayed red.
+ */
+class DisabledLinterTest {
+
+  @AfterEach
+  void restore() {
+    LinterConfigPlugin config = LinterConfigPlugin.getInstance();
+    config.setLinterEnabled(true);
+    config.saveToHopConfig();
+    LintResultsManager.getInstance().clearResults();
+  }
+
+  @Test
+  void switchingTheLinterOffClearsStoredFindings() {
+    LintResultsManager manager = LintResultsManager.getInstance();
+    manager.updateResultsForFile(
+        "/tmp/example.hwf",
+        List.of(
+            new LintResult(
+                "HOP-CHECK", "SQL", "ERROR", "SQL cannot be null or blank.", 
"/tmp/example.hwf")));
+    assertFalse(manager.getAllResults().isEmpty());
+
+    LinterConfigPlugin config = LinterConfigPlugin.getInstance();
+    config.setLinterEnabled(false);
+    config.applyEnabledState();
+
+    assertTrue(manager.getAllResults().isEmpty(), "a disabled linter must not 
leave a file marked");
+  }
+
+  @Test
+  void anInFlightUpdateDoesNotPublishAfterTheLinterIsOff() {
+    LinterConfigPlugin config = LinterConfigPlugin.getInstance();
+    config.setLinterEnabled(false);
+    config.saveToHopConfig();
+
+    LintResultsManager manager = LintResultsManager.getInstance();
+    manager.updateResultsForFile(
+        "/tmp/example.hwf",
+        List.of(new LintResult("HOP-CHECK", "SQL", "ERROR", "too late", 
"/tmp/example.hwf")));
+
+    assertTrue(
+        manager.getAllResults().isEmpty(),
+        "a pass that finishes after the switch must not put findings back");
+  }
+
+  @Test
+  void leavingTheLinterOnKeepsStoredFindings() {
+    LintResultsManager manager = LintResultsManager.getInstance();
+    manager.updateResultsForFile(
+        "/tmp/example.hwf",
+        List.of(new LintResult("HOP-CHECK", "SQL", "ERROR", "still there", 
"/tmp/example.hwf")));
+
+    LinterConfigPlugin config = LinterConfigPlugin.getInstance();
+    config.setLinterEnabled(true);
+    config.applyEnabledState();
+
+    assertFalse(manager.getAllResults().isEmpty());
+  }
+
+  @Test
+  void theExplorerStopsDecoratingWhenTheLinterIsOff() {
+    LinterConfigPlugin config = LinterConfigPlugin.getInstance();
+    config.setLinterEnabled(false);
+    config.saveToHopConfig();
+    assertFalse(LintStatusFilePainter.decoratesExplorerFiles());
+
+    config = LinterConfigPlugin.getInstance();
+    config.setLinterEnabled(true);
+    config.saveToHopConfig();
+    assertTrue(LintStatusFilePainter.decoratesExplorerFiles());
+  }
+
+  @Test
+  void anOpenEditorIsNotLintedWhenTheLinterIsOff() {
+    LinterConfigPlugin config = LinterConfigPlugin.getInstance();
+    config.setLinterEnabled(false);
+    config.saveToHopConfig();
+
+    HopGuiAbstractGraph graph = mock(HopGuiAbstractGraph.class);
+    EditorLintSupport.onGraphUpdate(graph);
+    EditorLintSupport.onNewGraph(graph);
+    BackgroundLintService.getInstance().scheduleGraphLint(graph, true);
+
+    verifyNoInteractions(graph);
+  }
+}

Reply via email to