This is an automated email from the ASF dual-hosted git repository.
mattcasters 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 802351a16f Fixes #8814 : Lint: let rules carry tags, and write them to
SARIF and… (#8824)
802351a16f is described below
commit 802351a16f272bdbdf6b840fdcf592c30d215087
Author: Bart Maertens <[email protected]>
AuthorDate: Sat Oct 10 11:08:46 2026 +0200
Fixes #8814 : Lint: let rules carry tags, and write them to SARIF and…
(#8824)
* Fixes #8814 : Lint: let rules carry tags, and write them to SARIF and JSON
- Rules take an optional tags: map (string or list values) and helpUri:.
The linter never evaluates tags; every finding carries them.
- SARIF: properties.tags as key:value strings, properties.hopTags as a map,
fullDescription and helpUri on the rule.
- JSON: a tags object on each finding.
- A project's hop-lint.yml can set tags on a pack rule, replacing per key;
a key with no values removes the pack's tag.
- Unknown keys on a rule or an override are logged as warnings, with the
closest known key suggested.
- Hop GUI: Help URL field and Tags table in the rule editor, Tags column
in the rule manager. Editing a pack rule's tags writes an override.
- Docs: tags and help links in lint-rules, lint-rule-packs and hop-lint.
* Issue #8814 : Clamp rule editor to the screen, name hop-lint.yml in
warnings
---
.../modules/ROOT/pages/hop-tools/hop-lint.adoc | 1 +
.../ROOT/pages/linting/lint-rule-packs.adoc | 3 +-
.../modules/ROOT/pages/linting/lint-rules.adoc | 46 +++
.../java/org/apache/hop/lint/CustomLintRule.java | 32 +++
.../org/apache/hop/lint/CustomRuleExecutor.java | 15 +-
.../apache/hop/lint/LintCheckResultAdapter.java | 22 +-
.../java/org/apache/hop/lint/LintReportWriter.java | 72 +++--
.../main/java/org/apache/hop/lint/LintResult.java | 35 +++
.../java/org/apache/hop/lint/LintRuleDetails.java | 92 ++++++
.../org/apache/hop/lint/NativeCheckClassifier.java | 19 +-
.../org/apache/hop/lint/RuleBuilderDialog.java | 132 ++++++++-
.../org/apache/hop/lint/RuleManagerDialog.java | 5 +
.../org/apache/hop/lint/registry/LintWarnings.java | 47 +++
.../hop/lint/registry/ProjectLintYamlExporter.java | 53 ++++
.../hop/lint/registry/ProjectYamlOverlay.java | 44 ++-
.../org/apache/hop/lint/registry/RuleRegistry.java | 17 +-
.../hop/lint/registry/YamlRulePackParser.java | 202 ++++++++++++-
.../hop/lint/messages/messages_en_US.properties | 7 +
.../apache/hop/lint/CustomRuleExecutorTest.java | 23 ++
.../org/apache/hop/lint/LintReportWriterTest.java | 125 ++++++++
.../apache/hop/lint/NativeCheckClassifierTest.java | 50 ++++
.../hop/lint/RuleBuilderDialogNativeRuleTest.java | 34 +++
.../lint/registry/YamlRulePackParserTagsTest.java | 316 +++++++++++++++++++++
23 files changed, 1329 insertions(+), 63 deletions(-)
diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-tools/hop-lint.adoc
b/docs/hop-user-manual/modules/ROOT/pages/hop-tools/hop-lint.adoc
index 02bd08af91..60f8a277ba 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-tools/hop-lint.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-tools/hop-lint.adoc
@@ -134,6 +134,7 @@ hop lint --max-warnings 20 .
`sarif` is the format GitHub code scanning, Azure DevOps and most review
tooling read, so findings show up as annotations on the pull request.
`json` is there for everything else.
+Both carry the tags a rule pack gives its rules; see
xref:linting/lint-rules.adoc#tags[Tags and help links].
Stdout carries only the report, in every format, so it can be piped or
redirected.
Hop's log lines and notes such as the baseline count go to stderr.
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 3eaf7d69d8..7096b430eb 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
@@ -54,6 +54,7 @@ rules:
----
The rules themselves are written exactly as described in
xref:linting/lint-rules.adoc[Lint rules].
+Give rules `tags` and a `helpUri` to carry your own category, owner or policy
reference into the SARIF and JSON reports, as described in
xref:linting/lint-rules.adoc#tags[Tags and help links].
[cols="1,3", options="header"]
|===
@@ -120,5 +121,5 @@ 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 or enabled state 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 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.
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 ca65acdb39..946cd07826 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
@@ -292,6 +292,52 @@ suppress:
reason: "Fields in these templates arrive through metadata injection"
----
+[[tags]]
+== Tags and help links
+
+A rule can carry `tags`: labels a team or vendor attaches to it, such as a
category, the team that owns it or the policy it enforces.
+Each value is a string or a list of strings.
+
+[source,yaml]
+----
+rules:
+ ACME-002:
+ type: custom
+ # ...the rule as before...
+ helpUri: https://wiki.example.com/lint/ACME-002
+ tags:
+ category: secrets
+ owner: platform-team
+ policy: ["SEC-POL-4", "SEC-POL-7"]
+----
+
+The linter never evaluates tags, so changing them cannot change what a rule
finds.
+They are written to the reports, on every finding the rule produces:
+
+* `sarif`: on the rule, `properties.tags` holds one `key:value` string per
value (`category:secrets`, `policy:SEC-POL-4`, `policy:SEC-POL-7`), the form
GitHub code scanning and Azure DevOps read, and `properties.hopTags` holds the
same tags as a map. The rule's `description` is written as `fullDescription`,
and `helpUri` as `helpUri`.
+* `json`: each finding has a `tags` object. Every value is written as a list,
also a single one.
+
+Tags work the same on a native rule.
+
+A project sets tags on any rule, a pack's included, in its own `hop-lint.yml`.
+Each key replaces the pack's values for that key and leaves the pack's other
tags as they are; a key with no values removes the pack's tag:
+
+[source,yaml]
+----
+rules:
+ DB-001:
+ tags:
+ owner: data-team # replaces the pack's owner, if it has one
+ policy: [] # removes the pack's policy tag
+ helpUri: https://wiki.example.com/lint/DB-001
+----
+
+In Hop Gui, the rule editor has a *Help URL* field and a *Tags* table with one
row per key and value; repeat a key to give it several values.
+Saving a pack rule there writes only the changed keys to `hop-lint.yml`, as
above.
+The rule manager lists each rule's tags in its *Tags* column.
+
+A key the linter does not know, such as `tag:` for `tags:`, is logged as a
warning and ignored.
+
== The rules Hop ships
Hop's core rule pack is deliberately small. A rule is enabled by default only
when a violation is defensible as a defect in any project, whatever the house
style.
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/CustomLintRule.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/CustomLintRule.java
index abba36736e..69b337de63 100644
--- a/plugins/misc/lint/src/main/java/org/apache/hop/lint/CustomLintRule.java
+++ b/plugins/misc/lint/src/main/java/org/apache/hop/lint/CustomLintRule.java
@@ -18,6 +18,7 @@ package org.apache.hop.lint;
import java.util.ArrayList;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@@ -74,6 +75,18 @@ public class CustomLintRule {
*/
private String messageKey;
+ /**
+ * Free-form labels a pack attaches to the rule: a category, an owning team,
a policy reference.
+ *
+ * <p>The linter never evaluates them. They are copied onto every finding
the rule produces and
+ * written to the SARIF and JSON reports, so a pack can change its tags
without any risk of
+ * changing its findings. Each key holds one or more values, in the order
the pack wrote them.
+ */
+ private Map<String, List<String>> tags = new LinkedHashMap<>();
+
+ /** Where a reader can learn more about the rule, written to SARIF as {@code
helpUri}. */
+ private String helpUri;
+
/** A rule the linter evaluates against a pipeline, workflow or metadata
object. */
public static final String TYPE_CUSTOM = "custom";
@@ -284,6 +297,22 @@ public class CustomLintRule {
this.messageKey = messageKey;
}
+ public Map<String, List<String>> getTags() {
+ return tags;
+ }
+
+ public void setTags(Map<String, List<String>> tags) {
+ this.tags = tags != null ? tags : new LinkedHashMap<>();
+ }
+
+ public String getHelpUri() {
+ return helpUri;
+ }
+
+ public void setHelpUri(String helpUri) {
+ this.helpUri = helpUri;
+ }
+
/**
* Whether this rule classifies Hop's own verify remarks instead of being
evaluated by the linter.
*
@@ -320,6 +349,9 @@ public class CustomLintRule {
copy.combinator = this.combinator;
copy.type = this.type;
copy.messageKey = this.messageKey;
+ copy.helpUri = this.helpUri;
+ copy.tags = new LinkedHashMap<>();
+ this.tags.forEach((key, values) -> copy.tags.put(key, new
ArrayList<>(values)));
copy.additionalClauses = new ArrayList<>();
for (RuleClause clause : this.additionalClauses) {
copy.additionalClauses.add(clause.copy());
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/CustomRuleExecutor.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/CustomRuleExecutor.java
index 94386a913f..6bef6b582c 100644
---
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/CustomRuleExecutor.java
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/CustomRuleExecutor.java
@@ -263,13 +263,14 @@ public class CustomRuleExecutor {
private static LintResult createResult(
CustomLintRule rule, String message, String fileName, Object hopObject,
String severity) {
return new LintResult(
- rule.generateRuleId(),
- rule.getName(),
- severity,
- message,
- fileName,
- sourceFrom(hopObject),
- LintResult.Origin.LINT);
+ rule.generateRuleId(),
+ rule.getName(),
+ severity,
+ message,
+ fileName,
+ sourceFrom(hopObject),
+ LintResult.Origin.LINT)
+ .withRuleDetails(LintRuleDetails.of(rule));
}
/**
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintCheckResultAdapter.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintCheckResultAdapter.java
index 4b7c59487b..fb5d8908a6 100644
---
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintCheckResultAdapter.java
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintCheckResultAdapter.java
@@ -102,6 +102,7 @@ public final class LintCheckResultAdapter {
String severity = LintSeverity.fromCheckResultType(remark.getType());
String ruleId = remark.getErrorCode();
List<String> aliasRuleIds = new ArrayList<>();
+ LintRuleDetails ruleDetails = LintRuleDetails.NONE;
if (classifier != null && !classifier.isEmpty()) {
NativeCheckClassifier.Classification classification =
classifier.classify(remark);
@@ -111,6 +112,7 @@ public final class LintCheckResultAdapter {
return null;
}
severity = classification.severity();
+ ruleDetails = classification.ruleDetails();
String classifyingRule = classification.ruleId();
if (!Utils.isEmpty(classifyingRule)) {
if (Utils.isEmpty(ruleId)) {
@@ -125,6 +127,9 @@ public final class LintCheckResultAdapter {
// The blanket rule names every remark, and taking its id would
collapse them all into
// one. It is kept alongside, so what a project wrote against it
still applies.
aliasRuleIds.add(classifyingRule);
+ // The rule's tags apply to the remark; its description and help
link describe the
+ // rule, not this code.
+ ruleDetails = ruleDetails.tagsOnly();
}
}
// A narrowed rule took the id above, so the rule covering every remark
would stop naming
@@ -144,14 +149,15 @@ public final class LintCheckResultAdapter {
remark.getSourceInfo() != null ? remark.getSourceInfo().getName() :
"Hop verify";
return new LintResult(
- ruleId,
- ruleName,
- severity,
- remark.getText(),
- fileName,
- sourceRef,
- LintResult.Origin.HOP_NATIVE,
- aliasRuleIds);
+ ruleId,
+ ruleName,
+ severity,
+ remark.getText(),
+ fileName,
+ sourceRef,
+ LintResult.Origin.HOP_NATIVE,
+ aliasRuleIds)
+ .withRuleDetails(ruleDetails);
}
private static String formatCheckText(LintResult lintResult) {
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintReportWriter.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintReportWriter.java
index e2b660a33d..cfbf4bcc08 100644
--- a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintReportWriter.java
+++ b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintReportWriter.java
@@ -122,6 +122,7 @@ public final class LintReportWriter {
source.put("kind", result.getSource().getKind().name());
source.put("name", result.getSource().getName());
}
+ putTags(finding.putObject("tags"), result.getRuleDetails());
}
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(root) +
"\n";
}
@@ -144,25 +145,48 @@ public final class LintReportWriter {
driver.put("version", toolVersion);
}
- // SARIF wants each rule declared once, then referenced by index from
every result.
- Map<String, Integer> ruleIndex = new LinkedHashMap<>();
- ArrayNode rules = driver.putArray("rules");
+ // SARIF wants each rule declared once, then referenced by index from
every result. The rule is
+ // described by the first finding of that id which carries the rule's
details, so a finding
+ // without them, such as a remark checked without the native rules, cannot
hide the tags.
+ Map<String, LintResult> describing = new LinkedHashMap<>();
for (LintResult result : results) {
- ruleIndex.computeIfAbsent(
- result.getRuleId(),
- id -> {
- ObjectNode rule = rules.addObject();
- rule.put("id", id);
- // The id is the only stable per-rule name available: Hop's own
verify remarks all share
- // one rule id while carrying the transform name as their result
name, so using that
- // here would label the rule after whichever transform happened to
be reported first.
- rule.put("name", id);
- rule.putObject("shortDescription")
- .put("text", result.getRuleName() != null ?
result.getRuleName() : id);
- rule.putObject("defaultConfiguration").put("level",
sarifLevel(result.getSeverity()));
- return rules.size() - 1;
- });
+ LintResult current = describing.get(result.getRuleId());
+ if (current == null
+ || (LintRuleDetails.NONE.equals(current.getRuleDetails())
+ && !LintRuleDetails.NONE.equals(result.getRuleDetails()))) {
+ describing.put(result.getRuleId(), result);
+ }
}
+ Map<String, Integer> ruleIndex = new LinkedHashMap<>();
+ ArrayNode rules = driver.putArray("rules");
+ describing.forEach(
+ (id, result) -> {
+ ruleIndex.put(id, rules.size());
+ ObjectNode rule = rules.addObject();
+ rule.put("id", id);
+ // The id is the only stable per-rule name available: Hop's own
verify remarks all share
+ // one rule id while carrying the transform name as their result
name, so using that
+ // here would label the rule after whichever transform happened to
be reported first.
+ rule.put("name", id);
+ rule.putObject("shortDescription")
+ .put("text", result.getRuleName() != null ? result.getRuleName()
: id);
+ LintRuleDetails details = result.getRuleDetails();
+ if (!details.description().isBlank()) {
+ rule.putObject("fullDescription").put("text",
details.description());
+ }
+ if (details.helpUri() != null && !details.helpUri().isBlank()) {
+ rule.put("helpUri", details.helpUri());
+ }
+ rule.putObject("defaultConfiguration").put("level",
sarifLevel(result.getSeverity()));
+ if (details.hasTags()) {
+ ObjectNode properties = rule.putObject("properties");
+ // GitHub code scanning and Azure DevOps read tags as a flat list
of strings, so a
+ // key with several values becomes several key:value entries.
+ ArrayNode flat = properties.putArray("tags");
+ LintRuleDetails.flatTags(details.tags()).forEach(flat::add);
+ putTags(properties.putObject("hopTags"), details);
+ }
+ });
ArrayNode sarifResults = run.putArray("results");
for (LintResult result : results) {
@@ -197,6 +221,20 @@ public final class LintReportWriter {
return result.getSource().getName() + ": " + result.getMessage();
}
+ /**
+ * Write a rule's tags as an object of arrays. Every value is an array, also
a single one, so a
+ * consumer reads one shape whatever the pack wrote.
+ */
+ private static void putTags(ObjectNode target, LintRuleDetails details) {
+ details
+ .tags()
+ .forEach(
+ (key, values) -> {
+ ArrayNode array = target.putArray(key);
+ values.forEach(array::add);
+ });
+ }
+
private static String sarifLevel(String severity) {
if ("ERROR".equalsIgnoreCase(severity)) {
return "error";
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintResult.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintResult.java
index bc74abd849..6b1bc11372 100644
--- a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintResult.java
+++ b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintResult.java
@@ -36,6 +36,7 @@ public class LintResult {
private final LintSourceRef source;
private final Origin origin;
private final List<String> aliasRuleIds;
+ private final LintRuleDetails ruleDetails;
public LintResult(
String ruleId, String ruleName, String severity, String message, String
fileName) {
@@ -79,6 +80,28 @@ public class LintResult {
LintSourceRef source,
Origin origin,
List<String> aliasRuleIds) {
+ this(
+ ruleId,
+ ruleName,
+ severity,
+ message,
+ fileName,
+ source,
+ origin,
+ aliasRuleIds,
+ LintRuleDetails.NONE);
+ }
+
+ private LintResult(
+ String ruleId,
+ String ruleName,
+ String severity,
+ String message,
+ String fileName,
+ LintSourceRef source,
+ Origin origin,
+ List<String> aliasRuleIds,
+ LintRuleDetails ruleDetails) {
this.ruleId = ruleId;
this.ruleName = ruleName;
this.severity = severity;
@@ -87,6 +110,13 @@ public class LintResult {
this.source = source;
this.origin = origin != null ? origin : Origin.LINT;
this.aliasRuleIds = aliasRuleIds == null ? Collections.emptyList() :
List.copyOf(aliasRuleIds);
+ this.ruleDetails = ruleDetails != null ? ruleDetails :
LintRuleDetails.NONE;
+ }
+
+ /** This finding, carrying the description, help link and tags of the rule
that produced it. */
+ public LintResult withRuleDetails(LintRuleDetails details) {
+ return new LintResult(
+ ruleId, ruleName, severity, message, fileName, source, origin,
aliasRuleIds, details);
}
public String getRuleId() {
@@ -117,6 +147,11 @@ public class LintResult {
return origin;
}
+ /** The description, help link and tags of the rule that produced this
finding. */
+ public LintRuleDetails getRuleDetails() {
+ return ruleDetails;
+ }
+
/**
* The other id this finding answers to, or null.
*
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintRuleDetails.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintRuleDetails.java
new file mode 100644
index 0000000000..c7b05f9c67
--- /dev/null
+++ b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintRuleDetails.java
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.lint;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * What a finding carries about the rule that produced it, for the reports to
write out.
+ *
+ * <p>None of it changes what the rule checks: it is the rule's description,
where to read more
+ * about it, and the tags its pack gave it.
+ *
+ * @param description the rule's description, empty when it has none
+ * @param helpUri where to read more about the rule, or null
+ * @param tags the rule's tags, each key holding one or more values
+ */
+public record LintRuleDetails(String description, String helpUri, Map<String,
List<String>> tags) {
+
+ public static final LintRuleDetails NONE = new LintRuleDetails("", null,
Map.of());
+
+ public LintRuleDetails {
+ description = description != null ? description : "";
+ tags = copyOf(tags);
+ }
+
+ /** The details of a rule, or {@link #NONE} when there is no rule. */
+ public static LintRuleDetails of(CustomLintRule rule) {
+ if (rule == null) {
+ return NONE;
+ }
+ return new LintRuleDetails(rule.getDescription(), rule.getHelpUri(),
rule.getTags());
+ }
+
+ /**
+ * Only the tags, for a finding reported under an id other than the rule's
own.
+ *
+ * <p>A remark from Hop's own checks with an error code of its own is
reported under that code.
+ * The tags of the native rule that classified it apply to it, but that
rule's description and
+ * help link are about the rule, not about the code.
+ */
+ public LintRuleDetails tagsOnly() {
+ return hasTags() ? new LintRuleDetails("", null, tags) : NONE;
+ }
+
+ public boolean hasTags() {
+ return !tags.isEmpty();
+ }
+
+ /**
+ * The tags as one {@code key:value} string per value, the form SARIF
consumers read and the rule
+ * manager shows.
+ */
+ public static List<String> flatTags(Map<String, List<String>> tags) {
+ List<String> flat = new ArrayList<>();
+ if (tags != null) {
+ tags.forEach((key, values) -> values.forEach(value -> flat.add(key + ":"
+ value)));
+ }
+ return flat;
+ }
+
+ private static Map<String, List<String>> copyOf(Map<String, List<String>>
tags) {
+ if (tags == null || tags.isEmpty()) {
+ return Map.of();
+ }
+ // Insertion order is kept so the reports list tags as the pack wrote
them. A value given twice
+ // is kept once: SARIF requires the tags of a rule to be unique.
+ Map<String, List<String>> copy = new LinkedHashMap<>();
+ tags.forEach(
+ (key, values) ->
+ copy.put(key, values != null ? List.copyOf(new
LinkedHashSet<>(values)) : List.of()));
+ return Collections.unmodifiableMap(copy);
+ }
+}
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/NativeCheckClassifier.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/NativeCheckClassifier.java
index a3571965e3..2a2c8ad737 100644
---
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/NativeCheckClassifier.java
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/NativeCheckClassifier.java
@@ -49,9 +49,14 @@ public final class NativeCheckClassifier {
* What a matching rule says should happen to a remark.
*
* @param narrowed whether the rule names a plugin or a check, rather than
every remark
+ * @param ruleDetails the description, help link and tags of the matching
rule
*/
public record Classification(
- String severity, String ruleId, boolean narrowed, String blanketRuleId)
{}
+ String severity,
+ String ruleId,
+ boolean narrowed,
+ String blanketRuleId,
+ LintRuleDetails ruleDetails) {}
/** Where MessageFormat left a value out: {@code {0}}, {@code {1}}, and so
on. */
private static final Pattern PLACEHOLDER = Pattern.compile("\\{\\d+\\}");
@@ -89,7 +94,11 @@ public final class NativeCheckClassifier {
CustomLintRule match = bestMatch(remark);
if (match == null) {
return new Classification(
- LintSeverity.fromCheckResultType(remark.getType()), null, false,
null);
+ LintSeverity.fromCheckResultType(remark.getType()),
+ null,
+ false,
+ null,
+ LintRuleDetails.NONE);
}
if (!match.isEnabled()) {
return null;
@@ -103,7 +112,11 @@ public final class NativeCheckClassifier {
// naming this finding, and a project's existing suppression of it would
quietly lapse the
// moment that project named the check. It is carried along as another
name.
return new Classification(
- severity, match.generateRuleId(), narrowed, narrowed ? blanketRuleId()
: null);
+ severity,
+ match.generateRuleId(),
+ narrowed,
+ narrowed ? blanketRuleId() : null,
+ LintRuleDetails.of(match));
}
/**
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 cb864aa625..2ba1d817c9 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,19 @@
package org.apache.hop.lint;
import java.util.ArrayList;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.variables.Variables;
import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.widget.ColumnInfo;
+import org.apache.hop.ui.core.widget.TableView;
import org.eclipse.swt.SWT;
import org.eclipse.swt.events.SelectionAdapter;
import org.eclipse.swt.events.SelectionEvent;
+import org.eclipse.swt.graphics.Rectangle;
import org.eclipse.swt.layout.FormAttachment;
import org.eclipse.swt.layout.FormData;
import org.eclipse.swt.layout.FormLayout;
@@ -63,6 +70,8 @@ public class RuleBuilderDialog extends Dialog {
private Table clauseTable;
private Button addClauseButton;
private Button removeClauseButton;
+ private Text helpUriText;
+ private TableView tagsTable;
/**
* The clauses being edited. Always at least one; a rule which checks one
thing has exactly one.
@@ -88,12 +97,21 @@ public class RuleBuilderDialog extends Dialog {
populateControls();
shell.pack();
- // Roomy enough for the clause table and its buttons on top of the fields
that were here
- // before; the dialog is resizable if a rule grows more clauses than that.
- shell.setSize(700, 660);
- shell.setLocation(
- parent.getLocation().x + (parent.getSize().x - 700) / 2,
- parent.getLocation().y + (parent.getSize().y - 660) / 2);
+ // Roomy enough for the clause table, the fields and the tags table, but
never taller than the
+ // screen: on a laptop display an 820 pixel dialog put OK and Cancel out
of reach. Below the
+ // minimum the fixed rows would run into the button bar, so the dialog
does not shrink past it.
+ // The whole dialog stays inside the work area, wherever the Hop window
sits: clamping only the
+ // top edge still put the buttons off-screen below a window in the lower
half of the screen.
+ Rectangle screen = parent.getMonitor().getClientArea();
+ int width = Math.min(700, screen.width);
+ int height = Math.min(820, screen.height);
+ int x = parent.getLocation().x + (parent.getSize().x - width) / 2;
+ int y = parent.getLocation().y + (parent.getSize().y - height) / 2;
+ x = Math.max(screen.x, Math.min(x, screen.x + screen.width - width));
+ y = Math.max(screen.y, Math.min(y, screen.y + screen.height - height));
+ shell.setMinimumSize(Math.min(560, width), Math.min(700, height));
+ shell.setSize(width, height);
+ shell.setLocation(x, y);
shell.open();
Display display = parent.getDisplay();
@@ -347,6 +365,22 @@ public class RuleBuilderDialog extends Dialog {
enabledData.top = new FormAttachment(severityCombo, margin);
enabledCheck.setLayoutData(enabledData);
+ // Help URL, written to SARIF as helpUri
+ Label helpUriLabel = new Label(shell, SWT.RIGHT);
+ helpUriLabel.setText(BaseMessages.getString(PKG,
"RuleBuilderDialog.Label.HelpUri"));
+ FormData helpUriLabelData = new FormData();
+ helpUriLabelData.left = new FormAttachment(0, margin);
+ helpUriLabelData.right = new FormAttachment(0, labelWidth);
+ helpUriLabelData.top = new FormAttachment(enabledCheck, margin);
+ helpUriLabel.setLayoutData(helpUriLabelData);
+
+ helpUriText = new Text(shell, SWT.BORDER);
+ FormData helpUriData = new FormData();
+ helpUriData.left = new FormAttachment(helpUriLabel, margin);
+ helpUriData.right = new FormAttachment(100, -margin);
+ helpUriData.top = new FormAttachment(enabledCheck, margin);
+ helpUriText.setLayoutData(helpUriData);
+
// Buttons
Button okButton = new Button(shell, SWT.PUSH);
okButton.setText(BaseMessages.getString(PKG,
"RuleBuilderDialog.Button.Ok"));
@@ -378,6 +412,45 @@ public class RuleBuilderDialog extends Dialog {
cancelData.width = 80;
cancelButton.setLayoutData(cancelData);
+ // Tags: one row per key and value, so a value never needs escaping. The
table takes whatever
+ // height is left between the help URL and the buttons, so resizing the
dialog grows it rather
+ // than pushing it under the buttons.
+ Label tagsLabel = new Label(shell, SWT.RIGHT);
+ tagsLabel.setText(BaseMessages.getString(PKG,
"RuleBuilderDialog.Label.Tags"));
+ tagsLabel.setToolTipText(BaseMessages.getString(PKG,
"RuleBuilderDialog.Tags.Tooltip"));
+ FormData tagsLabelData = new FormData();
+ tagsLabelData.left = new FormAttachment(0, margin);
+ tagsLabelData.right = new FormAttachment(0, labelWidth);
+ tagsLabelData.top = new FormAttachment(helpUriText, margin);
+ tagsLabel.setLayoutData(tagsLabelData);
+
+ ColumnInfo[] tagColumns = {
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "RuleBuilderDialog.Column.TagKey"),
+ ColumnInfo.COLUMN_TYPE_TEXT,
+ false),
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "RuleBuilderDialog.Column.TagValue"),
+ ColumnInfo.COLUMN_TYPE_TEXT,
+ false)
+ };
+ tagsTable =
+ new TableView(
+ Variables.getADefaultVariableSpace(),
+ shell,
+ SWT.BORDER | SWT.FULL_SELECTION | SWT.MULTI,
+ tagColumns,
+ 1,
+ null,
+ PropsUi.getInstance());
+ tagsTable.setToolTipText(BaseMessages.getString(PKG,
"RuleBuilderDialog.Tags.Tooltip"));
+ FormData tagsData = new FormData();
+ tagsData.left = new FormAttachment(tagsLabel, margin);
+ tagsData.right = new FormAttachment(100, -margin);
+ tagsData.top = new FormAttachment(helpUriText, margin);
+ tagsData.bottom = new FormAttachment(okButton, -margin);
+ tagsTable.setLayoutData(tagsData);
+
shell.setDefaultButton(okButton);
}
@@ -401,6 +474,8 @@ public class RuleBuilderDialog extends Dialog {
}
}
enabledCheck.setSelection(rule.isEnabled());
+ helpUriText.setText(rule.getHelpUri() != null ? rule.getHelpUri() : "");
+ populateTags();
loadClausesFromRule();
// A native rule, such as HOP-CHECK, says how Hop's own verify remarks are
reported. It has no
@@ -491,6 +566,13 @@ public class RuleBuilderDialog extends Dialog {
showError("Rule name is required");
return false;
}
+ // A row with only a key or only a value is not a tag. Saving would drop
it without a word.
+ for (TableItem item : tagsTable.getNonEmptyItems()) {
+ if (item.getText(1).trim().isEmpty() ||
item.getText(2).trim().isEmpty()) {
+ showError(BaseMessages.getString(PKG,
"RuleBuilderDialog.Tags.Incomplete"));
+ return false;
+ }
+ }
if (rule.isNativeVerify()) {
return true;
}
@@ -522,6 +604,9 @@ public class RuleBuilderDialog extends Dialog {
rule.setDescription(descriptionText.getText());
rule.setSeverity(severityCombo.getText());
rule.setEnabled(enabledCheck.getSelection());
+ rule.setHelpUri(
+ Utils.isEmpty(helpUriText.getText().trim()) ? null :
helpUriText.getText().trim());
+ rule.setTags(readTags());
if (rule.isNativeVerify()) {
return;
}
@@ -545,6 +630,41 @@ public class RuleBuilderDialog extends Dialog {
rule.setAdditionalClauses(rest);
}
+ private void populateTags() {
+ tagsTable.clearAll(false);
+ rule.getTags()
+ .forEach(
+ (key, values) -> {
+ for (String value : values) {
+ TableItem item = new TableItem(tagsTable.table, SWT.NONE);
+ item.setText(1, key);
+ item.setText(2, value);
+ }
+ });
+ tagsTable.removeEmptyRows();
+ tagsTable.setRowNums();
+ tagsTable.optWidth(true);
+ }
+
+ /**
+ * The tags as entered, grouped by key in the order the keys first appear.
{@link #validate()} has
+ * refused rows with only a key or only a value; a row entered twice is kept
once.
+ */
+ private Map<String, List<String>> readTags() {
+ Map<String, List<String>> tags = new LinkedHashMap<>();
+ for (TableItem item : tagsTable.getNonEmptyItems()) {
+ String key = item.getText(1).trim();
+ String value = item.getText(2).trim();
+ if (!key.isEmpty() && !value.isEmpty()) {
+ List<String> values = tags.computeIfAbsent(key, k -> new
ArrayList<>());
+ if (!values.contains(value)) {
+ values.add(value);
+ }
+ }
+ }
+ return tags;
+ }
+
/** Load the rule's clauses into the table, selecting the first. */
private void loadClausesFromRule() {
editingClauses.clear();
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 a9f673b649..fff060060e 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
@@ -171,6 +171,10 @@ public class RuleManagerDialog extends Dialog {
enabledColumn.setText(BaseMessages.getString(PKG,
"RuleManagerDialog.Column.Enabled"));
enabledColumn.setWidth(80);
+ TableColumn tagsColumn = new TableColumn(rulesTable, SWT.LEFT);
+ tagsColumn.setText(BaseMessages.getString(PKG,
"RuleManagerDialog.Column.Tags"));
+ tagsColumn.setWidth(200);
+
FormData tableData = new FormData();
tableData.left = new FormAttachment(0, margin);
tableData.right = new FormAttachment(100, -(BUTTON_PANEL_WIDTH + 2 *
margin));
@@ -304,6 +308,7 @@ public class RuleManagerDialog extends Dialog {
}
item.setText(7, rule.getSeverity() != null ? rule.getSeverity() :
"WARNING");
item.setText(8, rule.isEnabled() ? "✓" : "✗");
+ item.setText(9, String.join(", ",
LintRuleDetails.flatTags(rule.getTags())));
item.setData(rule);
}
}
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/LintWarnings.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/LintWarnings.java
new file mode 100644
index 0000000000..2a92a85ae5
--- /dev/null
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/LintWarnings.java
@@ -0,0 +1,47 @@
+/*
+ * 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.registry;
+
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import org.apache.hop.core.logging.LogChannel;
+
+/**
+ * Logs a configuration warning prominently once, and quietly after that.
+ *
+ * <p>Rule packs and the project's hop-lint.yml are read again on every lint
run, and the editor
+ * lints on a timer, so logging each time repeated the same line for as long
as the file kept the
+ * mistake.
+ */
+final class LintWarnings {
+
+ private static final Set<String> logged = ConcurrentHashMap.newKeySet();
+
+ private LintWarnings() {}
+
+ /**
+ * @param key what makes this warning the same as one already logged
+ * @param warning the line to log
+ */
+ static void logOnce(String key, String warning) {
+ if (logged.add(key)) {
+ LogChannel.GENERAL.logMinimal(warning);
+ } else {
+ LogChannel.GENERAL.logDetailed(warning);
+ }
+ }
+}
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 443adcb106..d5f3b34417 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
@@ -99,6 +99,8 @@ public final class ProjectLintYamlExporter {
|| !Objects.equals(desired.getSeverity(), packDefault.getSeverity())
|| !Objects.equals(desired.getAdditionalParameters(),
packDefault.getAdditionalParameters())
|| !Objects.equals(desired.getConditionValue(),
packDefault.getConditionValue())
+ || !Objects.equals(desired.getHelpUri(), packDefault.getHelpUri())
+ || !Objects.equals(desired.getTags(), packDefault.getTags())
|| structurallyDiffersFromPackDefault(desired, packDefault);
}
@@ -155,9 +157,43 @@ public final class ProjectLintYamlExporter {
&& !desired.getAdditionalParameters().isEmpty()) {
ruleConfig.put("parameters", new
HashMap<>(desired.getAdditionalParameters()));
}
+ if (!Objects.equals(desired.getHelpUri(), packDefault.getHelpUri())) {
+ // An empty value is how a project removes the pack's link.
+ ruleConfig.put("helpUri", desired.getHelpUri() != null ?
desired.getHelpUri() : "");
+ }
+ Map<String, Object> tags = tagOverrides(desired.getTags(),
packDefault.getTags());
+ if (!tags.isEmpty()) {
+ ruleConfig.put("tags", tags);
+ }
return ruleConfig;
}
+ /**
+ * Only the tag keys the project changed. A key the project removed is
written with no values, so
+ * the pack's tag is dropped, while the pack's other tags, and any it adds
later, still apply.
+ */
+ static Map<String, Object> tagOverrides(
+ Map<String, List<String>> desired, Map<String, List<String>>
packDefault) {
+ Map<String, Object> overrides = new LinkedHashMap<>();
+ desired.forEach(
+ (key, values) -> {
+ if (!values.equals(packDefault.get(key))) {
+ overrides.put(key, tagYamlValue(values));
+ }
+ });
+ for (String key : packDefault.keySet()) {
+ if (!desired.containsKey(key)) {
+ overrides.put(key, new ArrayList<>());
+ }
+ }
+ return overrides;
+ }
+
+ /** One value is written as a plain string, several as a list, the way a
person writes them. */
+ private static Object tagYamlValue(List<String> values) {
+ return values.size() == 1 ? values.get(0) : new ArrayList<>(values);
+ }
+
private static Map<String, Object> toFullCustomRuleMap(CustomLintRule rule) {
if (rule.isNativeVerify()) {
return toFullNativeRuleMap(rule);
@@ -196,9 +232,25 @@ public final class ProjectLintYamlExporter {
// Omitted when empty, like appliesTo: "parameters: {}" on every rule
was noise in the diff.
ruleConfig.put("parameters", new
HashMap<>(rule.getAdditionalParameters()));
}
+ putDocumentation(ruleConfig, rule);
return ruleConfig;
}
+ /**
+ * Write the rule's help link and tags, so a rule saved from the rule
manager keeps them. Both are
+ * omitted when absent, like appliesTo.
+ */
+ private static void putDocumentation(Map<String, Object> ruleConfig,
CustomLintRule rule) {
+ if (rule.getHelpUri() != null && !rule.getHelpUri().isEmpty()) {
+ ruleConfig.put("helpUri", rule.getHelpUri());
+ }
+ if (!rule.getTags().isEmpty()) {
+ Map<String, Object> tags = new LinkedHashMap<>();
+ rule.getTags().forEach((key, values) -> tags.put(key,
tagYamlValue(values)));
+ ruleConfig.put("tags", tags);
+ }
+ }
+
/**
* A native rule written out in full.
*
@@ -219,6 +271,7 @@ public final class ProjectLintYamlExporter {
}
ruleConfig.put("name", rule.getName());
ruleConfig.put("description", rule.getDescription());
+ putDocumentation(ruleConfig, rule);
return ruleConfig;
}
}
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 f7db88aa9a..cbb787c497 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
@@ -16,6 +16,7 @@
*/
package org.apache.hop.lint.registry;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -66,16 +67,34 @@ public final class ProjectYamlOverlay {
private final String severity;
private final String conditionValue;
private final Map<String, Object> parameters;
+ private final String helpUri;
+
+ /**
+ * 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.
+ */
+ private final Map<String, List<String>> tags;
private ProjectRuleOverlay(
- Boolean enabled, String severity, String conditionValue, Map<String,
Object> parameters) {
+ Boolean enabled,
+ String severity,
+ String conditionValue,
+ Map<String, Object> parameters,
+ String helpUri,
+ Map<String, List<String>> tags) {
this.enabled = enabled;
this.severity = severity;
this.conditionValue = conditionValue;
this.parameters = parameters;
+ this.helpUri = helpUri;
+ this.tags = tags != null ? tags : Collections.emptyMap();
}
- public static ProjectRuleOverlay fromMap(Map<String, Object> ruleData) {
+ /**
+ * @param location where the override is written, for the warnings
+ */
+ public static ProjectRuleOverlay fromMap(
+ String ruleId, Map<String, Object> ruleData, String location) {
Boolean enabled = ruleData.containsKey("enabled") ? (Boolean)
ruleData.get("enabled") : null;
String severity =
ruleData.containsKey("severity") ?
String.valueOf(ruleData.get("severity")) : null;
@@ -87,7 +106,15 @@ public final class ProjectYamlOverlay {
: null;
@SuppressWarnings("unchecked")
Map<String, Object> parameters = (Map<String, Object>)
ruleData.get("parameters");
- return new ProjectRuleOverlay(enabled, severity, conditionValue,
parameters);
+ // A key with no value, like an empty one, removes the pack's link.
+ String helpUri = null;
+ if (ruleData.containsKey("helpUri")) {
+ Object value = ruleData.get("helpUri");
+ helpUri = value != null ? value.toString().trim() : "";
+ }
+ Map<String, List<String>> tags =
+ YamlRulePackParser.tagsValue(ruleData.get("tags"), ruleId, location,
true);
+ return new ProjectRuleOverlay(enabled, severity, conditionValue,
parameters, helpUri, tags);
}
public void applyTo(org.apache.hop.lint.CustomLintRule rule) {
@@ -103,6 +130,17 @@ public final class ProjectYamlOverlay {
if (parameters != null && !parameters.isEmpty()) {
rule.getAdditionalParameters().putAll(parameters);
}
+ if (helpUri != null) {
+ rule.setHelpUri(helpUri.isEmpty() ? null : helpUri);
+ }
+ tags.forEach(
+ (key, values) -> {
+ if (values.isEmpty()) {
+ rule.getTags().remove(key);
+ } else {
+ rule.getTags().put(key, new ArrayList<>(values));
+ }
+ });
}
public RuleConfig toRuleConfig() {
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/RuleRegistry.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/RuleRegistry.java
index 30b1452506..fd807c1b6f 100644
---
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/RuleRegistry.java
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/RuleRegistry.java
@@ -22,8 +22,6 @@ import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.hop.core.logging.LogChannel;
import org.apache.hop.core.util.Utils;
@@ -61,13 +59,6 @@ public class RuleRegistry {
*/
private volatile List<String> packErrors = List.of();
- /**
- * The unknown rule id warnings already logged, per hop-lint.yml. Resolution
runs once per file
- * and on every background check, so logging each time repeated the same
line for as long as the
- * project kept the id.
- */
- private final Set<String> loggedWarnings = ConcurrentHashMap.newKeySet();
-
private RuleRegistry() {
this(new RulePackDiscovery());
}
@@ -213,11 +204,7 @@ public class RuleRegistry {
// no sign anything had gone wrong.
String warning = unknownRuleWarning(entry.getKey(),
merged.keySet(), projectYaml);
warnings.add(warning);
- if (loggedWarnings.add(projectYaml.getAbsolutePath() + '\n' +
warning)) {
- LogChannel.GENERAL.logMinimal(warning);
- } else {
- LogChannel.GENERAL.logDetailed(warning);
- }
+ LintWarnings.logOnce(projectYaml.getAbsolutePath() + '\n' +
warning, warning);
}
}
LogChannel.GENERAL.logDetailed(
@@ -271,7 +258,7 @@ public class RuleRegistry {
}
/** The known id within two edits of the one given, or null. */
- private static String closestId(String ruleId, Collection<String> knownIds) {
+ static String closestId(String ruleId, Collection<String> knownIds) {
String best = null;
int bestDistance = 3;
for (String known : knownIds) {
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 d809b28e1d..979a60e5c7 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
@@ -23,8 +23,11 @@ import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import org.apache.hop.core.logging.LogChannel;
import org.apache.hop.core.util.Utils;
import org.apache.hop.lint.CustomLintRule;
@@ -40,6 +43,42 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Parses YAML rule pack and project overlay files. */
public final class YamlRulePackParser {
+ /** The keys a rule the linter evaluates may carry. */
+ static final Set<String> CUSTOM_RULE_KEYS =
+ Set.of(
+ "type",
+ "name",
+ "description",
+ "enabled",
+ "severity",
+ "target",
+ "targetField",
+ "condition",
+ "conditionValue",
+ "appliesTo",
+ RuleCombinator.ALL_OF.getYamlKey(),
+ RuleCombinator.ANY_OF.getYamlKey(),
+ "parameters",
+ "tags",
+ "helpUri");
+
+ /** The keys a rule classifying Hop's own verify remarks may carry. */
+ static final Set<String> NATIVE_RULE_KEYS =
+ Set.of(
+ "type",
+ "name",
+ "description",
+ "enabled",
+ "severity",
+ "appliesTo",
+ "messageKey",
+ "tags",
+ "helpUri");
+
+ /** 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");
+
private YamlRulePackParser() {}
/**
@@ -253,6 +292,8 @@ public final class YamlRulePackParser {
List<CustomLintRule> projectRules = new ArrayList<>();
Map<String, ProjectYamlOverlay.ProjectRuleOverlay> overlays = new
HashMap<>();
+ // Every project has a hop-lint.yml, so the warnings name the file by its
path.
+ String location = inFile(projectYaml);
for (Map.Entry<String, Object> entry : rulesSection.entrySet()) {
String ruleId = entry.getKey();
@@ -260,13 +301,16 @@ public final class YamlRulePackParser {
Map<String, Object> ruleData = (Map<String, Object>) entry.getValue();
if (isNativeRuleDefinition(ruleData)) {
projectRules.add(
- parseNativeRule(ruleId, ruleData, RulePackIds.PROJECT,
RulePackOwner.PROJECT));
+ parseNativeRule(
+ ruleId, ruleData, RulePackIds.PROJECT, RulePackOwner.PROJECT,
location));
} else if (isCustomRuleDefinition(ruleData)) {
CustomLintRule rule =
- parseCustomRule(ruleId, ruleData, RulePackIds.PROJECT,
RulePackOwner.PROJECT);
+ parseCustomRule(ruleId, ruleData, RulePackIds.PROJECT,
RulePackOwner.PROJECT, location);
projectRules.add(rule);
} else {
- overlays.put(ruleId,
ProjectYamlOverlay.ProjectRuleOverlay.fromMap(ruleData));
+ warnUnknownKeys(ruleId, location, ruleData, OVERRIDE_KEYS);
+ overlays.put(
+ ruleId, ProjectYamlOverlay.ProjectRuleOverlay.fromMap(ruleId,
ruleData, location));
}
}
return new ProjectYamlOverlay(projectRules, overlays, policy);
@@ -429,6 +473,18 @@ public final class YamlRulePackParser {
*/
public static CustomLintRule parseNativeRule(
String ruleId, Map<String, Object> ruleData, String packId,
RulePackOwner owner) {
+ return parseNativeRule(ruleId, ruleData, packId, owner, inPack(packId));
+ }
+
+ /**
+ * @param location where the rule is written, for the warnings: {@link
#inPack} or {@link #inFile}
+ */
+ private static CustomLintRule parseNativeRule(
+ String ruleId,
+ Map<String, Object> ruleData,
+ String packId,
+ RulePackOwner owner,
+ String location) {
CustomLintRule rule = new CustomLintRule();
rule.setId(ruleId);
rule.setPackId(packId);
@@ -440,11 +496,25 @@ public final class YamlRulePackParser {
rule.setSeverity(stringValue(ruleData.get("severity"), "WARNING"));
rule.setAppliesTo(stringListValue(ruleData.get("appliesTo")));
rule.setMessageKey(stringValue(ruleData.get("messageKey"), ""));
+ applyDocumentation(rule, ruleData, location);
+ warnUnknownKeys(ruleId, location, ruleData, NATIVE_RULE_KEYS);
return rule;
}
public static CustomLintRule parseCustomRule(
String ruleId, Map<String, Object> ruleData, String packId,
RulePackOwner owner) {
+ return parseCustomRule(ruleId, ruleData, packId, owner, inPack(packId));
+ }
+
+ /**
+ * @param location where the rule is written, for the warnings: {@link
#inPack} or {@link #inFile}
+ */
+ private static CustomLintRule parseCustomRule(
+ String ruleId,
+ Map<String, Object> ruleData,
+ String packId,
+ RulePackOwner owner,
+ String location) {
CustomLintRule customRule = new CustomLintRule();
customRule.setId(ruleId);
customRule.setPackId(packId);
@@ -481,9 +551,135 @@ public final class YamlRulePackParser {
if (parameters != null && !parameters.isEmpty()) {
customRule.setAdditionalParameters(new HashMap<>(parameters));
}
+ applyDocumentation(customRule, ruleData, location);
+ warnUnknownKeys(ruleId, location, ruleData, CUSTOM_RULE_KEYS);
return customRule;
}
+ /** Read the keys that describe a rule without changing what it checks: tags
and helpUri. */
+ private static void applyDocumentation(
+ CustomLintRule rule, Map<String, Object> ruleData, String location) {
+ String helpUri = stringValue(ruleData.get("helpUri"), null);
+ if (!Utils.isEmpty(helpUri)) {
+ rule.setHelpUri(helpUri.trim());
+ }
+ rule.setTags(tagsValue(ruleData.get("tags"), rule.getId(), location));
+ }
+
+ /**
+ * Read a {@code tags:} block. Each value is a string or a list of strings.
+ *
+ * <p>Anything else is logged and left out rather than failing the pack:
tags never change what a
+ * rule finds, so a malformed one is no reason to stop linting.
+ */
+ static Map<String, List<String>> tagsValue(Object value, String ruleId,
String location) {
+ return tagsValue(value, ruleId, location, false);
+ }
+
+ /**
+ * @param keepEmpty whether a key with no values is kept: in a project
override it removes the
+ * pack's tag of that key, on a rule it means nothing
+ */
+ static Map<String, List<String>> tagsValue(
+ Object value, String ruleId, String location, boolean keepEmpty) {
+ Map<String, List<String>> tags = new LinkedHashMap<>();
+ if (value == null) {
+ return tags;
+ }
+ if (!(value instanceof Map<?, ?> map)) {
+ warn(
+ "Warning: tags of rule '"
+ + ruleId
+ + "' "
+ + location
+ + " are not a mapping and are ignored.");
+ return tags;
+ }
+ for (Map.Entry<?, ?> entry : map.entrySet()) {
+ String key = entry.getKey() != null ? entry.getKey().toString().trim() :
"";
+ Object tagValue = entry.getValue();
+ if (key.isEmpty() || tagValue instanceof Map) {
+ warn(
+ "Warning: tag '"
+ + key
+ + "' of rule '"
+ + ruleId
+ + "' "
+ + location
+ + " is ignored: a tag is a string or a list of strings.");
+ continue;
+ }
+ // A value given twice is kept once: SARIF requires the tags of a rule
to be unique.
+ List<String> values = new ArrayList<>(new
LinkedHashSet<>(stringListValue(tagValue)));
+ if (keepEmpty || !values.isEmpty()) {
+ tags.put(key, values);
+ }
+ }
+ return tags;
+ }
+
+ /**
+ * Log every key on a rule that the linter does not read.
+ *
+ * <p>An unknown key is dropped, and a typo such as {@code tag:} for {@code
tags:} or {@code
+ * severty:} would otherwise change the rule with nothing to show for it.
+ */
+ private static void warnUnknownKeys(
+ String ruleId, String location, Map<String, Object> ruleData,
Set<String> knownKeys) {
+ unknownKeyWarnings(ruleId, location, ruleData,
knownKeys).forEach(YamlRulePackParser::warn);
+ }
+
+ /**
+ * One warning for each key on the rule that is not one of the known keys.
+ *
+ * @param location where the rule is written: {@link #inPack} or {@link
#inFile}
+ */
+ static List<String> unknownKeyWarnings(
+ String ruleId, String location, Map<String, Object> ruleData,
Set<String> knownKeys) {
+ List<String> warnings = new ArrayList<>();
+ if (ruleData == null) {
+ return warnings;
+ }
+ for (Object key : ruleData.keySet()) {
+ String name = String.valueOf(key);
+ if (knownKeys.contains(name)) {
+ continue;
+ }
+ StringBuilder warning =
+ new StringBuilder("Warning: rule '")
+ .append(ruleId)
+ .append("' ")
+ .append(location)
+ .append(" has an unknown key '")
+ .append(name)
+ .append("', which is ignored.");
+ String closest = RuleRegistry.closestId(name, knownKeys);
+ if (closest != null) {
+ warning.append(" Did you mean ").append(closest).append("?");
+ }
+ warnings.add(warning.toString());
+ }
+ return warnings;
+ }
+
+ /** Where a rule from a pack is written, for a warning about it. */
+ static String inPack(String packId) {
+ return RulePackIds.PROJECT.equals(packId) ? "in hop-lint.yml" : "in pack
'" + packId + "'";
+ }
+
+ /**
+ * Where a rule in a project's hop-lint.yml is written. Every project has a
file of that name, so
+ * the path says which one, and two projects with the same mistake are each
told about it.
+ */
+ static String inFile(File projectYaml) {
+ return "in " + projectYaml.getAbsolutePath();
+ }
+
+ /** The location is part of the text, so the same mistake in another file is
logged again. */
+ private static void warn(String warning) {
+ LintWarnings.logOnce(warning, warning);
+ }
+
private static String stringValue(Object value, String defaultValue) {
return value != null ? value.toString() : defaultValue;
}
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 db564b37d7..050fbcc81a 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
@@ -66,6 +66,12 @@ RuleBuilderDialog.Label.Condition=Condition:
RuleBuilderDialog.Label.Value=Value:
RuleBuilderDialog.Label.Severity=Severity:
RuleBuilderDialog.Checkbox.Enabled=Rule Enabled
+RuleBuilderDialog.Label.HelpUri=Help URL:
+RuleBuilderDialog.Label.Tags=Tags:
+RuleBuilderDialog.Tags.Tooltip=Labels written to the SARIF and JSON reports.
They do not change what the rule checks. Repeat a key to give it several values.
+RuleBuilderDialog.Column.TagKey=Key
+RuleBuilderDialog.Column.TagValue=Value
+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
@@ -83,6 +89,7 @@ RuleManagerDialog.Column.Condition=Condition
RuleManagerDialog.Column.Value=Value
RuleManagerDialog.Column.Severity=Severity
RuleManagerDialog.Column.Enabled=Enabled
+RuleManagerDialog.Column.Tags=Tags
RuleManagerDialog.Button.AddProjectRule=Add Project Rule
RuleManagerDialog.Button.EditRule=Edit Rule
RuleManagerDialog.Button.DeleteRule=Delete Rule
diff --git
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/CustomRuleExecutorTest.java
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/CustomRuleExecutorTest.java
index dd1d61dfd9..f34759a8ce 100644
---
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/CustomRuleExecutorTest.java
+++
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/CustomRuleExecutorTest.java
@@ -21,7 +21,9 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Arrays;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import org.apache.hop.pipeline.PipelineHopMeta;
import org.apache.hop.pipeline.PipelineMeta;
import org.apache.hop.pipeline.transform.TransformMeta;
@@ -62,6 +64,27 @@ public class CustomRuleExecutorTest {
assertTrue(results.get(0).getSource().getName().contains("A -> B"));
}
+ /** Tags are inert: they change nothing about the finding except that it
carries them. */
+ @Test
+ public void findingsCarryTheRuleTagsAndHelpUri() {
+ TransformMeta from = new TransformMeta();
+ from.setName("A");
+ TransformMeta to = new TransformMeta();
+ to.setName("B");
+ PipelineHopMeta hop = new PipelineHopMeta(from, to, false);
+
+ CustomLintRule rule = baseRule(RuleTarget.HOP, "enabled",
RuleCondition.MUST_BE_TRUE);
+ rule.setTags(new LinkedHashMap<>(Map.of("owner",
List.of("platform-team"))));
+ rule.setHelpUri("https://example.com/rules/hop");
+
+ LintRuleDetails details =
+ CustomRuleExecutor.executeRule(rule, hop,
"/tmp/test.hpl").get(0).getRuleDetails();
+
+ assertEquals(List.of("platform-team"), details.tags().get("owner"));
+ assertEquals("https://example.com/rules/hop", details.helpUri());
+ assertEquals("Test rule description", details.description());
+ }
+
@Test
public void enabledPipelineHopPasses() {
TransformMeta from = new TransformMeta();
diff --git
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintReportWriterTest.java
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintReportWriterTest.java
index 34264ddbdf..d9b9beae87 100644
---
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintReportWriterTest.java
+++
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintReportWriterTest.java
@@ -25,7 +25,9 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.file.Path;
import java.nio.file.Paths;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import org.junit.jupiter.api.Test;
/** The machine-readable reports are a contract with CI, so their shape is
pinned here. */
@@ -141,6 +143,129 @@ public class LintReportWriterTest {
assertEquals(0, json.get("summary").get("total").asInt());
}
+ private static List<LintResult> taggedResults() {
+ Map<String, List<String>> tags = new LinkedHashMap<>();
+ tags.put("category", List.of("secrets"));
+ tags.put("policy", List.of("SEC-POL-4", "SEC-POL-7"));
+ LintRuleDetails details =
+ new LintRuleDetails(
+ "Passwords come from a variable",
"https://example.com/rules/SEC-002", tags);
+ return List.of(
+ new LintResult(
+ "SEC-002",
+ "Hardcoded Secret",
+ "ERROR",
+ "Use a variable",
+ PROJECT.resolve("pipelines/load.hpl").toString(),
+ LintSourceRef.transform("REST"),
+ LintResult.Origin.LINT)
+ .withRuleDetails(details),
+ sampleResults().get(1));
+ }
+
+ /** GitHub code scanning and Azure DevOps read a flat list of key:value
strings. */
+ @Test
+ public void sarifWritesTagsDescriptionAndHelpUriOnTheRule() throws Exception
{
+ String sarif =
+ LintReportWriter.render(LintReportFormat.SARIF, taggedResults(),
"1.0.0", PROJECT);
+ JsonNode rules =
+ new ObjectMapper()
+ .readTree(sarif)
+ .get("runs")
+ .get(0)
+ .get("tool")
+ .get("driver")
+ .get("rules");
+
+ JsonNode tagged = rules.get(0);
+ assertEquals(
+ "Passwords come from a variable",
tagged.get("fullDescription").get("text").asText());
+ assertEquals("https://example.com/rules/SEC-002",
tagged.get("helpUri").asText());
+
+ JsonNode flat = tagged.get("properties").get("tags");
+ assertEquals(3, flat.size());
+ assertEquals("category:secrets", flat.get(0).asText());
+ assertEquals("policy:SEC-POL-4", flat.get(1).asText());
+ assertEquals("policy:SEC-POL-7", flat.get(2).asText());
+
+ JsonNode hopTags = tagged.get("properties").get("hopTags");
+ assertEquals("secrets", hopTags.get("category").get(0).asText());
+ assertEquals(2, hopTags.get("policy").size());
+
+ // A rule without tags, description or help link gets none of those keys.
+ JsonNode plain = rules.get(1);
+ assertFalse(plain.has("properties"));
+ assertFalse(plain.has("fullDescription"));
+ assertFalse(plain.has("helpUri"));
+ }
+
+ private static JsonNode sarifRules(List<LintResult> results) throws
Exception {
+ return new ObjectMapper()
+ .readTree(LintReportWriter.render(LintReportFormat.SARIF, results,
"1.0.0", PROJECT))
+ .get("runs")
+ .get(0)
+ .get("tool")
+ .get("driver")
+ .get("rules");
+ }
+
+ /**
+ * The rule is described by a finding that carries its details, even when
one without them came
+ * first, so the order of the results cannot hide the tags.
+ */
+ @Test
+ public void sarifDescribesTheRuleFromAFindingThatCarriesItsDetails() throws
Exception {
+ LintResult tagged = taggedResults().get(0);
+ LintResult bare =
+ new LintResult(
+ "SEC-002",
+ "Hardcoded Secret",
+ "ERROR",
+ "Use a variable",
+ PROJECT.resolve("pipelines/other.hpl").toString());
+
+ JsonNode rules = sarifRules(List.of(bare, tagged));
+
+ assertEquals(1, rules.size());
+ assertEquals("category:secrets",
rules.get(0).get("properties").get("tags").get(0).asText());
+ JsonNode results =
+ new ObjectMapper()
+ .readTree(
+ LintReportWriter.render(
+ LintReportFormat.SARIF, List.of(bare, tagged), "1.0.0",
PROJECT))
+ .get("runs")
+ .get(0)
+ .get("results");
+ assertEquals(0, results.get(0).get("ruleIndex").asInt());
+ assertEquals(0, results.get(1).get("ruleIndex").asInt());
+ }
+
+ /** The SARIF schema requires a rule's tags to be unique. */
+ @Test
+ public void sarifTagsAreUnique() throws Exception {
+ LintResult finding =
+ sampleResults()
+ .get(0)
+ .withRuleDetails(
+ new LintRuleDetails("", null, Map.of("policy",
List.of("SEC-POL-4", "SEC-POL-4"))));
+
+ JsonNode flat =
sarifRules(List.of(finding)).get(0).get("properties").get("tags");
+
+ assertEquals(1, flat.size());
+ assertEquals("policy:SEC-POL-4", flat.get(0).asText());
+ }
+
+ @Test
+ public void jsonWritesTheRuleTagsOnEachFinding() throws Exception {
+ String json = LintReportWriter.render(LintReportFormat.JSON,
taggedResults(), "1.0.0", PROJECT);
+ JsonNode findings = new ObjectMapper().readTree(json).get("findings");
+
+ JsonNode tags = findings.get(0).get("tags");
+ assertEquals("secrets", tags.get("category").get(0).asText());
+ assertEquals("SEC-POL-7", tags.get("policy").get(1).asText());
+ assertEquals(0, findings.get(1).get("tags").size(), "an untagged rule
writes an empty object");
+ }
+
@Test
public void formatParsingIsCaseInsensitiveAndRejectsUnknownValues() {
assertEquals(LintReportFormat.SARIF, LintReportFormat.parse("SARIF"));
diff --git
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/NativeCheckClassifierTest.java
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/NativeCheckClassifierTest.java
index cb98909311..ce37e149aa 100644
---
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/NativeCheckClassifierTest.java
+++
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/NativeCheckClassifierTest.java
@@ -23,7 +23,9 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.ArrayList;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import org.apache.hop.core.CheckResult;
import org.apache.hop.core.Const;
import org.apache.hop.core.ICheckResult;
@@ -359,6 +361,54 @@ public class NativeCheckClassifierTest {
"SelectValues");
}
+ /** The tags of the rule that classified a remark travel with the finding it
becomes. */
+ @Test
+ public void aNativeFindingCarriesTheTagsOfTheRuleThatClassifiedIt() {
+ CustomLintRule rule = nativeRule("HOP-CHECK", "WARNING", true);
+ rule.setTags(new LinkedHashMap<>(Map.of("category", List.of("verify"))));
+ NativeCheckClassifier classifier = new
NativeCheckClassifier(List.of(rule));
+
+ LintResult finding =
+ LintCheckResultAdapter.fromCheckResult(
+ remark(ICheckResult.TYPE_RESULT_ERROR, "boom"), "/tmp/a.hpl",
classifier);
+
+ assertEquals(List.of("verify"),
finding.getRuleDetails().tags().get("category"));
+ assertFalse(
+ LintCheckResultAdapter.fromCheckResult(
+ remark(ICheckResult.TYPE_RESULT_ERROR, "boom"), "/tmp/a.hpl")
+ .getRuleDetails()
+ .hasTags(),
+ "a remark no rule classified has no tags");
+ }
+
+ /**
+ * A check reported under its own error code carries the tags of the rule
that classified it, but
+ * not that rule's description or help link: those describe the rule, not
the code.
+ */
+ @Test
+ public void anErrorCodeFindingCarriesTheTagsButNotTheDescription() {
+ CustomLintRule rule = nativeRule("HOP-CHECK", "WARNING", true);
+ rule.setDescription("Every remark Hop's own checks produce");
+ rule.setHelpUri("https://example.com/HOP-CHECK");
+ rule.setTags(new LinkedHashMap<>(Map.of("category", List.of("verify"))));
+ NativeCheckClassifier classifier = new
NativeCheckClassifier(List.of(rule));
+
+ LintResult finding =
+ LintCheckResultAdapter.fromCheckResult(
+ new CheckResult(
+ ICheckResult.TYPE_RESULT_ERROR,
+ "CONNECTION_DOES_NOT_EXIST",
+ "no such connection",
+ new TransformMeta("TableInput", "read", null)),
+ "/tmp/a.hpl",
+ classifier);
+
+ assertEquals("CONNECTION_DOES_NOT_EXIST", finding.getRuleId());
+ assertEquals(List.of("verify"),
finding.getRuleDetails().tags().get("category"));
+ assertEquals("", finding.getRuleDetails().description());
+ assertNull(finding.getRuleDetails().helpUri());
+ }
+
private static ICheckResult remark(int type, String text) {
return remark(type, text, "SelectValues");
}
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
index d609256ea6..f4ae23edcb 100644
---
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
@@ -22,6 +22,9 @@ 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.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.hop.lint.registry.RuleRegistry;
import org.apache.hop.ui.testing.SwtBotTestBase;
@@ -66,4 +69,35 @@ class RuleBuilderDialogNativeRuleTest extends SwtBotTestBase
{
assertTrue(saved.get().isNativeVerify(), "still a native rule");
assertNull(saved.get().getTarget(), "and still without a target");
}
+
+ /**
+ * The tags table is filled from the rule and read back on OK, so a rule
saved without touching
+ * its tags keeps every one of them, a key with several values included.
+ */
+ @Test
+ void tagsAndHelpUrlSurviveTheEditor() {
+ CustomLintRule hopCheck =
+ RuleRegistry.getInstance().resolve(null).getRules().stream()
+ .filter(rule -> "HOP-CHECK".equals(rule.generateRuleId()))
+ .findFirst()
+ .orElseThrow()
+ .copy();
+ Map<String, List<String>> tags = new LinkedHashMap<>();
+ tags.put("category", List.of("verify"));
+ tags.put("policy", List.of("SEC-POL-4", "SEC-POL-7"));
+ hopCheck.setTags(new LinkedHashMap<>(tags));
+ AtomicReference<CustomLintRule> saved = new AtomicReference<>();
+
+ withDialog(
+ parent -> saved.set(new RuleBuilderDialog(parent, hopCheck).open()),
+ bot -> {
+ SWTBot dialog = bot.shell("Edit Lint Rule").bot();
+ dialog.textWithLabel("Help
URL:").setText("https://wiki.example.com/HOP-CHECK");
+ dialog.button("OK").click();
+ });
+
+ assertNotNull(saved.get(), "the dialog refused to save");
+ assertEquals(tags, saved.get().getTags());
+ assertEquals("https://wiki.example.com/HOP-CHECK",
saved.get().getHelpUri());
+ }
}
diff --git
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/registry/YamlRulePackParserTagsTest.java
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/registry/YamlRulePackParserTagsTest.java
new file mode 100644
index 0000000000..917c6322ac
--- /dev/null
+++
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/registry/YamlRulePackParserTagsTest.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.registry;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.lint.CustomLintRule;
+import org.junit.jupiter.api.Test;
+import org.yaml.snakeyaml.Yaml;
+
+/** Tags and help links on a rule, and the warning for keys the linter does
not read. */
+public class YamlRulePackParserTagsTest {
+
+ private static final String PACK =
+ """
+ pack:
+ id: acme
+ rules:
+ ACME-001:
+ type: custom
+ target: DATABASE_CONNECTION
+ targetField: password
+ condition: NO_HARDCODED
+ helpUri: https://example.com/rules/ACME-001
+ tags:
+ category: secrets
+ owner: platform-team
+ policy: ["SEC-POL-4", "SEC-POL-7"]
+ level: 3
+ ACME-002:
+ type: native
+ tags:
+ category: verify
+ ACME-003:
+ type: custom
+ target: PIPELINE
+ targetField: name
+ condition: NOT_EMPTY
+ """;
+
+ private static Map<String, CustomLintRule> load(String yaml) throws
Exception {
+ Map<String, CustomLintRule> rules = new LinkedHashMap<>();
+ for (CustomLintRule rule :
+ YamlRulePackParser.loadFromStream(
+ new ByteArrayInputStream(yaml.getBytes(StandardCharsets.UTF_8)),
+ "acme",
+ RulePackOwner.VENDOR)) {
+ rules.put(rule.getId(), rule);
+ }
+ return rules;
+ }
+
+ @Test
+ public void tagsAreReadAsListsInTheOrderTheyWereWritten() throws Exception {
+ CustomLintRule rule = load(PACK).get("ACME-001");
+
+ assertEquals(
+ List.of("category", "owner", "policy", "level"),
List.copyOf(rule.getTags().keySet()));
+ assertEquals(List.of("secrets"), rule.getTags().get("category"));
+ assertEquals(List.of("SEC-POL-4", "SEC-POL-7"),
rule.getTags().get("policy"));
+ assertEquals(List.of("3"), rule.getTags().get("level"));
+ assertEquals("https://example.com/rules/ACME-001", rule.getHelpUri());
+ }
+
+ @Test
+ public void nativeRulesCarryTagsToo() throws Exception {
+ assertEquals(Map.of("category", List.of("verify")),
load(PACK).get("ACME-002").getTags());
+ }
+
+ @Test
+ public void aRuleWithoutTagsHasNone() throws Exception {
+ CustomLintRule rule = load(PACK).get("ACME-003");
+
+ assertTrue(rule.getTags().isEmpty());
+ assertNull(rule.getHelpUri());
+ }
+
+ @Test
+ public void copyKeepsTagsAndHelpUriWithoutSharingThem() throws Exception {
+ CustomLintRule rule = load(PACK).get("ACME-001");
+ CustomLintRule copy = rule.copy();
+
+ assertEquals(rule.getTags(), copy.getTags());
+ assertEquals(rule.getHelpUri(), copy.getHelpUri());
+ copy.getTags().get("policy").add("SEC-POL-9");
+ assertEquals(2, rule.getTags().get("policy").size());
+ }
+
+ /** A rule saved from the rule manager keeps its tags and help link. */
+ @Test
+ public void tagsSurviveARoundTripThroughTheProjectFile() throws Exception {
+ CustomLintRule rule = load(PACK).get("ACME-001");
+ rule.setPackOwner(RulePackOwner.PROJECT);
+
+ Map<String, Object> entry = ProjectLintYamlExporter.entryFor(rule);
+ assertEquals("secrets", ((Map<?, ?>) entry.get("tags")).get("category"));
+
+ CustomLintRule reread =
+ YamlRulePackParser.parseCustomRule("ACME-001", entry, "project",
RulePackOwner.PROJECT);
+ assertEquals(rule.getTags(), reread.getTags());
+ assertEquals(rule.getHelpUri(), reread.getHelpUri());
+ }
+
+ /**
+ * A project sets a tag per key: its owner replaces the pack's owner, the
pack's other tags stay,
+ * and a key with no values removes the pack's tag.
+ */
+ @Test
+ public void aProjectOverrideReplacesTagsPerKey() throws Exception {
+ CustomLintRule rule = load(PACK).get("ACME-001");
+ Map<String, Object> override = new LinkedHashMap<>();
+ Map<String, Object> tags = new LinkedHashMap<>();
+ tags.put("owner", "data-team");
+ tags.put("level", List.of());
+ tags.put("team", List.of("crm", "sales"));
+ override.put("tags", tags);
+ override.put("helpUri", "https://wiki.example.com/ACME-001");
+
+ ProjectYamlOverlay.ProjectRuleOverlay.fromMap("ACME-001", override, "in
hop-lint.yml")
+ .applyTo(rule);
+
+ assertEquals(List.of("data-team"), rule.getTags().get("owner"));
+ assertEquals(List.of("secrets"), rule.getTags().get("category"));
+ assertEquals(List.of("SEC-POL-4", "SEC-POL-7"),
rule.getTags().get("policy"));
+ assertEquals(List.of("crm", "sales"), rule.getTags().get("team"));
+ assertTrue(!rule.getTags().containsKey("level"), "an empty key removes the
pack's tag");
+ assertEquals("https://wiki.example.com/ACME-001", rule.getHelpUri());
+ }
+
+ /** A bare helpUri: removes the pack's link, rather than becoming the text
"null". */
+ @Test
+ public void aBareHelpUriOverrideRemovesTheLink() throws Exception {
+ CustomLintRule rule = load(PACK).get("ACME-001");
+ Map<String, Object> override = new LinkedHashMap<>();
+ override.put("helpUri", null);
+
+ ProjectYamlOverlay.ProjectRuleOverlay.fromMap("ACME-001", override, "in
hop-lint.yml")
+ .applyTo(rule);
+
+ assertNull(rule.getHelpUri());
+ }
+
+ @Test
+ public void aValueGivenTwiceIsKeptOnce() {
+ assertEquals(
+ Map.of("policy", List.of("SEC-POL-4", "SEC-POL-7")),
+ YamlRulePackParser.tagsValue(
+ Map.of("policy", List.of("SEC-POL-4", "SEC-POL-7", "SEC-POL-4")),
+ "ACME-001",
+ YamlRulePackParser.inPack("acme")));
+ }
+
+ /**
+ * The rule manager writes only the keys the project changed, so the pack's
other tags follow it.
+ */
+ @Test
+ public void theExporterWritesOnlyTheChangedTagKeys() {
+ Map<String, List<String>> pack = new LinkedHashMap<>();
+ pack.put("category", List.of("secrets"));
+ pack.put("owner", List.of("platform-team"));
+ pack.put("level", List.of("3"));
+ Map<String, List<String>> desired = new LinkedHashMap<>();
+ desired.put("category", List.of("secrets"));
+ desired.put("owner", List.of("data-team"));
+ desired.put("team", List.of("crm", "sales"));
+
+ Map<String, Object> expected = new LinkedHashMap<>();
+ expected.put("owner", "data-team");
+ expected.put("team", List.of("crm", "sales"));
+ expected.put("level", List.of());
+ assertEquals(expected, ProjectLintYamlExporter.tagOverrides(desired,
pack));
+ }
+
+ /** Tagging a core rule in the rule manager is an override, not a copy of
the whole rule. */
+ @Test
+ public void taggingAPackRuleIsWrittenAsAnOverride() {
+ CustomLintRule rule =
+ RuleRegistry.getInstance().resolve(null).getRules().stream()
+ .filter(r -> "DB-001".equals(r.generateRuleId()))
+ .findFirst()
+ .orElseThrow()
+ .copy();
+ rule.getTags().put("owner", List.of("data-team"));
+
+ assertEquals(
+ Map.of("tags", Map.of("owner", "data-team")),
ProjectLintYamlExporter.entryFor(rule));
+ }
+
+ /** A nested mapping is not a tag. It is left out rather than failing the
whole pack. */
+ @Test
+ public void malformedTagsAreLeftOut() {
+ Map<String, Object> tags = new LinkedHashMap<>();
+ tags.put("owner", "platform-team");
+ tags.put("nested", Map.of("a", "b"));
+
+ assertEquals(
+ Map.of("owner", List.of("platform-team")),
+ YamlRulePackParser.tagsValue(tags, "ACME-001",
YamlRulePackParser.inPack("acme")));
+ assertTrue(
+ YamlRulePackParser.tagsValue("secrets", "ACME-001",
YamlRulePackParser.inPack("acme"))
+ .isEmpty());
+ }
+
+ /** A typo such as tag: for tags: would otherwise drop every tag without
anyone noticing. */
+ @Test
+ public void anUnknownKeyIsReportedWithTheClosestKnownOne() {
+ Map<String, Object> ruleData = new LinkedHashMap<>();
+ ruleData.put("type", "custom");
+ ruleData.put("tag", Map.of("owner", "platform-team"));
+
+ List<String> warnings =
+ YamlRulePackParser.unknownKeyWarnings(
+ "ACME-001",
+ YamlRulePackParser.inPack("acme"),
+ ruleData,
+ YamlRulePackParser.CUSTOM_RULE_KEYS);
+
+ assertEquals(
+ List.of(
+ "Warning: rule 'ACME-001' in pack 'acme' has an unknown key 'tag',
which is ignored."
+ + " Did you mean tags?"),
+ warnings);
+ }
+
+ @Test
+ public void knownKeysProduceNoWarning() {
+ Map<String, Object> ruleData = new LinkedHashMap<>();
+ for (String key : YamlRulePackParser.CUSTOM_RULE_KEYS) {
+ ruleData.put(key, "x");
+ }
+
+ assertTrue(
+ YamlRulePackParser.unknownKeyWarnings(
+ "ACME-001",
+ YamlRulePackParser.inPack("acme"),
+ ruleData,
+ YamlRulePackParser.CUSTOM_RULE_KEYS)
+ .isEmpty());
+ }
+
+ /**
+ * A project override that misspells a key used to leave the pack's rule
unchanged silently. Every
+ * project has a hop-lint.yml, so the warning names the file by its path:
the same typo in a
+ * second project is a second warning, not a repeat of the first.
+ */
+ @Test
+ public void anUnknownOverrideKeyNamesTheProjectFile() {
+ Map<String, Object> ruleData = Map.of("severty", "ERROR");
+ File crm = new File("/projects/crm/hop-lint.yml");
+ File sales = new File("/projects/sales/hop-lint.yml");
+
+ List<String> warnings =
+ YamlRulePackParser.unknownKeyWarnings(
+ "DB-001", YamlRulePackParser.inFile(crm), ruleData,
YamlRulePackParser.OVERRIDE_KEYS);
+
+ assertEquals(
+ List.of(
+ "Warning: rule 'DB-001' in "
+ + crm.getAbsolutePath()
+ + " has an unknown key 'severty', which is ignored. Did you
mean severity?"),
+ warnings);
+ assertNotEquals(
+ warnings,
+ YamlRulePackParser.unknownKeyWarnings(
+ "DB-001",
+ YamlRulePackParser.inFile(sales),
+ ruleData,
+ YamlRulePackParser.OVERRIDE_KEYS));
+ }
+
+ /** Every key the core pack uses has to be a known key, or Hop warns about
its own rules. */
+ @Test
+ public void theCorePackUsesOnlyKnownKeys() throws Exception {
+ try (var in =
+
YamlRulePackParser.class.getClassLoader().getResourceAsStream("hop-lint-core.yml"))
{
+ @SuppressWarnings("unchecked")
+ Map<String, Object> rules =
+ (Map<String, Object>) ((Map<String, Object>) new
Yaml().load(in)).get("rules");
+ for (Map.Entry<String, Object> entry : rules.entrySet()) {
+ @SuppressWarnings("unchecked")
+ Map<String, Object> ruleData = (Map<String, Object>) entry.getValue();
+ var known =
+ YamlRulePackParser.isNativeRuleDefinition(ruleData)
+ ? YamlRulePackParser.NATIVE_RULE_KEYS
+ : YamlRulePackParser.CUSTOM_RULE_KEYS;
+ assertEquals(
+ List.of(),
+ YamlRulePackParser.unknownKeyWarnings(
+ entry.getKey(), YamlRulePackParser.inPack("hop-core"),
ruleData, known));
+ }
+ }
+ }
+}