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 85a6b1328a Fixes #8871 : Lint rule editor and Rules Manager: clause 
validation, … (#8877)
85a6b1328a is described below

commit 85a6b1328aab0a0639ab2a7caa94be749fbd10b5
Author: Bart Maertens <[email protected]>
AuthorDate: Tue Oct 13 13:29:01 2026 +0200

    Fixes #8871 : Lint rule editor and Rules Manager: clause validation, … 
(#8877)
    
    * Fixes #8871 : Lint rule editor and Rules Manager: clause validation, 
saved values, Rule ID, pack rule overrides, sorting
    
    * Issue #8871 : Address review comments: later clause values, edits kept on 
a target round trip, validation tests, comments
---
 .../ROOT/pages/linting/lint-rule-packs.adoc        |   8 +-
 .../modules/ROOT/pages/linting/lint-rules.adoc     |  11 +
 .../org/apache/hop/lint/LintPolicyYamlWriter.java  |  19 +-
 .../org/apache/hop/lint/LinterConfigPlugin.java    |   5 +-
 .../org/apache/hop/lint/RuleBuilderDialog.java     | 268 ++++++++++++++---
 .../org/apache/hop/lint/RuleManagerDialog.java     | 272 ++++++++++++++----
 .../hop/lint/registry/ProjectLintYamlExporter.java |  78 ++++-
 .../hop/lint/registry/ProjectYamlOverlay.java      |  26 +-
 .../hop/lint/registry/YamlRulePackParser.java      |  10 +-
 .../hop/lint/messages/messages_en_US.properties    |  18 ++
 .../apache/hop/lint/LintRuleYamlWriterTest.java    |  36 +++
 .../hop/lint/RuleBuilderDialogClauseTest.java      | 316 +++++++++++++++++++++
 .../apache/hop/lint/RuleBuilderValidationTest.java |  60 ++++
 .../apache/hop/lint/registry/RuleRegistryTest.java | 124 ++++++++
 14 files changed, 1146 insertions(+), 105 deletions(-)

diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/linting/lint-rule-packs.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/linting/lint-rule-packs.adoc
index 7096b430eb..cd339cd420 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/linting/lint-rule-packs.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/linting/lint-rule-packs.adoc
@@ -121,5 +121,11 @@ Rule ids are matched ignoring case.
 An id that no installed pack defines, such as a typo, is reported as a warning 
in the Hop log and by `hop lint`, with the closest id as a suggestion, and the 
override is ignored.
 
 The rule manager in Hop Gui writes these overrides for you.
-Editing a pack rule there never changes the pack: a change to its severity, 
threshold, enabled state, tags or help URL is written to `hop-lint.yml` as an 
override, and a change to what the rule actually looks at is written there as a 
complete rule which replaces the pack rule of that id.
+Editing a pack rule there never changes the pack: a change to its name, 
description, severity, threshold, enabled state, tags or help URL is written to 
`hop-lint.yml` as an override, and the rule goes on following the pack.
+
+A change to what the rule actually looks at, its target, a field or a 
condition, is written to `hop-lint.yml` as a complete rule which replaces the 
pack rule of that id.
+So is a changed value in any clause of a composed rule but the first: an 
override carries one `conditionValue`, the first clause's.
+From then on, changes to that rule in a newer version of the pack no longer 
apply, so the rule manager asks before it saves such a change.
+The rule is then listed as a project rule, and *Restore Pack Rule* removes it 
from `hop-lint.yml` and brings the pack's rule back.
+
 Pack rules cannot be deleted, only switched off.
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 0b3eb71a04..ea016d7c01 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
@@ -497,6 +497,11 @@ rules:
     enabled: true
     conditionValue: "30"
 
+  # Use your own wording for a pack rule
+  DOC-002:
+    name: "Workflows need a description"
+    description: "Say what the workflow does and who to ask about it"
+
   # A rule that exists only in this project
   LOCAL-001:
     type: custom
@@ -511,6 +516,12 @@ rules:
 *Tools -> Lint -> Manage Custom Rules* writes this file for you.
 It changes only the entry of the rule you added, edited, switched or deleted: 
the other rules, the `exclude` and `suppress` sections and your comments stay 
as you wrote them.
 
+A rule you add there gets its id in the *Rule ID* field, suggested as 
`PROJECT-001`, `PROJECT-002` and so on.
+Findings, SARIF reports, `hop lint --list-rules` and `hop-lint.yml` all name 
the rule by this id, so pick one that says what the rule is about.
+An id holds letters, digits, hyphens, underscores and dots, and no two rules 
can share one, whatever the case.
+A project rule's id can be changed later; a `suppress` entry naming the old id 
does not follow it.
+A pack rule keeps the pack's id.
+
 A parse error in `hop-lint.yml` is reported rather than ignored, so that a 
typo cannot leave you linting with the defaults while believing otherwise.
 
 == Excluding files, and accepting findings
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
index d5bbd45925..c961e80591 100644
--- 
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
@@ -233,12 +233,29 @@ public final class LintPolicyYamlWriter {
    */
   public static void putRule(Path yamlFile, String ruleId, Map<String, Object> 
entry)
       throws IOException {
+    putRule(yamlFile, ruleId, entry, null);
+  }
+
+  /**
+   * Write a rule under a new id and remove the entry under its old one, in a 
single write: a
+   * refused write leaves the file as it was.
+   *
+   * @param previousId the id the rule was saved under before; null or the 
same id changes nothing
+   *     else
+   */
+  public static void putRule(
+      Path yamlFile, String ruleId, Map<String, Object> entry, String 
previousId)
+      throws IOException {
     if (isBlank(ruleId)) {
       throw new IOException("A rule needs an id");
     }
     String original =
         Files.exists(yamlFile) ? Files.readString(yamlFile, 
StandardCharsets.UTF_8) : "";
-    String updated = replaceRule(original, ruleId, entry);
+    String updated = original;
+    if (!isBlank(previousId) && !previousId.equalsIgnoreCase(ruleId)) {
+      updated = replaceRule(updated, previousId, null);
+    }
+    updated = replaceRule(updated, ruleId, entry);
     if (updated.equals(original)) {
       return;
     }
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 b72ac48c6c..71cc6aca36 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
@@ -604,10 +604,7 @@ public class LinterConfigPlugin implements IConfigOptions, 
IGuiPluginCompositeWi
   public void saveProjectRule(CustomLintRule rule, String previousId) throws 
IOException {
     Path path = Paths.get(resolveProjectConfigPath());
     String ruleId = rule.generateRuleId();
-    if (!Utils.isEmpty(previousId) && !previousId.equalsIgnoreCase(ruleId)) {
-      LintPolicyYamlWriter.removeRule(path, previousId);
-    }
-    LintPolicyYamlWriter.putRule(path, ruleId, 
ProjectLintYamlExporter.entryFor(rule));
+    LintPolicyYamlWriter.putRule(path, ruleId, 
ProjectLintYamlExporter.entryFor(rule), previousId);
     log.logBasic("Saved lint rule " + ruleId + " to " + path);
   }
 
diff --git 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/RuleBuilderDialog.java 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/RuleBuilderDialog.java
index 2ba1d817c9..7a01748541 100644
--- a/plugins/misc/lint/src/main/java/org/apache/hop/lint/RuleBuilderDialog.java
+++ b/plugins/misc/lint/src/main/java/org/apache/hop/lint/RuleBuilderDialog.java
@@ -17,12 +17,20 @@
 package org.apache.hop.lint;
 
 import java.util.ArrayList;
+import java.util.Collection;
+import java.util.IdentityHashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.function.Consumer;
+import java.util.regex.Pattern;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.Variables;
 import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.lint.registry.ProjectLintYamlExporter;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.widget.ColumnInfo;
 import org.apache.hop.ui.core.widget.TableView;
@@ -51,12 +59,16 @@ public class RuleBuilderDialog extends Dialog {
 
   private static final Class<?> PKG = RuleBuilderDialog.class; // for i18n 
purposes
 
+  /** A rule id is a YAML key, a SARIF rule id and a word on the command line. 
*/
+  private static final Pattern RULE_ID = 
Pattern.compile("[A-Za-z0-9][A-Za-z0-9_.-]*");
+
   private Shell shell;
   private Shell parent;
   private CustomLintRule rule;
   private boolean ok = false;
 
   // UI components
+  private Text idText;
   private Text nameText;
   private Text descriptionText;
   private Combo targetCombo;
@@ -84,11 +96,46 @@ public class RuleBuilderDialog extends Dialog {
   /** Guards the widget listeners while the widgets are being loaded from a 
clause. */
   private boolean loadingClause = false;
 
+  /**
+   * Where validation errors go instead of a message box. A native message box 
cannot be driven from
+   * a UI test, so the tests collect the messages here.
+   */
+  Consumer<String> errorReporter;
+
+  /** The target shown, so choosing it again changes nothing. */
+  private int shownTarget = -1;
+
+  /**
+   * Clauses a target change emptied, as they were just before, by the clause 
they were taken from.
+   * Going back to that target puts a clause back while it is still empty.
+   */
+  private final Map<RuleClause, EmptiedClause> emptiedClauses = new 
IdentityHashMap<>();
+
+  private record EmptiedClause(RuleTarget target, RuleClause clause) {}
+
+  /** The ids of the other rules, upper case, which this rule's id must not 
clash with. */
+  private final Set<String> takenIds = new TreeSet<>();
+
   public RuleBuilderDialog(Shell parent, CustomLintRule rule) {
     super(parent, SWT.DIALOG_TRIM | SWT.RESIZE | SWT.APPLICATION_MODAL);
     this.parent = parent;
     this.rule = rule != null ? rule : new CustomLintRule();
-    setText(rule != null ? "Edit Lint Rule" : "Add Lint Rule");
+    setText(
+        BaseMessages.getString(
+            PKG, rule != null ? "RuleBuilderDialog.Title.Edit" : 
"RuleBuilderDialog.Title.Add"));
+  }
+
+  /**
+   * @param takenIds the ids of every other rule, compared ignoring case; the 
rule's own id may be
+   *     among them
+   */
+  public void setTakenIds(Collection<String> takenIds) {
+    this.takenIds.clear();
+    for (String id : takenIds) {
+      if (id != null && !id.equalsIgnoreCase(rule.getId())) {
+        this.takenIds.add(id.toUpperCase(Locale.ROOT));
+      }
+    }
   }
 
   public CustomLintRule open() {
@@ -134,20 +181,37 @@ public class RuleBuilderDialog extends Dialog {
     int margin = 10;
     int labelWidth = 120;
 
+    // Rule ID: what findings, SARIF, --list-rules and hop-lint.yml call the 
rule.
+    Label idLabel = new Label(shell, SWT.RIGHT);
+    idLabel.setText(BaseMessages.getString(PKG, 
"RuleBuilderDialog.Label.RuleId"));
+    FormData idLabelData = new FormData();
+    idLabelData.left = new FormAttachment(0, margin);
+    idLabelData.right = new FormAttachment(0, labelWidth);
+    idLabelData.top = new FormAttachment(0, margin);
+    idLabel.setLayoutData(idLabelData);
+
+    idText = new Text(shell, SWT.BORDER);
+    idText.setToolTipText(BaseMessages.getString(PKG, 
"RuleBuilderDialog.RuleId.Tooltip"));
+    FormData idData = new FormData();
+    idData.left = new FormAttachment(idLabel, margin);
+    idData.right = new FormAttachment(100, -margin);
+    idData.top = new FormAttachment(0, margin);
+    idText.setLayoutData(idData);
+
     // Rule Name
     Label nameLabel = new Label(shell, SWT.RIGHT);
     nameLabel.setText(BaseMessages.getString(PKG, 
"RuleBuilderDialog.Label.RuleName"));
     FormData nameLabelData = new FormData();
     nameLabelData.left = new FormAttachment(0, margin);
     nameLabelData.right = new FormAttachment(0, labelWidth);
-    nameLabelData.top = new FormAttachment(0, margin);
+    nameLabelData.top = new FormAttachment(idText, margin);
     nameLabel.setLayoutData(nameLabelData);
 
     nameText = new Text(shell, SWT.BORDER);
     FormData nameData = new FormData();
     nameData.left = new FormAttachment(nameLabel, margin);
     nameData.right = new FormAttachment(100, -margin);
-    nameData.top = new FormAttachment(0, margin);
+    nameData.top = new FormAttachment(idText, margin);
     nameText.setLayoutData(nameData);
 
     // Description
@@ -184,7 +248,7 @@ public class RuleBuilderDialog extends Dialog {
         new SelectionAdapter() {
           @Override
           public void widgetSelected(SelectionEvent e) {
-            updateFieldCombo(null);
+            targetChanged();
           }
         });
     FormData targetData = new FormData();
@@ -285,8 +349,7 @@ public class RuleBuilderDialog extends Dialog {
         new SelectionAdapter() {
           @Override
           public void widgetSelected(SelectionEvent e) {
-            updateConditionCombo(null);
-            captureWidgetsIntoSelectedClause();
+            fieldChanged();
           }
         });
     FormData fieldData = new FormData();
@@ -309,8 +372,7 @@ public class RuleBuilderDialog extends Dialog {
         new SelectionAdapter() {
           @Override
           public void widgetSelected(SelectionEvent e) {
-            updateValueField();
-            captureWidgetsIntoSelectedClause();
+            conditionChanged();
           }
         });
     FormData conditionData = new FormData();
@@ -455,6 +517,11 @@ public class RuleBuilderDialog extends Dialog {
   }
 
   private void populateControls() {
+    idText.setText(rule.getId() != null ? rule.getId() : "");
+    // A pack rule keeps the pack's id: hop-lint.yml names it to change it. So 
does a project rule
+    // which redefines a pack rule, since another id would bring the pack rule 
back beside it.
+    idText.setEditable(
+        rule.isProjectEditable() && 
ProjectLintYamlExporter.packRule(rule.getId()) == null);
     if (rule.getName() != null) {
       nameText.setText(rule.getName());
     }
@@ -463,6 +530,7 @@ public class RuleBuilderDialog extends Dialog {
     }
     if (rule.getTarget() != null) {
       targetCombo.select(rule.getTarget().ordinal());
+      shownTarget = rule.getTarget().ordinal();
       updateFieldCombo(null);
     }
     // Selected by index: setText does nothing on a read-only combo, which 
left WARNING showing for
@@ -542,15 +610,97 @@ public class RuleBuilderDialog extends Dialog {
 
   private void updateValueField() {
     RuleCondition condition = selectedCondition();
-    if (condition != null) {
-      valueLabel.setVisible(condition.requiresValue());
-      valueText.setVisible(condition.requiresValue());
+    boolean usesValue = condition == null || condition.requiresValue();
+    valueLabel.setVisible(usesValue);
+    valueText.setVisible(usesValue);
+    if (condition != null && condition.requiresValue()) {
+      valueText.setToolTipText(condition.getDescription());
+    }
+    shell.layout(true, true);
+  }
 
-      if (condition.requiresValue()) {
-        valueText.setToolTipText(condition.getDescription());
+  /**
+   * A field the new target also has stays, with its condition and value; a 
field it does not have
+   * empties its clause. Going back to the target a clause was emptied on puts 
it back as it was
+   * then, edits included, as long as it is still empty. That covers a field 
the list does not know,
+   * such as a Table Input's {@code sql}.
+   */
+  private void targetChanged() {
+    int targetIndex = targetCombo.getSelectionIndex();
+    if (targetIndex < 0 || targetIndex == shownTarget) {
+      return;
+    }
+    RuleTarget previousTarget = shownTarget < 0 ? null : 
RuleTarget.values()[shownTarget];
+    shownTarget = targetIndex;
+    RuleTarget target = RuleTarget.values()[targetIndex];
+    List<String> fields = RuleTargetFields.getFieldsForTarget(target);
+    for (RuleClause clause : editingClauses) {
+      if (!Utils.isEmpty(clause.getTargetField()) && 
!fields.contains(clause.getTargetField())) {
+        emptiedClauses.put(clause, new EmptiedClause(previousTarget, 
clause.copy()));
+        clause.setTargetField("");
+        clause.setCondition(null);
+        clause.setConditionValue("");
+      } else if (Utils.isEmpty(clause.getTargetField())) {
+        EmptiedClause emptied = emptiedClauses.get(clause);
+        if (emptied != null && emptied.target() == target) {
+          clause.setTargetField(emptied.clause().getTargetField());
+          clause.setCondition(emptied.clause().getCondition());
+          clause.setConditionValue(emptied.clause().getConditionValue());
+          emptiedClauses.remove(clause);
+        }
       }
     }
-    shell.layout(true, true);
+    refreshClauseTable();
+    loadSelectedClauseIntoWidgets();
+  }
+
+  /**
+   * Choosing the field the clause already reads changes nothing. Another 
field keeps the condition
+   * when that field offers it too.
+   */
+  private void fieldChanged() {
+    RuleClause clause = selectedClause();
+    if (clause == null) {
+      return;
+    }
+    String field = fieldCombo.getText();
+    if (field.equals(clause.getTargetField())) {
+      return;
+    }
+    RuleCondition condition = clause.getCondition();
+    if (condition != null && 
!RuleTargetFields.getCompatibleConditions(field).contains(condition)) {
+      condition = null;
+    }
+    clause.setTargetField(field);
+    clause.setCondition(condition);
+    if (condition == null || !condition.requiresValue()) {
+      clause.setConditionValue("");
+    }
+    refreshClauseTable();
+    loadSelectedClauseIntoWidgets();
+  }
+
+  /**
+   * A condition without a value clears the value: the value field is hidden 
then, and a value
+   * nobody can see must not be saved.
+   */
+  private void conditionChanged() {
+    RuleCondition condition = selectedCondition();
+    if (condition != null && !condition.requiresValue()) {
+      loadingClause = true;
+      try {
+        valueText.setText("");
+      } finally {
+        loadingClause = false;
+      }
+    }
+    updateValueField();
+    captureWidgetsIntoSelectedClause();
+  }
+
+  private RuleClause selectedClause() {
+    int index = clauseTable.getSelectionIndex();
+    return index < 0 || index >= editingClauses.size() ? null : 
editingClauses.get(index);
   }
 
   private void ok() {
@@ -562,8 +712,15 @@ public class RuleBuilderDialog extends Dialog {
   }
 
   private boolean validate() {
+    if (idText.getEditable()) {
+      String problem = ruleIdProblem(idText.getText().trim(), takenIds);
+      if (problem != null) {
+        showError(problem);
+        return false;
+      }
+    }
     if (Utils.isEmpty(nameText.getText())) {
-      showError("Rule name is required");
+      showError(BaseMessages.getString(PKG, 
"RuleBuilderDialog.Error.NameRequired"));
       return false;
     }
     // A row with only a key or only a value is not a tag. Saving would drop 
it without a word.
@@ -577,29 +734,64 @@ public class RuleBuilderDialog extends Dialog {
       return true;
     }
     if (targetCombo.getSelectionIndex() < 0) {
-      showError("Target type must be selected");
-      return false;
-    }
-    if (fieldCombo.getSelectionIndex() < 0) {
-      showError("Field must be selected");
+      showError(BaseMessages.getString(PKG, 
"RuleBuilderDialog.Error.TargetRequired"));
       return false;
     }
-    if (conditionCombo.getSelectionIndex() < 0) {
-      showError("Condition must be selected");
-      return false;
+
+    // Every clause, not only the one on show. Saving relies on each having a 
field and condition.
+    captureWidgetsIntoSelectedClause();
+    for (int i = 0; i < editingClauses.size(); i++) {
+      String problem = clauseProblem(editingClauses.get(i));
+      if (problem != null) {
+        clauseTable.setSelection(i);
+        loadSelectedClauseIntoWidgets();
+        showError(
+            editingClauses.size() == 1
+                ? problem
+                : BaseMessages.getString(
+                    PKG, "RuleBuilderDialog.Error.Clause", String.valueOf(i + 
1), problem));
+        return false;
+      }
     }
+    return true;
+  }
 
-    // Check if condition requires a value
-    RuleCondition condition = selectedCondition();
-    if (condition != null && condition.requiresValue() && 
Utils.isEmpty(valueText.getText())) {
-      showError("Value is required for this condition");
-      return false;
+  /**
+   * What is wrong with a rule id, or null when it can be used.
+   *
+   * @param takenIds the other rules' ids, upper case
+   */
+  static String ruleIdProblem(String id, Set<String> takenIds) {
+    if (id.isEmpty()) {
+      return BaseMessages.getString(PKG, 
"RuleBuilderDialog.Error.RuleIdRequired");
+    }
+    if (!RULE_ID.matcher(id).matches()) {
+      return BaseMessages.getString(PKG, 
"RuleBuilderDialog.Error.RuleIdInvalid", id);
+    }
+    if (takenIds.contains(id.toUpperCase(Locale.ROOT))) {
+      return BaseMessages.getString(PKG, 
"RuleBuilderDialog.Error.RuleIdTaken", id);
     }
+    return null;
+  }
 
-    return true;
+  /** What is missing from a clause, or null when it is complete. */
+  static String clauseProblem(RuleClause clause) {
+    if (Utils.isEmpty(clause.getTargetField())) {
+      return BaseMessages.getString(PKG, 
"RuleBuilderDialog.Error.FieldRequired");
+    }
+    if (clause.getCondition() == null) {
+      return BaseMessages.getString(PKG, 
"RuleBuilderDialog.Error.ConditionRequired");
+    }
+    if (clause.getCondition().requiresValue() && 
Utils.isEmpty(clause.getConditionValue())) {
+      return BaseMessages.getString(PKG, 
"RuleBuilderDialog.Error.ValueRequired");
+    }
+    return null;
   }
 
   private void saveRule() {
+    if (idText.getEditable()) {
+      rule.setId(idText.getText().trim());
+    }
     rule.setName(nameText.getText());
     rule.setDescription(descriptionText.getText());
     rule.setSeverity(severityCombo.getText());
@@ -612,6 +804,12 @@ public class RuleBuilderDialog extends Dialog {
     }
 
     captureWidgetsIntoSelectedClause();
+    for (RuleClause clause : editingClauses) {
+      // No value where the condition takes none, and none rather than an 
empty one.
+      if (!clause.getCondition().requiresValue() || 
Utils.isEmpty(clause.getConditionValue())) {
+        clause.setConditionValue(null);
+      }
+    }
     rule.setTarget(RuleTarget.values()[targetCombo.getSelectionIndex()]);
     rule.setCombinator(
         combinatorCombo.getSelectionIndex() == 1 ? RuleCombinator.ANY_OF : 
RuleCombinator.ALL_OF);
@@ -735,11 +933,11 @@ public class RuleBuilderDialog extends Dialog {
     }
     RuleClause clause = editingClauses.get(index);
     clause.setTargetField(fieldCombo.getText());
+    // Null too, so the table never shows a condition the combo no longer has.
     RuleCondition condition = selectedCondition();
-    if (condition != null) {
-      clause.setCondition(condition);
-    }
-    clause.setConditionValue(valueText.getText());
+    clause.setCondition(condition);
+    clause.setConditionValue(
+        condition == null || condition.requiresValue() ? valueText.getText() : 
"");
     refreshClauseTable();
   }
 
@@ -767,6 +965,10 @@ public class RuleBuilderDialog extends Dialog {
   }
 
   private void showError(String message) {
+    if (errorReporter != null) {
+      errorReporter.accept(message);
+      return;
+    }
     MessageBox messageBox = new MessageBox(shell, SWT.ICON_ERROR | SWT.OK);
     messageBox.setText(
         BaseMessages.getString(PKG, 
"RuleBuilderDialog.Dialog.ValidationError.Title"));
diff --git 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/RuleManagerDialog.java 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/RuleManagerDialog.java
index fff060060e..56cea11c44 100644
--- a/plugins/misc/lint/src/main/java/org/apache/hop/lint/RuleManagerDialog.java
+++ b/plugins/misc/lint/src/main/java/org/apache/hop/lint/RuleManagerDialog.java
@@ -16,10 +16,16 @@
  */
 package org.apache.hop.lint;
 
+import java.util.ArrayList;
+import java.util.Comparator;
 import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.TreeSet;
 import org.apache.hop.core.logging.ILogChannel;
 import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.lint.registry.ProjectLintYamlExporter;
 import org.apache.hop.lint.registry.RulePackIds;
 import org.apache.hop.lint.registry.RulePackOwner;
 import org.eclipse.swt.SWT;
@@ -57,6 +63,14 @@ public class RuleManagerDialog extends Dialog {
   private Button deleteButton;
   private Button toggleButton;
 
+  /** The column the table is sorted on, or -1 for the order the rules were 
loaded in. */
+  private int sortColumn = -1;
+
+  private boolean sortAscending = true;
+
+  /** The ids the packs define, looked up once rather than on every selection. 
*/
+  private Set<String> packRuleIds;
+
   public RuleManagerDialog(Shell parent, List<CustomLintRule> rules) {
     super(parent, SWT.DIALOG_TRIM | SWT.RESIZE | SWT.APPLICATION_MODAL);
     this.parent = parent;
@@ -175,6 +189,12 @@ public class RuleManagerDialog extends Dialog {
     tagsColumn.setText(BaseMessages.getString(PKG, 
"RuleManagerDialog.Column.Tags"));
     tagsColumn.setWidth(200);
 
+    TableColumn[] columns = rulesTable.getColumns();
+    for (int i = 0; i < columns.length; i++) {
+      int index = i;
+      columns[i].addListener(SWT.Selection, e -> sortOn(index));
+    }
+
     FormData tableData = new FormData();
     tableData.left = new FormAttachment(0, margin);
     tableData.right = new FormAttachment(100, -(BUTTON_PANEL_WIDTH + 2 * 
margin));
@@ -283,34 +303,68 @@ public class RuleManagerDialog extends Dialog {
   }
 
   private void populateTable() {
+    CustomLintRule selected = rulesTable.getSelectionIndex() < 0 ? null : 
getSelectedRule();
     rulesTable.removeAll();
 
-    for (CustomLintRule rule : rules) {
+    List<CustomLintRule> shown = new ArrayList<>(rules);
+    if (sortColumn >= 0) {
+      Comparator<CustomLintRule> order =
+          Comparator.comparing(
+              rule -> rowText(rule)[sortColumn].toLowerCase(Locale.ROOT),
+              Comparator.naturalOrder());
+      shown.sort(sortAscending ? order : order.reversed());
+    }
+    for (CustomLintRule rule : shown) {
       TableItem item = new TableItem(rulesTable, SWT.NONE);
-      item.setText(0, rule.getTarget() != null ? rule.generateRuleId() : 
nullToEmpty(rule.getId()));
-      item.setText(1, rule.getName() != null ? rule.getName() : "");
-      item.setText(2, rule.getPackOwner() != null ? 
rule.getPackOwner().getDisplayName() : "");
-      item.setText(3, rule.getTarget() != null ? 
rule.getTarget().getDisplayName() : "");
-      item.setText(4, rule.getTargetField() != null ? rule.getTargetField() : 
"");
-      // A composed rule checks several things, and showing only its first 
clause made it
-      // indistinguishable from a rule that checks one. Say so in the columns 
that would otherwise
-      // be a half-truth.
-      if (rule.isComposed()) {
-        item.setText(
-            4, rule.getClauses().size() + " fields (" + 
rule.getCombinator().getYamlKey() + ")");
-        // A condition says what is required; the rule reports when the 
conditions are not met.
-        item.setText(
-            5, rule.getCombinator() == RuleCombinator.ALL_OF ? "All not met" : 
"Any not met");
-        item.setText(6, "");
-      } else {
-        item.setText(5, rule.getCondition() != null ? 
rule.getCondition().getDisplayName() : "");
-        item.setText(6, rule.getConditionValue() != null ? 
rule.getConditionValue() : "");
-      }
-      item.setText(7, rule.getSeverity() != null ? rule.getSeverity() : 
"WARNING");
-      item.setText(8, rule.isEnabled() ? "✓" : "✗");
-      item.setText(9, String.join(", ", 
LintRuleDetails.flatTags(rule.getTags())));
+      item.setText(rowText(rule));
       item.setData(rule);
     }
+    if (selected != null) {
+      select(selected);
+    }
+  }
+
+  private static String[] rowText(CustomLintRule rule) {
+    String[] row = new String[10];
+    row[0] = rule.getTarget() != null ? rule.generateRuleId() : 
nullToEmpty(rule.getId());
+    row[1] = nullToEmpty(rule.getName());
+    row[2] = rule.getPackOwner() != null ? 
rule.getPackOwner().getDisplayName() : "";
+    row[3] = rule.getTarget() != null ? rule.getTarget().getDisplayName() : "";
+    // A composed rule checks several things, and showing only its first 
clause made it
+    // indistinguishable from a rule that checks one. Say so in the columns 
that would otherwise be
+    // a half-truth.
+    if (rule.isComposed()) {
+      row[4] = rule.getClauses().size() + " fields (" + 
rule.getCombinator().getYamlKey() + ")";
+      // A condition says what is required; the rule reports when the 
conditions are not met.
+      row[5] = rule.getCombinator() == RuleCombinator.ALL_OF ? "All not met" : 
"Any not met";
+      row[6] = "";
+    } else {
+      row[4] = nullToEmpty(rule.getTargetField());
+      row[5] = rule.getCondition() != null ? 
rule.getCondition().getDisplayName() : "";
+      row[6] = nullToEmpty(rule.getConditionValue());
+    }
+    row[7] = rule.getSeverity() != null ? rule.getSeverity() : "WARNING";
+    row[8] = rule.isEnabled() ? "✓" : "✗";
+    row[9] = String.join(", ", LintRuleDetails.flatTags(rule.getTags()));
+    return row;
+  }
+
+  /** Sort on a column, or turn the order round when it is already the one 
sorted on. */
+  private void sortOn(int column) {
+    sortAscending = column != sortColumn || !sortAscending;
+    sortColumn = column;
+    rulesTable.setSortColumn(rulesTable.getColumn(column));
+    rulesTable.setSortDirection(sortAscending ? SWT.UP : SWT.DOWN);
+    populateTable();
+  }
+
+  private void select(CustomLintRule rule) {
+    int index = findTableIndex(rule);
+    if (index >= 0) {
+      rulesTable.setSelection(index);
+      rulesTable.showSelection();
+    }
+    updateButtonState();
   }
 
   private CustomLintRule getSelectedRule() {
@@ -331,20 +385,79 @@ public class RuleManagerDialog extends Dialog {
     editButton.setEnabled(hasSelection);
     deleteButton.setEnabled(projectEditable);
     toggleButton.setEnabled(hasSelection);
+    // A project rule with a pack rule's id replaces it, so removing it brings 
the pack rule back.
+    deleteButton.setText(
+        BaseMessages.getString(
+            PKG,
+            projectEditable && shadowsPackRule(selected)
+                ? "RuleManagerDialog.Button.RestorePackRule"
+                : "RuleManagerDialog.Button.DeleteRule"));
+  }
+
+  /**
+   * Compared exactly, as the rule registry does: a project rule {@code 
trans-002} sits beside the
+   * pack's TRANS-002 rather than replacing it.
+   */
+  private boolean shadowsPackRule(CustomLintRule rule) {
+    if (packRuleIds == null) {
+      packRuleIds = ProjectLintYamlExporter.packRuleIds();
+    }
+    return packRuleIds.contains(rule.generateRuleId());
+  }
+
+  /** Every rule id but this rule's own, for the editor to check a new id 
against. */
+  private List<String> idsOtherThan(CustomLintRule rule) {
+    List<String> ids = new ArrayList<>();
+    for (CustomLintRule other : rules) {
+      if (other != rule) {
+        ids.add(other.generateRuleId());
+      }
+    }
+    return ids;
+  }
+
+  /** PROJECT-001, or the first number after it that no rule uses yet. */
+  private String suggestRuleId() {
+    Set<String> taken = new TreeSet<>();
+    for (CustomLintRule rule : rules) {
+      taken.add(rule.generateRuleId().toUpperCase(Locale.ROOT));
+    }
+    for (int i = 1; ; i++) {
+      String id = String.format("PROJECT-%03d", i);
+      if (!taken.contains(id)) {
+        return id;
+      }
+    }
+  }
+
+  /** Put the saved version of a rule where the old one was. */
+  private void replaceInList(CustomLintRule old, CustomLintRule replacement) {
+    for (int i = 0; i < rules.size(); i++) {
+      if (rules.get(i) == old) {
+        rules.set(i, replacement);
+        return;
+      }
+    }
+    rules.add(replacement);
   }
 
   private void addRule() {
-    RuleBuilderDialog dialog = new RuleBuilderDialog(shell, null);
+    CustomLintRule draft = new CustomLintRule();
+    draft.setId(suggestRuleId());
+    RuleBuilderDialog dialog = new RuleBuilderDialog(shell, draft);
+    dialog.setText(BaseMessages.getString(PKG, "RuleBuilderDialog.Title.Add"));
+    dialog.setTakenIds(idsOtherThan(draft));
     CustomLintRule newRule = dialog.open();
-    if (newRule != null) {
-      newRule.setPackId(RulePackIds.PROJECT);
-      newRule.setPackOwner(RulePackOwner.PROJECT);
-      // Fixed now: without an id of its own the rule is named after a hash of 
its field and
-      // condition, so editing either would save it under a new id and leave 
the old entry behind.
-      newRule.setId(newRule.generateRuleId());
+    if (newRule == null) {
+      return;
+    }
+    newRule.setPackId(RulePackIds.PROJECT);
+    newRule.setPackOwner(RulePackOwner.PROJECT);
+    // Listed only once it is saved, so the table never shows a rule 
hop-lint.yml does not have.
+    if (saveRule(newRule, null)) {
       rules.add(newRule);
       populateTable();
-      saveRule(newRule, null);
+      select(newRule);
       log.logBasic("Added project rule: " + newRule.getName());
     }
   }
@@ -359,16 +472,43 @@ public class RuleManagerDialog extends Dialog {
     // the project's hop-lint.yml, as an override when it only tunes the rule 
and as a full rule
     // definition when it redefines it. That keeps the pack upgradeable and 
the decision with the
     // project.
+    //
+    // The editor works on a copy, so a change that fails to save leaves the 
rule as it was.
     String previousId = rule.generateRuleId();
-    RuleBuilderDialog dialog = new RuleBuilderDialog(shell, rule);
+    RuleBuilderDialog dialog = new RuleBuilderDialog(shell, rule.copy());
+    dialog.setTakenIds(idsOtherThan(rule));
     CustomLintRule editedRule = dialog.open();
-    if (editedRule != null) {
+    if (editedRule == null) {
+      return;
+    }
+    boolean redefinesPackRule =
+        !rule.isProjectEditable() && 
ProjectLintYamlExporter.redefinesPackRule(editedRule);
+    if (redefinesPackRule && !confirmRedefine(editedRule)) {
+      return;
+    }
+    if (saveRule(editedRule, previousId)) {
+      if (redefinesPackRule) {
+        // Written out in full, it is the project's rule now, as it will be 
when next loaded.
+        editedRule.setPackId(RulePackIds.PROJECT);
+        editedRule.setPackOwner(RulePackOwner.PROJECT);
+      }
+      replaceInList(rule, editedRule);
       populateTable();
-      saveRule(editedRule, previousId);
-      log.logBasic("Edited rule: " + rule.getName());
+      select(editedRule);
+      log.logBasic("Edited rule: " + editedRule.getName());
     }
   }
 
+  private boolean confirmRedefine(CustomLintRule rule) {
+    MessageBox confirmBox = new MessageBox(shell, SWT.ICON_WARNING | SWT.YES | 
SWT.NO);
+    confirmBox.setText(
+        BaseMessages.getString(PKG, 
"RuleManagerDialog.Dialog.ConfirmRedefine.Title"));
+    confirmBox.setMessage(
+        BaseMessages.getString(
+            PKG, "RuleManagerDialog.Dialog.ConfirmRedefine.Message", 
rule.generateRuleId()));
+    return confirmBox.open() == SWT.YES;
+  }
+
   private void deleteRule() {
     CustomLintRule rule = getSelectedRule();
     if (rule == null) {
@@ -380,21 +520,40 @@ public class RuleManagerDialog extends Dialog {
       return;
     }
 
+    String ruleId = rule.generateRuleId();
+    CustomLintRule packRule = ProjectLintYamlExporter.packRule(ruleId);
     MessageBox confirmBox = new MessageBox(shell, SWT.ICON_QUESTION | SWT.YES 
| SWT.NO);
-    confirmBox.setText(BaseMessages.getString(PKG, 
"RuleManagerDialog.Dialog.ConfirmDelete.Title"));
-    confirmBox.setMessage(
-        BaseMessages.getString(
-            PKG, "RuleManagerDialog.Dialog.ConfirmDelete.Message", 
rule.getName()));
+    if (packRule != null) {
+      confirmBox.setText(
+          BaseMessages.getString(PKG, 
"RuleManagerDialog.Dialog.ConfirmRestore.Title"));
+      confirmBox.setMessage(
+          BaseMessages.getString(PKG, 
"RuleManagerDialog.Dialog.ConfirmRestore.Message", ruleId));
+    } else {
+      confirmBox.setText(
+          BaseMessages.getString(PKG, 
"RuleManagerDialog.Dialog.ConfirmDelete.Title"));
+      confirmBox.setMessage(
+          BaseMessages.getString(
+              PKG, "RuleManagerDialog.Dialog.ConfirmDelete.Message", 
rule.getName()));
+    }
+    if (confirmBox.open() != SWT.YES) {
+      return;
+    }
 
-    if (confirmBox.open() == SWT.YES) {
+    try {
+      LinterConfigPlugin.getInstance().removeProjectRule(ruleId);
+    } catch (Exception e) {
+      showSaveError(rule, e);
+      return;
+    }
+    if (packRule != null) {
+      replaceInList(rule, packRule);
+      populateTable();
+      select(packRule);
+      log.logBasic("Restored pack rule: " + ruleId);
+    } else {
       rules.remove(rule);
       populateTable();
       updateButtonState();
-      try {
-        
LinterConfigPlugin.getInstance().removeProjectRule(rule.generateRuleId());
-      } catch (Exception e) {
-        showSaveError(rule, e);
-      }
       log.logBasic("Deleted project rule: " + rule.getName());
     }
   }
@@ -406,13 +565,18 @@ public class RuleManagerDialog extends Dialog {
       return;
     }
 
-    rule.setEnabled(!rule.isEnabled());
-    populateTable();
-    rulesTable.setSelection(findTableIndex(rule));
-    updateButtonState();
-    saveRule(rule, null);
-    log.logBasic(
-        "Toggled rule: " + rule.getName() + " to " + (rule.isEnabled() ? 
"enabled" : "disabled"));
+    CustomLintRule toggled = rule.copy();
+    toggled.setEnabled(!rule.isEnabled());
+    if (saveRule(toggled, null)) {
+      replaceInList(rule, toggled);
+      populateTable();
+      select(toggled);
+      log.logBasic(
+          "Toggled rule: "
+              + toggled.getName()
+              + " to "
+              + (toggled.isEnabled() ? "enabled" : "disabled"));
+    }
   }
 
   private int findTableIndex(CustomLintRule rule) {
@@ -449,11 +613,13 @@ public class RuleManagerDialog extends Dialog {
    * Write the one rule that changed. The rest of hop-lint.yml — other rules, 
exclusions,
    * suppressions and comments — stays as it is.
    */
-  private void saveRule(CustomLintRule rule, String previousId) {
+  private boolean saveRule(CustomLintRule rule, String previousId) {
     try {
       LinterConfigPlugin.getInstance().saveProjectRule(rule, previousId);
+      return true;
     } catch (Exception e) {
       showSaveError(rule, e);
+      return false;
     }
   }
 
diff --git 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/ProjectLintYamlExporter.java
 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/ProjectLintYamlExporter.java
index d5f3b34417..e36e9b39a9 100644
--- 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/ProjectLintYamlExporter.java
+++ 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/ProjectLintYamlExporter.java
@@ -19,9 +19,11 @@ package org.apache.hop.lint.registry;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Set;
 import org.apache.hop.lint.CustomLintRule;
 import org.apache.hop.lint.RuleClause;
 import org.yaml.snakeyaml.DumperOptions;
@@ -84,6 +86,30 @@ public final class ProjectLintYamlExporter {
     return null;
   }
 
+  /**
+   * Whether saving this rule writes it out in full over a pack rule of the 
same id, so that it no
+   * longer follows the pack. A change to the name, description, severity, 
value or tags is an
+   * override and keeps the rule tied to the pack; a change to what it checks 
is not.
+   */
+  public static boolean redefinesPackRule(CustomLintRule desired) {
+    CustomLintRule packDefault = packDefaults().get(desired.generateRuleId());
+    return packDefault != null && structurallyDiffersFromPackDefault(desired, 
packDefault);
+  }
+
+  /**
+   * The rule a pack defines under this id, before the project changes it, or 
null when no pack
+   * defines one.
+   */
+  public static CustomLintRule packRule(String ruleId) {
+    CustomLintRule packDefault = packDefaults().get(ruleId);
+    return packDefault != null ? packDefault.copy() : null;
+  }
+
+  /** The ids of the rules the packs define, as they are written. */
+  public static Set<String> packRuleIds() {
+    return new LinkedHashSet<>(packDefaults().keySet());
+  }
+
   /** The rules as the packs define them, before any project changes them. */
   private static Map<String, CustomLintRule> packDefaults() {
     Map<String, CustomLintRule> packDefaults = new LinkedHashMap<>();
@@ -98,9 +124,11 @@ public final class ProjectLintYamlExporter {
     return desired.isEnabled() != packDefault.isEnabled()
         || !Objects.equals(desired.getSeverity(), packDefault.getSeverity())
         || !Objects.equals(desired.getAdditionalParameters(), 
packDefault.getAdditionalParameters())
-        || !Objects.equals(desired.getConditionValue(), 
packDefault.getConditionValue())
+        || !sameText(desired.getConditionValue(), 
packDefault.getConditionValue())
         || !Objects.equals(desired.getHelpUri(), packDefault.getHelpUri())
         || !Objects.equals(desired.getTags(), packDefault.getTags())
+        || !Objects.equals(desired.getName(), packDefault.getName())
+        || !sameText(desired.getDescription(), packDefault.getDescription())
         || structurallyDiffersFromPackDefault(desired, packDefault);
   }
 
@@ -121,25 +149,40 @@ public final class ProjectLintYamlExporter {
         || !Objects.equals(desired.getCondition(), packDefault.getCondition())
         || !Objects.equals(desired.getAppliesTo(), packDefault.getAppliesTo())
         || !Objects.equals(desired.getType(), packDefault.getType())
-        || !Objects.equals(desired.getMessageKey(), 
packDefault.getMessageKey())
-        || !Objects.equals(desired.getName(), packDefault.getName())
-        || !Objects.equals(desired.getDescription(), 
packDefault.getDescription());
+        || !Objects.equals(desired.getMessageKey(), 
packDefault.getMessageKey());
   }
 
   /**
-   * What a rule looks at, ignoring the values it compares against.
+   * What a rule looks at: every clause's field and condition, and the values 
of the clauses after
+   * the first.
    *
-   * <p>A changed threshold is a tune and an override block carries it. A 
changed field or condition
-   * is a redefinition and it cannot, so only those count as structural.
+   * <p>An override block carries one {@code conditionValue}, the first 
clause's, so a changed
+   * threshold there is a tune. A changed field or condition, or a changed 
value in any later
+   * clause, can only be written as the whole rule.
    */
   private static List<String> clauseShape(CustomLintRule rule) {
     List<String> shape = new ArrayList<>();
-    for (RuleClause clause : rule.getClauses()) {
-      shape.add(clause.getTargetField() + "|" + clause.getCondition());
+    List<RuleClause> clauses = rule.getClauses();
+    for (int i = 0; i < clauses.size(); i++) {
+      RuleClause clause = clauses.get(i);
+      String value = i == 0 ? "" : emptyToNull(clause.getConditionValue());
+      shape.add(clause.getTargetField() + "|" + clause.getCondition() + "|" + 
value);
     }
     return shape;
   }
 
+  private static String emptyToNull(String text) {
+    return text == null || text.isEmpty() ? null : text;
+  }
+
+  /**
+   * An empty text and no text are the same thing: the editor cannot tell them 
apart, and a pack
+   * reads a missing conditionValue as an empty one.
+   */
+  private static boolean sameText(String first, String second) {
+    return Objects.equals(emptyToNull(first), emptyToNull(second));
+  }
+
   private static Map<String, Object> toOverrideMap(
       CustomLintRule desired, CustomLintRule packDefault) {
     Map<String, Object> ruleConfig = new LinkedHashMap<>();
@@ -149,7 +192,7 @@ public final class ProjectLintYamlExporter {
     if (!Objects.equals(desired.getSeverity(), packDefault.getSeverity())) {
       ruleConfig.put("severity", desired.getSeverity());
     }
-    if (!Objects.equals(desired.getConditionValue(), 
packDefault.getConditionValue())
+    if (!sameText(desired.getConditionValue(), packDefault.getConditionValue())
         && desired.getConditionValue() != null) {
       ruleConfig.put("conditionValue", desired.getConditionValue());
     }
@@ -161,6 +204,16 @@ public final class ProjectLintYamlExporter {
       // An empty value is how a project removes the pack's link.
       ruleConfig.put("helpUri", desired.getHelpUri() != null ? 
desired.getHelpUri() : "");
     }
+    if (!Objects.equals(desired.getName(), packDefault.getName())
+        && desired.getName() != null
+        && !desired.getName().isEmpty()) {
+      ruleConfig.put("name", desired.getName());
+    }
+    if (!sameText(desired.getDescription(), packDefault.getDescription())) {
+      // An empty value is how a project clears the pack's description.
+      ruleConfig.put(
+          "description", desired.getDescription() != null ? 
desired.getDescription() : "");
+    }
     Map<String, Object> tags = tagOverrides(desired.getTags(), 
packDefault.getTags());
     if (!tags.isEmpty()) {
       ruleConfig.put("tags", tags);
@@ -220,7 +273,10 @@ public final class ProjectLintYamlExporter {
     } else {
       ruleConfig.put("targetField", rule.getTargetField());
       ruleConfig.put("condition", rule.getCondition().name());
-      ruleConfig.put("conditionValue", rule.getConditionValue());
+      // Left out when there is none, as in a composed rule's clauses.
+      if (rule.getConditionValue() != null && 
!rule.getConditionValue().isEmpty()) {
+        ruleConfig.put("conditionValue", rule.getConditionValue());
+      }
     }
     ruleConfig.put("name", rule.getName());
     ruleConfig.put("description", rule.getDescription());
diff --git 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/ProjectYamlOverlay.java
 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/ProjectYamlOverlay.java
index cbb787c497..59fab55d93 100644
--- 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/ProjectYamlOverlay.java
+++ 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/ProjectYamlOverlay.java
@@ -69,6 +69,11 @@ public final class ProjectYamlOverlay {
     private final Map<String, Object> parameters;
     private final String helpUri;
 
+    /** A name and description of the project's own, for a pack rule it only 
renames. */
+    private final String name;
+
+    private final String description;
+
     /**
      * Tags the project sets on the pack's rule, by key. A key replaces the 
pack's values for that
      * key and leaves its other tags alone; a key with no values removes the 
pack's tag.
@@ -81,12 +86,16 @@ public final class ProjectYamlOverlay {
         String conditionValue,
         Map<String, Object> parameters,
         String helpUri,
+        String name,
+        String description,
         Map<String, List<String>> tags) {
       this.enabled = enabled;
       this.severity = severity;
       this.conditionValue = conditionValue;
       this.parameters = parameters;
       this.helpUri = helpUri;
+      this.name = name;
+      this.description = description;
       this.tags = tags != null ? tags : Collections.emptyMap();
     }
 
@@ -112,9 +121,18 @@ public final class ProjectYamlOverlay {
         Object value = ruleData.get("helpUri");
         helpUri = value != null ? value.toString().trim() : "";
       }
+      // A name or description renames the pack rule in place; it stays the 
pack's rule.
+      String name = ruleData.get("name") != null ? 
String.valueOf(ruleData.get("name")) : null;
+      String description =
+          ruleData.containsKey("description")
+              ? (ruleData.get("description") != null
+                  ? String.valueOf(ruleData.get("description"))
+                  : "")
+              : null;
       Map<String, List<String>> tags =
           YamlRulePackParser.tagsValue(ruleData.get("tags"), ruleId, location, 
true);
-      return new ProjectRuleOverlay(enabled, severity, conditionValue, 
parameters, helpUri, tags);
+      return new ProjectRuleOverlay(
+          enabled, severity, conditionValue, parameters, helpUri, name, 
description, tags);
     }
 
     public void applyTo(org.apache.hop.lint.CustomLintRule rule) {
@@ -133,6 +151,12 @@ public final class ProjectYamlOverlay {
       if (helpUri != null) {
         rule.setHelpUri(helpUri.isEmpty() ? null : helpUri);
       }
+      if (name != null && !name.trim().isEmpty()) {
+        rule.setName(name);
+      }
+      if (description != null) {
+        rule.setDescription(description);
+      }
       tags.forEach(
           (key, values) -> {
             if (values.isEmpty()) {
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 d93d1bf1a9..16b97e756a 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
@@ -77,7 +77,15 @@ public final class YamlRulePackParser {
 
   /** The keys a project's hop-lint.yml may use to tune a rule from a pack. */
   static final Set<String> OVERRIDE_KEYS =
-      Set.of("enabled", "severity", "conditionValue", "parameters", "tags", 
"helpUri");
+      Set.of(
+          "enabled",
+          "severity",
+          "conditionValue",
+          "parameters",
+          "tags",
+          "helpUri",
+          "name",
+          "description");
 
   private YamlRulePackParser() {}
 
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 050fbcc81a..7b3cc65bb9 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
@@ -58,6 +58,8 @@ LinterConfigPlugin.Option.NativeChecks.ToolTip=Also run Hop's 
built-in pipeline/
 # ---------------------------------------------------------------------------
 # Rule builder dialog
 # ---------------------------------------------------------------------------
+RuleBuilderDialog.Label.RuleId=Rule ID:
+RuleBuilderDialog.RuleId.Tooltip=The id findings, SARIF reports, --list-rules 
and hop-lint.yml use for this rule: letters, digits, hyphens, underscores and 
dots.
 RuleBuilderDialog.Label.RuleName=Rule Name:
 RuleBuilderDialog.Label.Description=Description:
 RuleBuilderDialog.Label.TargetType=Target Type:
@@ -75,6 +77,17 @@ RuleBuilderDialog.Tags.Incomplete=Each tag needs both a key 
and a value.
 RuleBuilderDialog.Button.Ok=OK
 RuleBuilderDialog.Button.Cancel=Cancel
 RuleBuilderDialog.Dialog.ValidationError.Title=Validation Error
+RuleBuilderDialog.Title.Add=Add Lint Rule
+RuleBuilderDialog.Title.Edit=Edit Lint Rule
+RuleBuilderDialog.Error.NameRequired=Rule name is required
+RuleBuilderDialog.Error.TargetRequired=Target type must be selected
+RuleBuilderDialog.Error.RuleIdRequired=Rule ID is required
+RuleBuilderDialog.Error.RuleIdInvalid=Rule ID ''{0}'' can only contain 
letters, digits, hyphens, underscores and dots, and must start with a letter or 
digit
+RuleBuilderDialog.Error.RuleIdTaken=Another rule already has ID ''{0}''
+RuleBuilderDialog.Error.Clause=Clause {0}: {1}
+RuleBuilderDialog.Error.FieldRequired=Field must be selected
+RuleBuilderDialog.Error.ConditionRequired=Condition must be selected
+RuleBuilderDialog.Error.ValueRequired=Value is required for this condition
 
 # ---------------------------------------------------------------------------
 # Rule manager dialog
@@ -93,10 +106,15 @@ RuleManagerDialog.Column.Tags=Tags
 RuleManagerDialog.Button.AddProjectRule=Add Project Rule
 RuleManagerDialog.Button.EditRule=Edit Rule
 RuleManagerDialog.Button.DeleteRule=Delete Rule
+RuleManagerDialog.Button.RestorePackRule=Restore Pack Rule
 RuleManagerDialog.Button.ToggleEnabled=Toggle Enabled
 RuleManagerDialog.Button.Close=Close
 RuleManagerDialog.Dialog.ConfirmDelete.Title=Confirm Delete
 RuleManagerDialog.Dialog.ConfirmDelete.Message=Delete project rule ''{0}''?
+RuleManagerDialog.Dialog.ConfirmRestore.Title=Restore Pack Rule
+RuleManagerDialog.Dialog.ConfirmRestore.Message=Remove {0} from hop-lint.yml 
and use the pack''s rule again?
+RuleManagerDialog.Dialog.ConfirmRedefine.Title=Replace Pack Rule
+RuleManagerDialog.Dialog.ConfirmRedefine.Message=This changes what {0} checks. 
The whole rule is written to hop-lint.yml and replaces the pack''s rule, so 
later changes to it in the pack no longer apply.\n\nSave anyway?
 RuleManagerDialog.Dialog.PackRule.Title=Pack Rule
 RuleManagerDialog.Dialog.SaveFailed.Title=Rule Not Saved
 RuleManagerDialog.Dialog.SaveFailed.Message=Rule {0} could not be saved to 
hop-lint.yml. The file was not changed.
diff --git 
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintRuleYamlWriterTest.java
 
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintRuleYamlWriterTest.java
index ca4fd65c91..081bf08c74 100644
--- 
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintRuleYamlWriterTest.java
+++ 
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintRuleYamlWriterTest.java
@@ -108,6 +108,42 @@ public class LintRuleYamlWriterTest {
     assertEquals(1, policy.getSuppressions().size());
   }
 
+  /**
+   * A new id and the removal of the old one go into the file together. 
Removing first and writing
+   * second lost the rule from the file when the write was refused.
+   */
+  @Test
+  public void aNewIdReplacesTheOldEntryInOneWrite() throws Exception {
+    Files.writeString(yaml(), PROJECT_FILE, StandardCharsets.UTF_8);
+
+    LintPolicyYamlWriter.putRule(
+        yaml(), "CRM-NAMING", entry("type", "custom", "enabled", false), 
"CRM-001");
+
+    Map<String, ?> rules = rulesIn(Files.readString(yaml(), 
StandardCharsets.UTF_8));
+    assertFalse(rules.containsKey("CRM-001"), "the old id is gone");
+    assertEquals(entry("type", "custom", "enabled", false), 
rules.get("CRM-NAMING"));
+    assertTrue(rules.containsKey("DOC-001") && rules.containsKey("SEC-002"));
+  }
+
+  @Test
+  public void aRefusedNewIdLeavesTheFileAsItWas() throws Exception {
+    String inline = "rules: {CRM-001: {enabled: false}}\n";
+    Files.writeString(yaml(), inline, StandardCharsets.UTF_8);
+
+    assertThrows(
+        IOException.class,
+        () ->
+            LintPolicyYamlWriter.putRule(yaml(), "CRM-NAMING", 
entry("enabled", true), "CRM-001"));
+
+    assertEquals(inline, Files.readString(yaml(), StandardCharsets.UTF_8));
+  }
+
+  @SuppressWarnings("unchecked")
+  private static Map<String, ?> rulesIn(String yaml) {
+    Map<String, ?> root = new org.yaml.snakeyaml.Yaml().load(yaml);
+    return (Map<String, ?>) root.get("rules");
+  }
+
   @Test
   public void aRuleWithAnInlineCommentIsReplacedWhole() throws Exception {
     String after =
diff --git 
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/RuleBuilderDialogClauseTest.java
 
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/RuleBuilderDialogClauseTest.java
new file mode 100644
index 0000000000..31425bc21b
--- /dev/null
+++ 
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/RuleBuilderDialogClauseTest.java
@@ -0,0 +1,316 @@
+/*
+ * 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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.eclipse.swtbot.swt.finder.SWTBot;
+import org.eclipse.swtbot.swt.finder.waits.DefaultCondition;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/** The field, condition and value widgets of the rule editor, and the clause 
table above them. */
+@Tag("uitest")
+class RuleBuilderDialogClauseTest extends SwtBotTestBase {
+
+  private static CustomLintRule rule(
+      RuleTarget target, String field, RuleCondition condition, String value) {
+    CustomLintRule rule = new CustomLintRule("Naming", target, field, 
condition);
+    rule.setId("PROJECT-001");
+    rule.setConditionValue(value);
+    return rule;
+  }
+
+  /** Hidden but kept, the old pattern was saved with NOT_NULL and came back 
on the next switch. */
+  @Test
+  void aConditionWithoutValueDropsTheOldValue() {
+    CustomLintRule rule =
+        rule(RuleTarget.PIPELINE, "name", RuleCondition.MATCHES_PATTERN, 
"^[A-Z][A-Z0-9_]*$");
+    AtomicReference<CustomLintRule> saved = new AtomicReference<>();
+
+    withDialog(
+        parent -> saved.set(new RuleBuilderDialog(parent, rule).open()),
+        bot -> {
+          SWTBot dialog = bot.shell("Edit Lint Rule").bot();
+          dialog.comboBoxWithLabel("Condition:").setSelection("Not Null");
+          dialog.button("OK").click();
+        });
+
+    assertNotNull(saved.get(), "the dialog refused to save");
+    assertEquals(RuleCondition.NOT_NULL, saved.get().getCondition());
+    assertNull(saved.get().getConditionValue());
+  }
+
+  /** Choosing the field again emptied the condition, and OK then refused to 
save. */
+  @Test
+  void choosingTheSameFieldKeepsTheCondition() {
+    CustomLintRule rule =
+        rule(RuleTarget.TRANSFORM, "isOrphaned", RuleCondition.MUST_BE_FALSE, 
null);
+    AtomicReference<CustomLintRule> saved = new AtomicReference<>();
+    AtomicReference<String> tableCondition = new AtomicReference<>();
+
+    withDialog(
+        parent -> saved.set(new RuleBuilderDialog(parent, rule).open()),
+        bot -> {
+          SWTBot dialog = bot.shell("Edit Lint Rule").bot();
+          dialog.comboBoxWithLabel("Field:").setSelection("isOrphaned");
+          tableCondition.set(dialog.table().cell(0, 1));
+          dialog.button("OK").click();
+        });
+
+    assertEquals("Must Be False", tableCondition.get());
+    assertNotNull(saved.get(), "the dialog refused to save");
+    assertEquals(RuleCondition.MUST_BE_FALSE, saved.get().getCondition());
+  }
+
+  /** A field with a condition both offer keeps the condition and its value. */
+  @Test
+  void anotherFieldKeepsAConditionItAlsoOffers() {
+    CustomLintRule rule = rule(RuleTarget.PIPELINE, "name", 
RuleCondition.STARTS_WITH, "etl_");
+    AtomicReference<CustomLintRule> saved = new AtomicReference<>();
+
+    withDialog(
+        parent -> saved.set(new RuleBuilderDialog(parent, rule).open()),
+        bot -> {
+          SWTBot dialog = bot.shell("Edit Lint Rule").bot();
+          dialog.comboBoxWithLabel("Field:").setSelection("description");
+          dialog.button("OK").click();
+        });
+
+    assertNotNull(saved.get(), "the dialog refused to save");
+    assertEquals("description", saved.get().getTargetField());
+    assertEquals(RuleCondition.STARTS_WITH, saved.get().getCondition());
+    assertEquals("etl_", saved.get().getConditionValue());
+  }
+
+  /** Pipeline to Workflow cleared the field and condition, though workflows 
have a name too. */
+  @Test
+  void anotherTargetKeepsAFieldItAlsoHas() {
+    CustomLintRule rule = rule(RuleTarget.PIPELINE, "name", 
RuleCondition.NOT_NULL, null);
+    AtomicReference<CustomLintRule> saved = new AtomicReference<>();
+    AtomicReference<String> tableField = new AtomicReference<>();
+
+    withDialog(
+        parent -> saved.set(new RuleBuilderDialog(parent, rule).open()),
+        bot -> {
+          SWTBot dialog = bot.shell("Edit Lint Rule").bot();
+          dialog
+              .comboBoxWithLabel("Target Type:")
+              .setSelection(RuleTarget.WORKFLOW.getDisplayName());
+          tableField.set(dialog.table().cell(0, 0));
+          dialog.button("OK").click();
+        });
+
+    assertEquals("name", tableField.get());
+    assertNotNull(saved.get(), "the dialog refused to save");
+    assertEquals(RuleTarget.WORKFLOW, saved.get().getTarget());
+    assertEquals("name", saved.get().getTargetField());
+    assertEquals(RuleCondition.NOT_NULL, saved.get().getCondition());
+  }
+
+  /** A field the new target does not have goes, in the table as well as the 
dropdowns. */
+  @Test
+  void anotherTargetClearsAFieldItDoesNotHave() {
+    CustomLintRule rule =
+        rule(RuleTarget.PIPELINE, "transformCount", RuleCondition.MAX_VALUE, 
"30");
+    AtomicReference<String> tableField = new AtomicReference<>();
+    AtomicReference<String> tableCondition = new AtomicReference<>();
+
+    withDialog(
+        parent -> new RuleBuilderDialog(parent, rule).open(),
+        bot -> {
+          SWTBot dialog = bot.shell("Edit Lint Rule").bot();
+          dialog
+              .comboBoxWithLabel("Target Type:")
+              .setSelection(RuleTarget.WORKFLOW.getDisplayName());
+          tableField.set(dialog.table().cell(0, 0));
+          tableCondition.set(dialog.table().cell(0, 1));
+          dialog.button("Cancel").click();
+        });
+
+    assertEquals("", tableField.get());
+    assertEquals("", tableCondition.get());
+  }
+
+  /**
+   * A trip to another target and back gives a clause its field again, even 
one the editor does not
+   * list, such as a Table Input's sql.
+   */
+  @Test
+  void backOnItsOwnTargetARuleGetsItsFieldsBack() {
+    CustomLintRule rule =
+        rule(RuleTarget.TRANSFORM, "sql", RuleCondition.NOT_MATCHES_PATTERN, 
"(?i)select \\*");
+    AtomicReference<CustomLintRule> saved = new AtomicReference<>();
+    AtomicReference<String> emptiedField = new AtomicReference<>();
+
+    withDialog(
+        parent -> saved.set(new RuleBuilderDialog(parent, rule).open()),
+        bot -> {
+          SWTBot dialog = bot.shell("Edit Lint Rule").bot();
+          dialog
+              .comboBoxWithLabel("Target Type:")
+              .setSelection(RuleTarget.TRANSFORM.getDisplayName());
+          dialog
+              .comboBoxWithLabel("Target Type:")
+              .setSelection(RuleTarget.PIPELINE.getDisplayName());
+          emptiedField.set(dialog.table().cell(0, 0));
+          dialog
+              .comboBoxWithLabel("Target Type:")
+              .setSelection(RuleTarget.TRANSFORM.getDisplayName());
+          dialog.button("OK").click();
+        });
+
+    assertEquals("", emptiedField.get(), "pipelines have no sql");
+    assertNotNull(saved.get(), "the dialog refused to save");
+    assertEquals("sql", saved.get().getTargetField());
+    assertEquals(RuleCondition.NOT_MATCHES_PATTERN, 
saved.get().getCondition());
+    assertEquals("(?i)select \\*", saved.get().getConditionValue());
+  }
+
+  /** An edit made before the trip to another target is what comes back, not 
the rule as opened. */
+  @Test
+  void backOnItsOwnTargetAClauseKeepsItsEdits() {
+    CustomLintRule rule = rule(RuleTarget.TRANSFORM, "sql", 
RuleCondition.NOT_MATCHES_PATTERN, "a");
+    AtomicReference<CustomLintRule> saved = new AtomicReference<>();
+
+    withDialog(
+        parent -> saved.set(new RuleBuilderDialog(parent, rule).open()),
+        bot -> {
+          SWTBot dialog = bot.shell("Edit Lint Rule").bot();
+          dialog.textWithLabel("Value:").setText("b");
+          dialog
+              .comboBoxWithLabel("Target Type:")
+              .setSelection(RuleTarget.PIPELINE.getDisplayName());
+          dialog
+              .comboBoxWithLabel("Target Type:")
+              .setSelection(RuleTarget.TRANSFORM.getDisplayName());
+          dialog.button("OK").click();
+        });
+
+    assertNotNull(saved.get(), "the dialog refused to save");
+    assertEquals("sql", saved.get().getTargetField());
+    assertEquals("b", saved.get().getConditionValue());
+  }
+
+  /** OK checks every clause, and shows the first incomplete one. */
+  @Test
+  void anEmptyClauseThatIsNotOnShowIsRefused() {
+    CustomLintRule rule = rule(RuleTarget.PIPELINE, "name", 
RuleCondition.NOT_NULL, null);
+    rule.setAdditionalClauses(new ArrayList<>(List.of(new RuleClause("", null, 
""))));
+
+    Refusal refusal = pressOkExpectingRefusal(rule);
+
+    assertEquals(List.of("Clause 2: Field must be selected"), 
refusal.errors());
+    assertEquals("", refusal.selectedField(), "the empty clause is selected");
+  }
+
+  @Test
+  void aMissingValueInAClauseThatIsNotOnShowIsRefused() {
+    CustomLintRule rule = rule(RuleTarget.PIPELINE, "name", 
RuleCondition.NOT_NULL, null);
+    rule.setAdditionalClauses(
+        new ArrayList<>(List.of(new RuleClause("description", 
RuleCondition.MATCHES_PATTERN, ""))));
+
+    Refusal refusal = pressOkExpectingRefusal(rule);
+
+    assertEquals(List.of("Clause 2: Value is required for this condition"), 
refusal.errors());
+    assertEquals("description", refusal.selectedField());
+  }
+
+  /** A value left on a condition that takes none is no reason to refuse; it 
is just not saved. */
+  @Test
+  void aLeftoverValueOnAConditionWithoutValueIsDropped() {
+    CustomLintRule rule = rule(RuleTarget.PIPELINE, "name", 
RuleCondition.NOT_NULL, "^X$");
+    rule.setAdditionalClauses(
+        new ArrayList<>(List.of(new RuleClause("description", 
RuleCondition.NOT_EMPTY, "left"))));
+    AtomicReference<CustomLintRule> saved = new AtomicReference<>();
+    List<String> errors = new CopyOnWriteArrayList<>();
+
+    withDialog(
+        parent -> {
+          RuleBuilderDialog dialog = new RuleBuilderDialog(parent, rule);
+          dialog.errorReporter = errors::add;
+          saved.set(dialog.open());
+        },
+        bot -> bot.shell("Edit Lint Rule").bot().button("OK").click());
+
+    assertEquals(List.of(), errors);
+    assertNotNull(saved.get(), "the dialog refused to save");
+    assertNull(saved.get().getConditionValue());
+    assertNull(saved.get().getAdditionalClauses().get(0).getConditionValue());
+  }
+
+  private record Refusal(List<String> errors, String selectedField) {}
+
+  /** Press OK, wait for the refusal, note the selected clause's field and 
cancel. */
+  private Refusal pressOkExpectingRefusal(CustomLintRule rule) {
+    List<String> errors = new CopyOnWriteArrayList<>();
+    AtomicReference<CustomLintRule> saved = new AtomicReference<>();
+    AtomicReference<String> selectedField = new AtomicReference<>();
+
+    withDialog(
+        parent -> {
+          RuleBuilderDialog dialog = new RuleBuilderDialog(parent, rule);
+          dialog.errorReporter = errors::add;
+          saved.set(dialog.open());
+        },
+        bot -> {
+          SWTBot dialog = bot.shell("Edit Lint Rule").bot();
+          dialog.button("OK").click();
+          bot.waitUntil(
+              new DefaultCondition() {
+                @Override
+                public boolean test() {
+                  return !errors.isEmpty();
+                }
+
+                @Override
+                public String getFailureMessage() {
+                  return "OK was not refused";
+                }
+              });
+          selectedField.set(dialog.table().selection().get(0, 0));
+          dialog.button("Cancel").click();
+        });
+
+    assertNull(saved.get(), "a refused rule is not saved");
+    return new Refusal(errors, selectedField.get());
+  }
+
+  @Test
+  void aProjectRuleIdCanBeChanged() {
+    CustomLintRule rule = rule(RuleTarget.PIPELINE, "name", 
RuleCondition.NOT_NULL, null);
+    AtomicReference<CustomLintRule> saved = new AtomicReference<>();
+
+    withDialog(
+        parent -> saved.set(new RuleBuilderDialog(parent, rule).open()),
+        bot -> {
+          SWTBot dialog = bot.shell("Edit Lint Rule").bot();
+          dialog.textWithLabel("Rule ID:").setText("NAMING-PIPE");
+          dialog.button("OK").click();
+        });
+
+    assertNotNull(saved.get(), "the dialog refused to save");
+    assertEquals("NAMING-PIPE", saved.get().getId());
+  }
+}
diff --git 
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/RuleBuilderValidationTest.java
 
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/RuleBuilderValidationTest.java
new file mode 100644
index 0000000000..8b5f8031f1
--- /dev/null
+++ 
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/RuleBuilderValidationTest.java
@@ -0,0 +1,60 @@
+/*
+ * 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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+
+/** The checks the rule editor runs on OK, before anything is written to 
hop-lint.yml. */
+class RuleBuilderValidationTest {
+
+  /**
+   * An empty extra clause passed the editor, which only checked the clause on 
show, and the save
+   * then failed with a NullPointerException message.
+   */
+  @Test
+  void anEmptyClauseIsIncomplete() {
+    assertEquals(
+        "Field must be selected", RuleBuilderDialog.clauseProblem(new 
RuleClause("", null, "")));
+    assertEquals(
+        "Condition must be selected",
+        RuleBuilderDialog.clauseProblem(new RuleClause("name", null, "")));
+    assertEquals(
+        "Value is required for this condition",
+        RuleBuilderDialog.clauseProblem(new RuleClause("name", 
RuleCondition.MATCHES_PATTERN, "")));
+    assertNull(RuleBuilderDialog.clauseProblem(new RuleClause("name", 
RuleCondition.NOT_NULL, "")));
+  }
+
+  @Test
+  void aRuleIdMustBeUsableAndFree() {
+    Set<String> taken = Set.of("DOC-001");
+
+    assertNull(RuleBuilderDialog.ruleIdProblem("PROJECT-001", taken));
+    assertNull(RuleBuilderDialog.ruleIdProblem("naming.pipelines_2", taken));
+    assertNotNull(RuleBuilderDialog.ruleIdProblem("", taken));
+    assertNotNull(RuleBuilderDialog.ruleIdProblem("my rule", taken));
+    assertNotNull(RuleBuilderDialog.ruleIdProblem("-001", taken));
+    assertEquals(
+        "Another rule already has ID 'doc-001'",
+        RuleBuilderDialog.ruleIdProblem("doc-001", taken),
+        "ids are compared ignoring case, as hop-lint.yml matches them");
+  }
+}
diff --git 
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/registry/RuleRegistryTest.java
 
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/registry/RuleRegistryTest.java
index 45a27a37b0..673c7cf6ca 100644
--- 
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/registry/RuleRegistryTest.java
+++ 
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/registry/RuleRegistryTest.java
@@ -284,6 +284,130 @@ public class RuleRegistryTest {
     assertFalse(yaml.contains("type: custom"), "a retuned pack rule stays an 
override");
   }
 
+  /**
+   * A new name or description is not a new rule. Written out in full, the 
pack rule was replaced
+   * for good and stopped following the pack, with no warning in the editor.
+   */
+  @Test
+  public void renamingAPackRuleIsAnOverride() throws Exception {
+    CustomLintRule renamed = packRule("DOC-001");
+    String packName = renamed.getName();
+    renamed.setName("Pipelines need a description");
+    renamed.setDescription("Our own wording");
+
+    assertFalse(ProjectLintYamlExporter.redefinesPackRule(renamed));
+    String yaml = ProjectLintYamlExporter.export(List.of(renamed));
+    assertFalse(yaml.contains("type: custom"), "a renamed pack rule stays an 
override");
+    assertFalse(yaml.contains("targetField"), yaml);
+
+    File projectYaml = File.createTempFile("hop-lint", ".yml");
+    Files.writeString(projectYaml.toPath(), yaml);
+    try {
+      CustomLintRule loaded =
+          RuleRegistry.getInstance().resolve(projectYaml).getRules().stream()
+              .filter(rule -> "DOC-001".equals(rule.generateRuleId()))
+              .findFirst()
+              .orElseThrow();
+      assertEquals("Pipelines need a description", loaded.getName());
+      assertEquals("Our own wording", loaded.getDescription());
+      assertFalse(loaded.isProjectEditable(), "still the pack's rule, not " + 
packName + " copied");
+    } finally {
+      Files.deleteIfExists(projectYaml.toPath());
+    }
+  }
+
+  /**
+   * A pack reads a missing conditionValue as empty, and the editor saves none 
as null. OK on an
+   * unchanged rule must not count as a change.
+   */
+  @Test
+  public void anUnchangedPackRuleSavedFromTheEditorNeedsNoEntry() {
+    CustomLintRule unchanged = packRule("DOC-001");
+    unchanged.setConditionValue(null);
+
+    assertEquals(null, ProjectLintYamlExporter.entryFor(unchanged));
+    
assertFalse(ProjectLintYamlExporter.export(List.of(unchanged)).contains("DOC-001"));
+  }
+
+  /** Project rules replace a pack rule only under the very same id, as the 
registry stores them. */
+  @Test
+  public void aPackRuleIsFoundByItsExactId() {
+    assertTrue(ProjectLintYamlExporter.packRule("TRANS-002") != null);
+    assertEquals(null, ProjectLintYamlExporter.packRule("trans-002"));
+  }
+
+  @Test
+  public void changingWhatAPackRuleChecksRedefinesIt() {
+    CustomLintRule redefined = packRule("TRANS-002");
+    redefined.setTargetField("hasDisabledHops");
+
+    assertTrue(ProjectLintYamlExporter.redefinesPackRule(redefined));
+    assertTrue(ProjectLintYamlExporter.packRuleIds().contains("TRANS-002"));
+    assertEquals("TRANS-002", 
ProjectLintYamlExporter.packRule("TRANS-002").generateRuleId());
+  }
+
+  /**
+   * An override carries the first clause's value only, so a changed value in 
a later clause makes
+   * the rule a redefinition, written whole with every clause.
+   */
+  @Test
+  public void aChangedValueInALaterClauseRedefinesTheRule() throws Exception {
+    CustomLintRule edited = packRule("SQL-002");
+    edited.getAdditionalClauses().get(0).setConditionValue("^\\s*0\\s*$");
+
+    assertTrue(ProjectLintYamlExporter.redefinesPackRule(edited));
+    String yaml = ProjectLintYamlExporter.export(List.of(edited));
+    assertTrue(yaml.contains("type: custom"), yaml);
+
+    File projectYaml = File.createTempFile("hop-lint", ".yml");
+    Files.writeString(projectYaml.toPath(), yaml);
+    try {
+      CustomLintRule loaded =
+          RuleRegistry.getInstance().resolve(projectYaml).getRules().stream()
+              .filter(rule -> "SQL-002".equals(rule.generateRuleId()))
+              .findFirst()
+              .orElseThrow();
+      assertEquals(edited.getClauses(), loaded.getClauses(), "both patterns 
are written");
+    } finally {
+      Files.deleteIfExists(projectYaml.toPath());
+    }
+  }
+
+  @Test
+  public void aChangedValueInTheFirstClauseStaysAnOverride() {
+    CustomLintRule edited = packRule("SQL-002");
+    edited.setConditionValue("(?is).*\\bselect\\s+\\*\\s+from.*");
+
+    assertFalse(ProjectLintYamlExporter.redefinesPackRule(edited));
+    Map<String, Object> entry = ProjectLintYamlExporter.entryFor(edited);
+    assertEquals(Map.of("conditionValue", edited.getConditionValue()), entry);
+  }
+
+  /** {@code conditionValue: ''} was written for every rule whose condition 
takes no value. */
+  @Test
+  public void aRuleWithoutAValueIsWrittenWithoutConditionValue() {
+    CustomLintRule rule =
+        new CustomLintRule(
+            "Named",
+            org.apache.hop.lint.RuleTarget.PIPELINE,
+            "name",
+            org.apache.hop.lint.RuleCondition.NOT_NULL);
+    rule.setId("PROJECT-001");
+
+    Map<String, Object> entry = ProjectLintYamlExporter.entryFor(rule);
+
+    assertEquals("NOT_NULL", entry.get("condition"));
+    assertFalse(entry.containsKey("conditionValue"), entry.toString());
+  }
+
+  private static CustomLintRule packRule(String ruleId) {
+    return RuleRegistry.getInstance().resolve(null).getRules().stream()
+        .filter(rule -> ruleId.equals(rule.generateRuleId()))
+        .findFirst()
+        .orElseThrow()
+        .copy();
+  }
+
   /**
    * Pack rules are cached, so each caller has to get its own copies. Handing 
out the cached objects
    * would let one caller's edit — the rule manager toggling a rule, a project 
overlay disabling one

Reply via email to