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 2d0fb90192 Fixes #8734 : Save only the changed rule from the Lint
Rules Manager and let it edit every rule (#8738)
2d0fb90192 is described below
commit 2d0fb901920330a4a90470ad7ecab4d87b8ae861
Author: Bart Maertens <[email protected]>
AuthorDate: Sat Oct 3 10:33:59 2026 +0200
Fixes #8734 : Save only the changed rule from the Lint Rules Manager and
let it edit every rule (#8738)
---
.../modules/ROOT/pages/linting/lint-rules.adoc | 3 +-
.../org/apache/hop/lint/LintPolicyYamlWriter.java | 290 ++++++++++++++++++++-
.../org/apache/hop/lint/LinterConfigPlugin.java | 49 ++--
.../org/apache/hop/lint/RuleBuilderDialog.java | 133 ++++++----
.../org/apache/hop/lint/RuleManagerDialog.java | 77 ++++--
.../java/org/apache/hop/lint/RuleTargetFields.java | 51 +++-
.../hop/lint/registry/ProjectLintYamlExporter.java | 60 +++--
.../hop/lint/messages/messages_en_US.properties | 3 +
.../apache/hop/lint/LintRuleYamlWriterTest.java | 255 ++++++++++++++++++
.../hop/lint/RuleBuilderDialogNativeRuleTest.java | 69 +++++
.../org/apache/hop/lint/RuleEditorChoicesTest.java | 133 ++++++++++
11 files changed, 1010 insertions(+), 113 deletions(-)
diff --git a/docs/hop-user-manual/modules/ROOT/pages/linting/lint-rules.adoc
b/docs/hop-user-manual/modules/ROOT/pages/linting/lint-rules.adoc
index 523eb6366b..7486c4fc12 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
@@ -198,7 +198,7 @@ A rule with neither block behaves exactly as before, so
nothing that was written
[NOTE]
====
-The rule manager in Hop Gui edits one condition, so it shows a composed rule's
clauses but leaves them alone: name, severity and enabled can be changed there,
the clauses are edited in `hop-lint.yml`.
+The rule editor in Hop Gui lists a composed rule's clauses and edits them one
at a time; *Add clause* and *Remove clause* change the list.
====
== Narrowing a rule to one kind of transform
@@ -404,6 +404,7 @@ 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 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.
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 7aa8f69433..d5bbd45925 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
@@ -22,13 +22,17 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import org.apache.hop.core.util.Utils;
+import org.yaml.snakeyaml.DumperOptions;
import org.yaml.snakeyaml.Yaml;
/**
- * Adds an exclusion or a suppression to a project's {@code hop-lint.yml} from
the user interface.
+ * Adds an exclusion, a suppression or a rule to a project's {@code
hop-lint.yml} from the user
+ * interface.
*
* <p>The file is edited as text rather than parsed and rewritten. A project's
lint configuration is
* meant to be read and hand-edited — the documentation says as much — and
round-tripping it through
@@ -44,6 +48,8 @@ public final class LintPolicyYamlWriter {
private static final String EXCLUDE_KEY = "exclude";
private static final String SUPPRESS_KEY = "suppress";
+ private static final String RULES_KEY = "rules";
+ private static final int DEFAULT_RULE_INDENT = 2;
private LintPolicyYamlWriter() {}
@@ -214,6 +220,288 @@ public final class LintPolicyYamlWriter {
return removed;
}
+ /**
+ * Write one rule's entry under {@code rules:}, in place of the entry that
is there.
+ *
+ * <p>The rule manager used to write the whole file from its list of rules,
which deleted the
+ * {@code exclude} and {@code suppress} sections with their reasons, every
comment, and the order
+ * the rules were written in, whatever rule was changed. Only the lines of
this one rule are
+ * replaced now, and the save is refused unless everything else in the file
reads back the same.
+ *
+ * @param ruleId the rule's key under {@code rules:}, matched ignoring case
+ * @param entry the keys to write for it; null or empty removes the entry
+ */
+ public static void putRule(Path yamlFile, String ruleId, Map<String, Object>
entry)
+ 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);
+ if (updated.equals(original)) {
+ return;
+ }
+ if (yamlFile.getParent() != null) {
+ Files.createDirectories(yamlFile.getParent());
+ }
+ Files.writeString(yamlFile, updated, StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Remove one rule's entry from {@code rules:}, leaving the rest of the file
as it was.
+ *
+ * @return true when an entry was removed
+ */
+ public static boolean removeRule(Path yamlFile, String ruleId) throws
IOException {
+ if (!Files.exists(yamlFile) || isBlank(ruleId)) {
+ return false;
+ }
+ String original = Files.readString(yamlFile, StandardCharsets.UTF_8);
+ String updated = replaceRule(original, ruleId, null);
+ if (updated.equals(original)) {
+ return false;
+ }
+ Files.writeString(yamlFile, updated, StandardCharsets.UTF_8);
+ return true;
+ }
+
+ /** The file with this rule's entry replaced, added or, for a null or empty
entry, removed. */
+ static String replaceRule(String original, String ruleId, Map<String,
Object> entry)
+ throws IOException {
+ Map<?, ?> before = loadMapping(original);
+ boolean remove = entry == null || entry.isEmpty();
+
+ List<String> lines = new ArrayList<>(List.of(original.split("\n", -1)));
+ int keyLine = indexOfRulesKey(lines);
+
+ String updated;
+ if (keyLine < 0) {
+ if (remove) {
+ return original;
+ }
+ updated = insert(original, RULES_KEY, renderRule(ruleId, entry,
DEFAULT_RULE_INDENT));
+ } else {
+ int blockEnd = endOfBlock(lines, keyLine);
+ int indent = childIndent(lines, keyLine + 1, blockEnd);
+ int start = indexOfRule(lines, keyLine + 1, blockEnd, indent, ruleId);
+
+ List<String> rendered = remove ? List.of() : renderRule(ruleId, entry,
indent);
+ if (start < 0) {
+ if (remove) {
+ return original;
+ }
+ lines.addAll(blockEnd, rendered);
+ } else {
+ int end = endOfRule(lines, start, blockEnd, indent);
+ lines.subList(start, end).clear();
+ lines.addAll(start, rendered);
+ // A rules: key with nothing under it reads as an oversight, so the
last entry takes the
+ // key with it, as the last suppression does.
+ if (remove && !hasContent(lines, keyLine + 1, endOfBlock(lines,
keyLine))) {
+ lines.remove(keyLine);
+ }
+ }
+ updated = String.join("\n", lines);
+ }
+
+ verifyRuleEdit(before, loadMapping(updated), ruleId, remove ? null :
entry);
+ return updated;
+ }
+
+ /**
+ * The line holding {@code rules:}, or -1. An empty inline {@code rules: {}}
is turned into a
+ * block key so an entry can go under it; any other inline form is left for
the user to edit.
+ */
+ private static int indexOfRulesKey(List<String> lines) throws IOException {
+ for (int i = 0; i < lines.size(); i++) {
+ String line = lines.get(i);
+ if (!line.startsWith(RULES_KEY + ":")) {
+ continue;
+ }
+ String rest = stripComment(line.substring(RULES_KEY.length() +
1)).trim();
+ if (rest.isEmpty()) {
+ return i;
+ }
+ if (rest.equals("{}")) {
+ lines.set(i, RULES_KEY + ":");
+ return i;
+ }
+ throw new IOException("hop-lint.yml writes its rules inline; edit the
rule there by hand");
+ }
+ return -1;
+ }
+
+ /** The indentation of the rule keys under {@code rules:}, as the file
already uses it. */
+ private static int childIndent(List<String> lines, int from, int to) {
+ for (int i = from; i < to; i++) {
+ if (isContent(lines.get(i))) {
+ return indentOf(lines.get(i));
+ }
+ }
+ return DEFAULT_RULE_INDENT;
+ }
+
+ private static int indexOfRule(List<String> lines, int from, int to, int
indent, String ruleId) {
+ for (int i = from; i < to; i++) {
+ String line = lines.get(i);
+ if (!isContent(line) || indentOf(line) != indent) {
+ continue;
+ }
+ String trimmed = line.trim();
+ int colon = keyEnd(trimmed);
+ if (colon > 0 && ruleId.equalsIgnoreCase(unquoteKey(trimmed.substring(0,
colon)))) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * Where a rule's lines end: at the next key of the same depth. Blank lines
and comments just
+ * above that key describe it, not this rule, so they stay.
+ */
+ private static int endOfRule(List<String> lines, int start, int blockEnd,
int indent) {
+ int end = blockEnd;
+ for (int i = start + 1; i < blockEnd; i++) {
+ if (isContent(lines.get(i)) && indentOf(lines.get(i)) <= indent) {
+ end = i;
+ break;
+ }
+ }
+ while (end - 1 > start) {
+ String line = lines.get(end - 1);
+ if (line.isBlank() || (line.trim().startsWith("#") && indentOf(line) <=
indent)) {
+ end--;
+ } else {
+ break;
+ }
+ }
+ return end;
+ }
+
+ private static List<String> renderRule(String ruleId, Map<String, Object>
entry, int indent) {
+ DumperOptions options = new DumperOptions();
+ options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
+ options.setIndent(Math.max(2, Math.min(indent, 10)));
+ Map<String, Object> wrapper = new LinkedHashMap<>();
+ wrapper.put(ruleId, entry);
+ String prefix = " ".repeat(indent);
+ List<String> rendered = new ArrayList<>();
+ for (String line : new Yaml(options).dump(wrapper).split("\n")) {
+ if (!line.isEmpty()) {
+ rendered.add(prefix + line);
+ }
+ }
+ return rendered;
+ }
+
+ /**
+ * Refuse to save unless the rule reads back as written and nothing else in
the file changed. Text
+ * editing keeps the user's file intact; this makes sure it also kept it
correct.
+ */
+ private static void verifyRuleEdit(
+ Map<?, ?> before, Map<?, ?> after, String ruleId, Map<String, Object>
entry)
+ throws IOException {
+ for (Object key : union(before.keySet(), after.keySet())) {
+ if (!RULES_KEY.equals(key) && !Objects.equals(before.get(key),
after.get(key))) {
+ throw new IOException("Editing a rule would have changed '" + key + "'
in hop-lint.yml");
+ }
+ }
+ Map<?, ?> rulesBefore = asMap(before.get(RULES_KEY));
+ Map<?, ?> rulesAfter = asMap(after.get(RULES_KEY));
+ for (Object key : union(rulesBefore.keySet(), rulesAfter.keySet())) {
+ if (String.valueOf(key).equalsIgnoreCase(ruleId)) {
+ continue;
+ }
+ if (!Objects.equals(rulesBefore.get(key), rulesAfter.get(key))) {
+ throw new IOException(
+ "Editing rule " + ruleId + " would have changed rule " + key + "
in hop-lint.yml");
+ }
+ }
+ Object written = rulesAfter.get(ruleId);
+ Object expected = entry == null ? null : new Yaml().load(new
Yaml().dump(entry));
+ if (!Objects.equals(expected, written)) {
+ throw new IOException("Rule " + ruleId + " did not survive the edit,
change it by hand");
+ }
+ }
+
+ private static Map<?, ?> loadMapping(String text) throws IOException {
+ Object parsed;
+ try {
+ parsed = new Yaml().load(text);
+ } catch (Exception e) {
+ throw new IOException("hop-lint.yml cannot be read: " + e.getMessage(),
e);
+ }
+ if (parsed == null) {
+ return Map.of();
+ }
+ if (!(parsed instanceof Map)) {
+ throw new IOException("hop-lint.yml is not a YAML mapping, change the
rule by hand");
+ }
+ return (Map<?, ?>) parsed;
+ }
+
+ private static Map<?, ?> asMap(Object value) {
+ return value instanceof Map<?, ?> map ? map : Map.of();
+ }
+
+ private static List<Object> union(java.util.Collection<?> first,
java.util.Collection<?> second) {
+ List<Object> keys = new ArrayList<>(first);
+ for (Object key : second) {
+ if (!keys.contains(key)) {
+ keys.add(key);
+ }
+ }
+ return keys;
+ }
+
+ private static boolean hasContent(List<String> lines, int from, int to) {
+ for (int i = from; i < to; i++) {
+ if (isContent(lines.get(i))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean isContent(String line) {
+ String trimmed = line.trim();
+ return !trimmed.isEmpty() && !trimmed.startsWith("#");
+ }
+
+ private static int indentOf(String line) {
+ int indent = 0;
+ while (indent < line.length() && line.charAt(indent) == ' ') {
+ indent++;
+ }
+ return indent;
+ }
+
+ /** The position of the colon ending a mapping key, skipping one inside a
quoted key. */
+ private static int keyEnd(String trimmed) {
+ if (trimmed.startsWith("\"") || trimmed.startsWith("'")) {
+ int close = trimmed.indexOf(trimmed.charAt(0), 1);
+ return close < 0 ? -1 : trimmed.indexOf(':', close);
+ }
+ return trimmed.indexOf(':');
+ }
+
+ private static String unquoteKey(String key) {
+ String trimmed = key.trim();
+ if (trimmed.length() >= 2
+ && (trimmed.startsWith("\"") && trimmed.endsWith("\"")
+ || trimmed.startsWith("'") && trimmed.endsWith("'"))) {
+ return trimmed.substring(1, trimmed.length() - 1);
+ }
+ return trimmed;
+ }
+
+ private static String stripComment(String value) {
+ int hash = value.indexOf(" #");
+ return hash < 0 ? value : value.substring(0, hash);
+ }
+
/**
* Split a block into its list items, each starting at a line whose first
token is "-".
*
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 7c41c310ee..01b99b4928 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
@@ -17,6 +17,9 @@
package org.apache.hop.lint;
import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -406,9 +409,6 @@ public class LinterConfigPlugin implements IConfigOptions,
IGuiPluginCompositeWi
if (written.isEmpty()) {
return false;
}
- if (!Utils.isEmpty(configFilePath)) {
- saveConfiguration();
- }
log.logBasic("Linter configuration updated");
return true;
}
@@ -569,32 +569,29 @@ public class LinterConfigPlugin implements
IConfigOptions, IGuiPluginCompositeWi
return exportToYaml(getCustomRules());
}
- /** Save configuration to the specified file path */
- public boolean saveConfiguration() {
- return saveProjectRules(getCustomRules());
+ /**
+ * Write one rule's state to the project's hop-lint.yml, leaving every other
line of it alone.
+ *
+ * @param rule the rule as the rule manager now has it
+ * @param previousId the id it was saved under before, when that differs;
that entry is removed
+ * @throws IOException when the file cannot be changed safely; nothing is
written then
+ */
+ 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));
+ log.logBasic("Saved lint rule " + ruleId + " to " + path);
}
- /** Save project hop-lint.yml from the rule manager's desired effective
state. */
- public boolean saveProjectRules(List<CustomLintRule> desiredRules) {
- try {
- String yamlContent = exportToYaml(desiredRules);
- if (yamlContent == null) {
- return false;
- }
- String savePath = resolveProjectConfigPath();
- File parentDir = new File(savePath).getParentFile();
- if (parentDir != null && !parentDir.exists()) {
- parentDir.mkdirs();
- }
- java.nio.file.Files.write(
- java.nio.file.Paths.get(savePath),
- yamlContent.getBytes(java.nio.charset.StandardCharsets.UTF_8));
- log.logBasic("Linter configuration saved to: " + savePath);
- return true;
- } catch (Exception e) {
- log.logError("Error saving linter configuration: " + e.getMessage(), e);
+ /** Remove a project rule's entry from the project's hop-lint.yml. */
+ public void removeProjectRule(String ruleId) throws IOException {
+ Path path = Paths.get(resolveProjectConfigPath());
+ if (LintPolicyYamlWriter.removeRule(path, ruleId)) {
+ log.logBasic("Removed lint rule " + ruleId + " from " + path);
}
- return false;
}
private String resolveProjectConfigPath() {
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 b433039eb4..cb864aa625 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
@@ -28,6 +28,7 @@ import org.eclipse.swt.layout.FormData;
import org.eclipse.swt.layout.FormLayout;
import org.eclipse.swt.widgets.Button;
import org.eclipse.swt.widgets.Combo;
+import org.eclipse.swt.widgets.Control;
import org.eclipse.swt.widgets.Dialog;
import org.eclipse.swt.widgets.Display;
import org.eclipse.swt.widgets.Label;
@@ -60,6 +61,7 @@ public class RuleBuilderDialog extends Dialog {
private Button enabledCheck;
private Combo combinatorCombo;
private Table clauseTable;
+ private Button addClauseButton;
private Button removeClauseButton;
/**
@@ -67,6 +69,9 @@ public class RuleBuilderDialog extends Dialog {
*/
private final List<RuleClause> editingClauses = new ArrayList<>();
+ /** What the condition combo offers, in the order it shows them. */
+ private List<RuleCondition> conditionChoices = new ArrayList<>();
+
/** Guards the widget listeners while the widgets are being loaded from a
clause. */
private boolean loadingClause = false;
@@ -161,7 +166,7 @@ public class RuleBuilderDialog extends Dialog {
new SelectionAdapter() {
@Override
public void widgetSelected(SelectionEvent e) {
- updateFieldCombo();
+ updateFieldCombo(null);
}
});
FormData targetData = new FormData();
@@ -219,7 +224,7 @@ public class RuleBuilderDialog extends Dialog {
clauseTableData.height = 90;
clauseTable.setLayoutData(clauseTableData);
- Button addClauseButton = new Button(shell, SWT.PUSH);
+ addClauseButton = new Button(shell, SWT.PUSH);
addClauseButton.setText(BaseMessages.getString(PKG,
"RuleBuilderDialog.Button.AddClause"));
addClauseButton.addSelectionListener(
new SelectionAdapter() {
@@ -262,7 +267,7 @@ public class RuleBuilderDialog extends Dialog {
new SelectionAdapter() {
@Override
public void widgetSelected(SelectionEvent e) {
- updateConditionCombo();
+ updateConditionCombo(null);
captureWidgetsIntoSelectedClause();
}
});
@@ -323,8 +328,10 @@ public class RuleBuilderDialog extends Dialog {
severityLabel.setLayoutData(severityLabelData);
severityCombo = new Combo(shell, SWT.DROP_DOWN | SWT.READ_ONLY);
- severityCombo.setItems(new String[] {"ERROR", "WARNING"});
- severityCombo.select(1); // Default to WARNING
+ for (LintSeverity.Level level : LintSeverity.Level.values()) {
+ severityCombo.add(level.name());
+ }
+ severityCombo.select(LintSeverity.Level.WARNING.ordinal());
FormData severityData = new FormData();
severityData.left = new FormAttachment(severityLabel, margin);
severityData.right = new FormAttachment(100, -margin);
@@ -383,54 +390,89 @@ public class RuleBuilderDialog extends Dialog {
}
if (rule.getTarget() != null) {
targetCombo.select(rule.getTarget().ordinal());
- updateFieldCombo();
+ updateFieldCombo(null);
}
+ // Selected by index: setText does nothing on a read-only combo, which
left WARNING showing for
+ // an INFO rule and saved it as WARNING.
if (rule.getSeverity() != null) {
- severityCombo.setText(rule.getSeverity());
+ int severityIndex =
severityCombo.indexOf(rule.getSeverity().trim().toUpperCase());
+ if (severityIndex >= 0) {
+ severityCombo.select(severityIndex);
+ }
}
enabledCheck.setSelection(rule.isEnabled());
loadClausesFromRule();
+
+ // A native rule, such as HOP-CHECK, says how Hop's own verify remarks are
reported. It has no
+ // target, field or condition, and the editor refused to save it, even a
severity change.
+ if (rule.isNativeVerify()) {
+ for (Control control :
+ new Control[] {
+ targetCombo,
+ combinatorCombo,
+ clauseTable,
+ addClauseButton,
+ removeClauseButton,
+ fieldCombo,
+ conditionCombo,
+ valueText
+ }) {
+ control.setEnabled(false);
+ }
+ }
}
- private void updateFieldCombo() {
+ /**
+ * @param currentField the field the clause being shown reads, kept in the
list even when the
+ * editor would not have offered it
+ */
+ private void updateFieldCombo(String currentField) {
fieldCombo.removeAll();
conditionCombo.removeAll();
+ conditionChoices = new ArrayList<>();
int targetIndex = targetCombo.getSelectionIndex();
if (targetIndex >= 0) {
RuleTarget target = RuleTarget.values()[targetIndex];
- List<String> fields = RuleTargetFields.getFieldsForTarget(target);
- for (String field : fields) {
+ for (String field : RuleTargetFields.getFieldChoices(target,
currentField)) {
fieldCombo.add(field);
}
}
}
- private void updateConditionCombo() {
+ /**
+ * @param currentCondition the condition the clause being shown uses, kept
in the list even when
+ * the editor would not have offered it for this field
+ */
+ private void updateConditionCombo(RuleCondition currentCondition) {
conditionCombo.removeAll();
+ conditionChoices = new ArrayList<>();
String selectedField = fieldCombo.getText();
if (!Utils.isEmpty(selectedField)) {
- List<RuleCondition> conditions =
RuleTargetFields.getCompatibleConditions(selectedField);
- for (RuleCondition condition : conditions) {
+ conditionChoices = RuleTargetFields.getConditionChoices(selectedField,
currentCondition);
+ for (RuleCondition condition : conditionChoices) {
conditionCombo.add(condition.getDisplayName());
}
}
}
- private void updateValueField() {
+ private RuleCondition selectedCondition() {
int conditionIndex = conditionCombo.getSelectionIndex();
- if (conditionIndex >= 0) {
- String selectedField = fieldCombo.getText();
- List<RuleCondition> conditions =
RuleTargetFields.getCompatibleConditions(selectedField);
- if (conditionIndex < conditions.size()) {
- RuleCondition condition = conditions.get(conditionIndex);
- valueLabel.setVisible(condition.requiresValue());
- valueText.setVisible(condition.requiresValue());
-
- if (condition.requiresValue()) {
- valueText.setToolTipText(condition.getDescription());
- }
+ if (conditionIndex < 0 || conditionIndex >= conditionChoices.size()) {
+ return null;
+ }
+ return conditionChoices.get(conditionIndex);
+ }
+
+ private void updateValueField() {
+ RuleCondition condition = selectedCondition();
+ if (condition != null) {
+ valueLabel.setVisible(condition.requiresValue());
+ valueText.setVisible(condition.requiresValue());
+
+ if (condition.requiresValue()) {
+ valueText.setToolTipText(condition.getDescription());
}
}
shell.layout(true, true);
@@ -449,6 +491,9 @@ public class RuleBuilderDialog extends Dialog {
showError("Rule name is required");
return false;
}
+ if (rule.isNativeVerify()) {
+ return true;
+ }
if (targetCombo.getSelectionIndex() < 0) {
showError("Target type must be selected");
return false;
@@ -463,27 +508,25 @@ public class RuleBuilderDialog extends Dialog {
}
// Check if condition requires a value
- String selectedField = fieldCombo.getText();
- List<RuleCondition> conditions =
RuleTargetFields.getCompatibleConditions(selectedField);
- int conditionIndex = conditionCombo.getSelectionIndex();
- if (conditionIndex < conditions.size()) {
- RuleCondition condition = conditions.get(conditionIndex);
- if (condition.requiresValue() && Utils.isEmpty(valueText.getText())) {
- showError("Value is required for this condition");
- return false;
- }
+ RuleCondition condition = selectedCondition();
+ if (condition != null && condition.requiresValue() &&
Utils.isEmpty(valueText.getText())) {
+ showError("Value is required for this condition");
+ return false;
}
return true;
}
private void saveRule() {
- captureWidgetsIntoSelectedClause();
-
rule.setName(nameText.getText());
rule.setDescription(descriptionText.getText());
rule.setSeverity(severityCombo.getText());
rule.setEnabled(enabledCheck.getSelection());
+ if (rule.isNativeVerify()) {
+ return;
+ }
+
+ captureWidgetsIntoSelectedClause();
rule.setTarget(RuleTarget.values()[targetCombo.getSelectionIndex()]);
rule.setCombinator(
combinatorCombo.getSelectionIndex() == 1 ? RuleCombinator.ANY_OF :
RuleCombinator.ALL_OF);
@@ -543,21 +586,16 @@ public class RuleBuilderDialog extends Dialog {
RuleClause clause = editingClauses.get(index);
loadingClause = true;
try {
- updateFieldCombo();
+ updateFieldCombo(clause.getTargetField());
if (clause.getTargetField() != null) {
int fieldIndex = fieldCombo.indexOf(clause.getTargetField());
if (fieldIndex >= 0) {
fieldCombo.select(fieldIndex);
}
}
- updateConditionCombo();
+ updateConditionCombo(clause.getCondition());
if (clause.getCondition() != null) {
- for (int i = 0; i < conditionCombo.getItemCount(); i++) {
- if
(conditionCombo.getItem(i).equals(clause.getCondition().getDisplayName())) {
- conditionCombo.select(i);
- break;
- }
- }
+ conditionCombo.select(conditionChoices.indexOf(clause.getCondition()));
}
updateValueField();
valueText.setText(clause.getConditionValue() == null ? "" :
clause.getConditionValue());
@@ -577,10 +615,9 @@ public class RuleBuilderDialog extends Dialog {
}
RuleClause clause = editingClauses.get(index);
clause.setTargetField(fieldCombo.getText());
- List<RuleCondition> conditions =
RuleTargetFields.getCompatibleConditions(fieldCombo.getText());
- int conditionIndex = conditionCombo.getSelectionIndex();
- if (conditionIndex >= 0 && conditionIndex < conditions.size()) {
- clause.setCondition(conditions.get(conditionIndex));
+ RuleCondition condition = selectedCondition();
+ if (condition != null) {
+ clause.setCondition(condition);
}
clause.setConditionValue(valueText.getText());
refreshClauseTable();
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 8855c6788b..101b8a3b59 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
@@ -74,7 +74,7 @@ public class RuleManagerDialog extends Dialog {
// at the default size. The table scrolls horizontally if the user makes
it narrower.
shell.setSize(1220, 620);
shell.setLocation(
- parent.getLocation().x + (parent.getSize().x - 900) / 2,
+ parent.getLocation().x + (parent.getSize().x - 1220) / 2,
parent.getLocation().y + (parent.getSize().y - 620) / 2);
shell.open();
@@ -133,9 +133,15 @@ public class RuleManagerDialog extends Dialog {
}
});
+ // Findings, hop-lint.yml and the CLI all name a rule by its id, and two
rules can share the
+ // start of a long name, so the id comes first.
+ TableColumn idColumn = new TableColumn(rulesTable, SWT.LEFT);
+ idColumn.setText(BaseMessages.getString(PKG,
"RuleManagerDialog.Column.RuleId"));
+ idColumn.setWidth(110);
+
TableColumn nameColumn = new TableColumn(rulesTable, SWT.LEFT);
nameColumn.setText(BaseMessages.getString(PKG,
"RuleManagerDialog.Column.RuleName"));
- nameColumn.setWidth(180);
+ nameColumn.setWidth(260);
TableColumn sourceColumn = new TableColumn(rulesTable, SWT.LEFT);
sourceColumn.setText(BaseMessages.getString(PKG,
"RuleManagerDialog.Column.Source"));
@@ -277,24 +283,25 @@ public class RuleManagerDialog extends Dialog {
for (CustomLintRule rule : rules) {
TableItem item = new TableItem(rulesTable, SWT.NONE);
- item.setText(0, rule.getName() != null ? rule.getName() : "");
- item.setText(1, rule.getPackOwner() != null ?
rule.getPackOwner().getDisplayName() : "");
- item.setText(2, rule.getTarget() != null ?
rule.getTarget().getDisplayName() : "");
- item.setText(3, rule.getTargetField() != null ? rule.getTargetField() :
"");
+ 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(
- 3, rule.getClauses().size() + " fields (" +
rule.getCombinator().getYamlKey() + ")");
- item.setText(4, rule.getCombinator() == RuleCombinator.ALL_OF ? "All
of" : "Any of");
- item.setText(5, "");
+ 4, rule.getClauses().size() + " fields (" +
rule.getCombinator().getYamlKey() + ")");
+ item.setText(5, rule.getCombinator() == RuleCombinator.ALL_OF ? "All
of" : "Any of");
+ item.setText(6, "");
} else {
- item.setText(4, rule.getCondition() != null ?
rule.getCondition().getDisplayName() : "");
- item.setText(5, rule.getConditionValue() != null ?
rule.getConditionValue() : "");
+ item.setText(5, rule.getCondition() != null ?
rule.getCondition().getDisplayName() : "");
+ item.setText(6, rule.getConditionValue() != null ?
rule.getConditionValue() : "");
}
- item.setText(6, rule.getSeverity() != null ? rule.getSeverity() :
"WARNING");
- item.setText(7, rule.isEnabled() ? "✓" : "✗");
+ item.setText(7, rule.getSeverity() != null ? rule.getSeverity() :
"WARNING");
+ item.setText(8, rule.isEnabled() ? "✓" : "✗");
item.setData(rule);
}
}
@@ -325,9 +332,12 @@ public class RuleManagerDialog extends Dialog {
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());
rules.add(newRule);
populateTable();
- saveConfiguration();
+ saveRule(newRule, null);
log.logBasic("Added project rule: " + newRule.getName());
}
}
@@ -342,11 +352,12 @@ 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.
+ String previousId = rule.generateRuleId();
RuleBuilderDialog dialog = new RuleBuilderDialog(shell, rule);
CustomLintRule editedRule = dialog.open();
if (editedRule != null) {
populateTable();
- saveConfiguration();
+ saveRule(editedRule, previousId);
log.logBasic("Edited rule: " + rule.getName());
}
}
@@ -372,7 +383,11 @@ public class RuleManagerDialog extends Dialog {
rules.remove(rule);
populateTable();
updateButtonState();
- saveConfiguration();
+ try {
+
LinterConfigPlugin.getInstance().removeProjectRule(rule.generateRuleId());
+ } catch (Exception e) {
+ showSaveError(rule, e);
+ }
log.logBasic("Deleted project rule: " + rule.getName());
}
}
@@ -388,7 +403,7 @@ public class RuleManagerDialog extends Dialog {
populateTable();
rulesTable.setSelection(findTableIndex(rule));
updateButtonState();
- saveConfiguration();
+ saveRule(rule, null);
log.logBasic(
"Toggled rule: " + rule.getName() + " to " + (rule.isEnabled() ?
"enabled" : "disabled"));
}
@@ -423,12 +438,32 @@ public class RuleManagerDialog extends Dialog {
messageBox.open();
}
- private void saveConfiguration() {
+ /**
+ * 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) {
try {
- LinterConfigPlugin configPlugin = LinterConfigPlugin.getInstance();
- configPlugin.saveProjectRules(new java.util.ArrayList<>(rules));
+ LinterConfigPlugin.getInstance().saveProjectRule(rule, previousId);
} catch (Exception e) {
- log.logError("Error saving custom rules configuration: " +
e.getMessage(), e);
+ showSaveError(rule, e);
}
}
+
+ /** A save that failed used to be logged only, so the change looked saved
and was not. */
+ private void showSaveError(CustomLintRule rule, Exception e) {
+ log.logError("Error saving lint rule " + rule.generateRuleId() + ": " +
e.getMessage(), e);
+ MessageBox messageBox = new MessageBox(shell, SWT.ICON_ERROR | SWT.OK);
+ messageBox.setText(BaseMessages.getString(PKG,
"RuleManagerDialog.Dialog.SaveFailed.Title"));
+ messageBox.setMessage(
+ BaseMessages.getString(
+ PKG, "RuleManagerDialog.Dialog.SaveFailed.Message",
rule.generateRuleId())
+ + "\n\n"
+ + e.getMessage());
+ messageBox.open();
+ }
+
+ private static String nullToEmpty(String value) {
+ return value == null ? "" : value;
+ }
}
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/RuleTargetFields.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/RuleTargetFields.java
index 2b2df4c07d..4c7185f444 100644
--- a/plugins/misc/lint/src/main/java/org/apache/hop/lint/RuleTargetFields.java
+++ b/plugins/misc/lint/src/main/java/org/apache/hop/lint/RuleTargetFields.java
@@ -16,6 +16,7 @@
*/
package org.apache.hop.lint;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
@@ -109,6 +110,8 @@ public class RuleTargetFields {
"targetTransforms",
"isStart",
"isDummy",
+ "isOrphaned",
+ "isBlockingTransform",
"hasDefaultName",
"password",
"secret",
@@ -128,6 +131,7 @@ public class RuleTargetFields {
"errorHandling",
"targetActions",
"isStart",
+ "isOrphaned",
"hasDefaultName",
"password",
"secret",
@@ -199,7 +203,7 @@ public class RuleTargetFields {
// Numeric fields
return Arrays.asList(
RuleCondition.MAX_VALUE, RuleCondition.MIN_VALUE,
RuleCondition.EXACT_VALUE);
- } else if (field.startsWith("has")
+ } else if (isFlagName(field)
|| field.equals("enabled")
|| field.equals("distributes")
|| field.equals("unconditional")
@@ -230,4 +234,49 @@ public class RuleTargetFields {
RuleCondition.ENDS_WITH);
}
}
+
+ /**
+ * {@code hasNotes}, {@code isDummy}: a yes-or-no question, but not {@code
issuer} or {@code
+ * hash}.
+ */
+ private static boolean isFlagName(String field) {
+ for (String prefix : new String[] {"has", "is"}) {
+ if (field.length() > prefix.length()
+ && field.startsWith(prefix)
+ && Character.isUpperCase(field.charAt(prefix.length()))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * The fields the rule editor offers, with the one the rule already reads
among them.
+ *
+ * <p>A rule may read a field the list does not know: a transform's own
setting such as a Table
+ * Input's {@code sql}, or any field of a metadata object. Leaving it out
opened such a rule with
+ * no field selected, and the editor then refused to save it at all, even
for a severity change.
+ */
+ public static List<String> getFieldChoices(RuleTarget target, String
currentField) {
+ List<String> fields = new ArrayList<>(getFieldsForTarget(target));
+ if (currentField != null && !currentField.isEmpty() &&
!fields.contains(currentField)) {
+ fields.add(currentField);
+ }
+ return fields;
+ }
+
+ /**
+ * The conditions the rule editor offers for a field, with the one the rule
already uses among
+ * them, for the same reason as {@link #getFieldChoices}: a rule from a pack
is not wrong just
+ * because the editor would not have suggested its condition.
+ */
+ public static List<RuleCondition> getConditionChoices(
+ String field, RuleCondition currentCondition) {
+ List<RuleCondition> conditions =
+ new ArrayList<>(getCompatibleConditions(field == null ? "" : field));
+ if (currentCondition != null && !conditions.contains(currentCondition)) {
+ conditions.add(currentCondition);
+ }
+ return conditions;
+ }
}
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 3bbd481f93..443adcb106 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
@@ -34,23 +34,13 @@ public final class ProjectLintYamlExporter {
public static String export(List<CustomLintRule> desiredRules) {
try {
- Map<String, CustomLintRule> packDefaults = new LinkedHashMap<>();
- for (CustomLintRule rule :
RuleRegistry.getInstance().resolve(null).getRules()) {
- packDefaults.put(rule.generateRuleId(), rule);
- }
+ Map<String, CustomLintRule> packDefaults = packDefaults();
Map<String, Object> rulesMap = new LinkedHashMap<>();
for (CustomLintRule desired : desiredRules) {
- String ruleId = desired.generateRuleId();
- CustomLintRule packDefault = packDefaults.get(ruleId);
- if (desired.getPackOwner() == RulePackOwner.PROJECT || packDefault ==
null) {
- rulesMap.put(ruleId, toFullCustomRuleMap(desired));
- } else if (structurallyDiffersFromPackDefault(desired, packDefault)) {
- // The project has redefined the rule rather than tuned it. Written
in full, it replaces
- // the pack rule of that id instead of layering on top of it.
- rulesMap.put(ruleId, toFullCustomRuleMap(desired));
- } else if (differsFromPackDefault(desired, packDefault)) {
- rulesMap.put(ruleId, toOverrideMap(desired, packDefault));
+ Map<String, Object> entry = entryFor(desired, packDefaults);
+ if (entry != null) {
+ rulesMap.put(desired.generateRuleId(), entry);
}
}
@@ -66,6 +56,43 @@ public final class ProjectLintYamlExporter {
}
}
+ /**
+ * What the project's {@code hop-lint.yml} has to say about one rule, so the
rule manager can
+ * write that rule alone and leave the rest of the file as the user wrote it.
+ *
+ * @return the keys to write under the rule's id, or null when the rule is
the pack's own and the
+ * project need say nothing about it
+ */
+ public static Map<String, Object> entryFor(CustomLintRule desired) {
+ return entryFor(desired, packDefaults());
+ }
+
+ private static Map<String, Object> entryFor(
+ CustomLintRule desired, Map<String, CustomLintRule> packDefaults) {
+ CustomLintRule packDefault = packDefaults.get(desired.generateRuleId());
+ if (desired.getPackOwner() == RulePackOwner.PROJECT || packDefault ==
null) {
+ return toFullCustomRuleMap(desired);
+ }
+ if (structurallyDiffersFromPackDefault(desired, packDefault)) {
+ // The project has redefined the rule rather than tuned it. Written in
full, it replaces the
+ // pack rule of that id instead of layering on top of it.
+ return toFullCustomRuleMap(desired);
+ }
+ if (differsFromPackDefault(desired, packDefault)) {
+ return toOverrideMap(desired, packDefault);
+ }
+ return null;
+ }
+
+ /** The rules as the packs define them, before any project changes them. */
+ private static Map<String, CustomLintRule> packDefaults() {
+ Map<String, CustomLintRule> packDefaults = new LinkedHashMap<>();
+ for (CustomLintRule rule :
RuleRegistry.getInstance().resolve(null).getRules()) {
+ packDefaults.put(rule.generateRuleId(), rule);
+ }
+ return packDefaults;
+ }
+
private static boolean differsFromPackDefault(
CustomLintRule desired, CustomLintRule packDefault) {
return desired.isEnabled() != packDefault.isEnabled()
@@ -165,7 +192,10 @@ public final class ProjectLintYamlExporter {
// Omitted when empty so an unrestricted rule round-trips to the same
YAML it came from.
ruleConfig.put("appliesTo", new ArrayList<>(rule.getAppliesTo()));
}
- ruleConfig.put("parameters", new
HashMap<>(rule.getAdditionalParameters()));
+ if (!rule.getAdditionalParameters().isEmpty()) {
+ // Omitted when empty, like appliesTo: "parameters: {}" on every rule
was noise in the diff.
+ ruleConfig.put("parameters", new
HashMap<>(rule.getAdditionalParameters()));
+ }
return ruleConfig;
}
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 4b38845164..a1b319030a 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
@@ -74,6 +74,7 @@ RuleBuilderDialog.Dialog.ValidationError.Title=Validation
Error
# Rule manager dialog
# ---------------------------------------------------------------------------
RuleManagerDialog.Title.EffectiveRules=Lint Rules ({0} effective rules)
+RuleManagerDialog.Column.RuleId=Rule ID
RuleManagerDialog.Column.RuleName=Rule Name
RuleManagerDialog.Column.Source=Source
RuleManagerDialog.Column.Target=Target
@@ -90,6 +91,8 @@ RuleManagerDialog.Button.Close=Close
RuleManagerDialog.Dialog.ConfirmDelete.Title=Confirm Delete
RuleManagerDialog.Dialog.ConfirmDelete.Message=Delete project rule ''{0}''?
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.
# ---------------------------------------------------------------------------
# Progress dialog
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
new file mode 100644
index 0000000000..ca4fd65c91
--- /dev/null
+++
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintRuleYamlWriterTest.java
@@ -0,0 +1,255 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.lint;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.lint.registry.YamlRulePackParser;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Saving one rule from the rule manager.
+ *
+ * <p>The rule manager used to write the whole of hop-lint.yml from its list
of rules. Toggling one
+ * rule deleted the exclude and suppress sections with their reasons, every
comment and the rule
+ * order, so excluded files were linted again and accepted findings came back
without a word.
+ *
+ * @see <a href="https://github.com/apache/hop/issues/8734">#8734</a>
+ */
+public class LintRuleYamlWriterTest {
+
+ private static final String PROJECT_FILE =
+ """
+ # Lint policy for the CRM project, reviewed 2026-09-14
+ pack:
+ id: crm
+
+ rules:
+ # Too noisy on the generated pipelines
+ DOC-001:
+ enabled: false
+
+ SEC-002:
+ severity: WARNING # until the vault migration is done
+
+ CRM-001:
+ type: custom
+ enabled: true
+ severity: ERROR
+ target: PIPELINE
+ targetField: name
+ condition: MATCHES_PATTERN
+ conditionValue: '^crm_.*'
+ name: CRM pipeline names
+ description: Pipelines are named crm_<subject>
+
+ exclude:
+ # Metadata injection templates
+ - "templates/**"
+
+ suppress:
+ - rule: "SEC-002"
+ path: "load/http.hpl"
+ source: "HTTP client"
+ reason: "Test endpoint without credentials"
+ """;
+
+ @TempDir private Path dir;
+
+ private Path yaml() {
+ return dir.resolve("hop-lint.yml");
+ }
+
+ private static Map<String, Object> entry(Object... keysAndValues) {
+ Map<String, Object> entry = new LinkedHashMap<>();
+ for (int i = 0; i < keysAndValues.length; i += 2) {
+ entry.put((String) keysAndValues[i], keysAndValues[i + 1]);
+ }
+ return entry;
+ }
+
+ @Test
+ public void changingOneRuleLeavesEveryOtherLineAlone() throws Exception {
+ Files.writeString(yaml(), PROJECT_FILE, StandardCharsets.UTF_8);
+
+ LintPolicyYamlWriter.putRule(yaml(), "DOC-001", entry("enabled", true));
+
+ String after = Files.readString(yaml(), StandardCharsets.UTF_8);
+ assertEquals(
+ PROJECT_FILE.replace("DOC-001:\n enabled: false", "DOC-001:\n
enabled: true"), after);
+
+ LintPolicy policy =
YamlRulePackParser.parseProjectYaml(yaml().toFile()).getPolicy();
+ assertEquals(List.of("templates/**"), policy.getExcludes());
+ assertEquals(1, policy.getSuppressions().size());
+ }
+
+ @Test
+ public void aRuleWithAnInlineCommentIsReplacedWhole() throws Exception {
+ String after =
+ LintPolicyYamlWriter.replaceRule(PROJECT_FILE, "SEC-002",
entry("severity", "INFO"));
+
+ assertEquals(
+ PROJECT_FILE.replace(
+ "severity: WARNING # until the vault migration is done",
"severity: INFO"),
+ after);
+ }
+
+ @Test
+ public void theCommentAboveTheNextRuleStaysWithIt() throws Exception {
+ String file =
+ """
+ rules:
+ DOC-001:
+ enabled: false
+ # Too noisy on the generated pipelines
+ DOC-002:
+ enabled: false
+ """;
+
+ String after = LintPolicyYamlWriter.replaceRule(file, "DOC-001", null);
+
+ assertEquals(
+ """
+ rules:
+ # Too noisy on the generated pipelines
+ DOC-002:
+ enabled: false
+ """,
+ after);
+ }
+
+ @Test
+ public void aNewRuleIsAddedAtTheEndOfTheRules() throws Exception {
+ String after =
+ LintPolicyYamlWriter.replaceRule(PROJECT_FILE, "STRUCT-001",
entry("conditionValue", "40"));
+
+ assertTrue(
+ after.contains(" description: Pipelines are named crm_<subject>\n
STRUCT-001:\n"),
+ after);
+ assertTrue(after.contains(" STRUCT-001:\n conditionValue: '40'\n"),
after);
+ }
+
+ @Test
+ public void aComposedRuleIsWrittenAsBlockYaml() throws Exception {
+ Map<String, Object> composed =
+ entry(
+ "type",
+ "custom",
+ "target",
+ "TRANSFORM",
+ "allOf",
+ List.of(
+ entry("targetField", "sql", "condition",
"NOT_MATCHES_PATTERN"),
+ entry("targetField", "limit", "condition", "NOT_EMPTY")));
+
+ String after = LintPolicyYamlWriter.replaceRule(PROJECT_FILE, "SQL-002",
composed);
+
+ Map<?, ?> rules =
+ (Map<?, ?>) ((Map<?, ?>) new
org.yaml.snakeyaml.Yaml().load(after)).get("rules");
+ assertEquals(composed, rules.get("SQL-002"));
+ assertFalse(after.contains("{"), "flow style in a hand-edited file: " +
after);
+ }
+
+ @Test
+ public void theFileKeepsItsOwnIndentation() throws Exception {
+ String file =
+ """
+ rules:
+ DOC-001:
+ enabled: false
+ """;
+
+ String after = LintPolicyYamlWriter.replaceRule(file, "DOC-002",
entry("enabled", false));
+
+ assertTrue(after.contains("\n DOC-002:\n enabled: false"),
after);
+ }
+
+ @Test
+ public void aQuotedOrDifferentlyCasedKeyIsTheSameRule() throws Exception {
+ String file = """
+ rules:
+ 'doc-001':
+ enabled: false
+ """;
+
+ String after = LintPolicyYamlWriter.replaceRule(file, "DOC-001",
entry("enabled", true));
+
+ assertEquals("rules:\n DOC-001:\n enabled: true\n", after);
+ }
+
+ @Test
+ public void removingTheLastRuleRemovesTheRulesKey() throws Exception {
+ String file =
+ """
+ rules:
+ DOC-001:
+ enabled: false
+
+ exclude:
+ - "templates/**"
+ """;
+
+ String after = LintPolicyYamlWriter.replaceRule(file, "DOC-001", null);
+
+ assertEquals("\nexclude:\n - \"templates/**\"\n", after);
+ }
+
+ @Test
+ public void aFileWithoutRulesGetsTheBlockAppended() throws Exception {
+ String file = "exclude:\n - \"templates/**\"\n";
+
+ String after = LintPolicyYamlWriter.replaceRule(file, "DOC-001",
entry("enabled", false));
+
+ assertEquals(file + "\nrules:\n DOC-001:\n enabled: false\n", after);
+ }
+
+ @Test
+ public void anEmptyInlineRulesMappingIsOpenedUp() throws Exception {
+ String after =
+ LintPolicyYamlWriter.replaceRule("rules: {}\n", "DOC-001",
entry("enabled", false));
+
+ assertEquals("rules:\n DOC-001:\n enabled: false\n", after);
+ }
+
+ @Test
+ public void inlineRulesAreLeftToTheUser() {
+ assertThrows(
+ IOException.class,
+ () ->
+ LintPolicyYamlWriter.replaceRule(
+ "rules: {DOC-001: {enabled: false}}\n", "DOC-002",
entry("enabled", false)));
+ }
+
+ @Test
+ public void removingARuleThatIsNotThereChangesNothing() throws Exception {
+ Files.writeString(yaml(), PROJECT_FILE, StandardCharsets.UTF_8);
+
+ assertFalse(LintPolicyYamlWriter.removeRule(yaml(), "NAMING-001"));
+ assertEquals(PROJECT_FILE, Files.readString(yaml(),
StandardCharsets.UTF_8));
+ }
+}
diff --git
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/RuleBuilderDialogNativeRuleTest.java
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/RuleBuilderDialogNativeRuleTest.java
new file mode 100644
index 0000000000..d609256ea6
--- /dev/null
+++
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/RuleBuilderDialogNativeRuleTest.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.lint;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.hop.lint.registry.RuleRegistry;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.eclipse.swtbot.swt.finder.SWTBot;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Editing a native rule, which says how Hop's own verify remarks are reported.
+ *
+ * <p>HOP-CHECK has no target, field or condition, so the editor opened it
with no target selected
+ * and refused every save with "Target type must be selected", even a change
of severity.
+ *
+ * @see <a href="https://github.com/apache/hop/issues/8734">#8734</a>
+ */
+@Tag("uitest")
+class RuleBuilderDialogNativeRuleTest extends SwtBotTestBase {
+
+ @Test
+ void theSeverityOfHopCheckCanBeChanged() {
+ CustomLintRule hopCheck =
+ RuleRegistry.getInstance().resolve(null).getRules().stream()
+ .filter(rule -> "HOP-CHECK".equals(rule.generateRuleId()))
+ .findFirst()
+ .orElseThrow()
+ .copy();
+ AtomicReference<CustomLintRule> saved = new AtomicReference<>();
+ AtomicReference<Boolean> targetEnabled = new AtomicReference<>();
+
+ withDialog(
+ parent -> saved.set(new RuleBuilderDialog(parent, hopCheck).open()),
+ bot -> {
+ SWTBot dialog = bot.shell("Edit Lint Rule").bot();
+ targetEnabled.set(dialog.comboBoxWithLabel("Target
Type:").isEnabled());
+ dialog.comboBoxWithLabel("Severity:").setSelection("ERROR");
+ dialog.button("OK").click();
+ });
+
+ assertFalse(targetEnabled.get(), "a native rule has no target to choose");
+ assertNotNull(saved.get(), "the dialog refused to save");
+ assertEquals("ERROR", saved.get().getSeverity());
+ assertTrue(saved.get().isNativeVerify(), "still a native rule");
+ assertNull(saved.get().getTarget(), "and still without a target");
+ }
+}
diff --git
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/RuleEditorChoicesTest.java
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/RuleEditorChoicesTest.java
new file mode 100644
index 0000000000..bcb368798c
--- /dev/null
+++
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/RuleEditorChoicesTest.java
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.lint;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.lint.registry.HopCoreRulePack;
+import org.apache.hop.lint.registry.ProjectLintYamlExporter;
+import org.apache.hop.lint.registry.RuleRegistry;
+import org.junit.jupiter.api.Test;
+
+/**
+ * What the rule editor offers, and what the rule manager writes for one rule.
+ *
+ * <p>The editor guessed a field's type from its name and only offered the
matching conditions, so a
+ * rule on {@code isDummy}, {@code isReferenced} or {@code isOrphaned} opened
with an empty field or
+ * condition and could not be saved at all, not even to change its severity.
+ *
+ * @see <a href="https://github.com/apache/hop/issues/8734">#8734</a>
+ */
+public class RuleEditorChoicesTest {
+
+ /** Every rule the core pack ships opens in the editor with its own field
and condition. */
+ @Test
+ public void everyCoreRuleFitsTheEditor() {
+ List<String> problems = new ArrayList<>();
+ for (CustomLintRule rule : new HopCoreRulePack().loadRules()) {
+ if (rule.isNativeVerify()) {
+ continue;
+ }
+ for (RuleClause clause : rule.getClauses()) {
+ String where = rule.generateRuleId() + " " + clause.getTargetField();
+ // A rule scoped to plugin types reads that plugin's own settings,
which no fixed list
+ // can name; the editor keeps such a field rather than offering it.
+ if (rule.getAppliesTo().isEmpty()
+ && !RuleTargetFields.getFieldsForTarget(rule.getTarget())
+ .contains(clause.getTargetField())) {
+ problems.add(where + ": field not offered for " + rule.getTarget());
+ }
+ if (!RuleTargetFields.getCompatibleConditions(clause.getTargetField())
+ .contains(clause.getCondition())) {
+ problems.add(where + ": " + clause.getCondition() + " not offered");
+ }
+ }
+ }
+ assertTrue(problems.isEmpty(), String.join("\n", problems));
+ }
+
+ @Test
+ public void isAndHasFieldsAreFlags() {
+ for (String field : List.of("isDummy", "isReferenced", "isOrphaned",
"hasNotes")) {
+ assertEquals(
+ List.of(RuleCondition.MUST_BE_TRUE, RuleCondition.MUST_BE_FALSE),
+ RuleTargetFields.getCompatibleConditions(field),
+ field);
+ }
+ for (String field : List.of("issuer", "hash", "isbn")) {
+ assertFalse(
+
RuleTargetFields.getCompatibleConditions(field).contains(RuleCondition.MUST_BE_TRUE),
+ field);
+ }
+ }
+
+ /** A Putki rule reads a Table Output's own truncateTable setting, and
PUTKI-ENV-006 a port. */
+ @Test
+ public void aFieldOrConditionTheEditorWouldNotSuggestIsKept() {
+ List<String> fields =
RuleTargetFields.getFieldChoices(RuleTarget.TRANSFORM, "truncateTable");
+ assertTrue(fields.contains("truncateTable"));
+
assertTrue(fields.containsAll(RuleTargetFields.getFieldsForTarget(RuleTarget.TRANSFORM)));
+
+ assertTrue(
+ RuleTargetFields.getConditionChoices("port",
RuleCondition.NO_HARDCODED)
+ .contains(RuleCondition.NO_HARDCODED));
+ assertEquals(
+ RuleTargetFields.getCompatibleConditions("port"),
+ RuleTargetFields.getConditionChoices("port", null));
+ }
+
+ @Test
+ public void anUnchangedPackRuleNeedsNoEntry() {
+ CustomLintRule rule = coreRule("DOC-001");
+
+ assertNull(ProjectLintYamlExporter.entryFor(rule));
+ }
+
+ @Test
+ public void aTunedPackRuleWritesOnlyWhatChanged() {
+ CustomLintRule rule = coreRule("DOC-001");
+ rule.setSeverity("INFO");
+
+ assertEquals(Map.of("severity", "INFO"),
ProjectLintYamlExporter.entryFor(rule));
+ }
+
+ @Test
+ public void aProjectRuleWithoutParametersWritesNone() {
+ CustomLintRule rule = coreRule("DOC-001");
+ rule.setId("CRM-001");
+ rule.setPackOwner(org.apache.hop.lint.registry.RulePackOwner.PROJECT);
+
+ Map<String, Object> entry = ProjectLintYamlExporter.entryFor(rule);
+
+ assertEquals("custom", entry.get("type"));
+ assertFalse(entry.containsKey("parameters"), entry.toString());
+ }
+
+ private static CustomLintRule coreRule(String id) {
+ return RuleRegistry.getInstance().resolve(null).getRules().stream()
+ .filter(rule -> id.equals(rule.generateRuleId()))
+ .findFirst()
+ .orElseThrow()
+ .copy();
+ }
+}