This is an automated email from the ASF dual-hosted git repository.
hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 306aefa5c3 Lint: check the staged files in the pre-commit hook,
resolve relative… (#9076)
306aefa5c3 is described below
commit 306aefa5c3dae17db4b1f6fc851fac9fbb4cd2ed
Author: Bart Maertens <[email protected]>
AuthorDate: Thu Oct 22 14:45:38 2026 +0200
Lint: check the staged files in the pre-commit hook, resolve relative…
(#9076)
* Lint: check the staged files in the pre-commit hook, resolve relative
paths from the working folder, support --fail-on INFO, validate rule values,
define the SYSTEM rules in the core pack, fixes #9071
* Lint: flag only rule conditions that can never fire, lint symlinked and
filtered files on disk in the pre-commit hook, fixes #9071
---
.../modules/ROOT/pages/hop-tools/hop-lint.adoc | 7 +-
.../modules/ROOT/pages/linting/lint-rules.adoc | 20 ++
.../ROOT/pages/workflow/actions/runlinter.adoc | 2 +-
.../java/org/apache/hop/lint/CustomLintRule.java | 19 ++
.../org/apache/hop/lint/ExplorerLintGuiPlugin.java | 20 +-
.../main/java/org/apache/hop/lint/HopLinter.java | 148 ++++++++++--
.../org/apache/hop/lint/HopMetadataFileLoader.java | 44 +++-
.../main/java/org/apache/hop/lint/LintCommand.java | 92 ++++++--
.../java/org/apache/hop/lint/LintSeverity.java | 24 +-
.../org/apache/hop/lint/PreCommitLintService.java | 92 ++++++--
.../org/apache/hop/lint/RuleBuilderDialog.java | 29 ++-
.../main/java/org/apache/hop/lint/RuleClause.java | 104 +++++++++
.../org/apache/hop/lint/RuleManagerDialog.java | 9 +
.../java/org/apache/hop/lint/RuleTargetFields.java | 79 +++++++
.../apache/hop/lint/registry/EffectiveRuleSet.java | 12 +-
.../hop/lint/registry/ProjectLintYamlExporter.java | 4 +-
.../hop/lint/registry/RulePackDiscovery.java | 4 +-
.../org/apache/hop/lint/registry/RuleRegistry.java | 32 +++
.../hop/lint/registry/YamlRulePackParser.java | 115 +++++++++-
.../misc/lint/src/main/resources/hop-lint-core.yml | 30 +++
.../hop/lint/messages/messages_en_US.properties | 7 +
.../apache/hop/lint/HopMetadataFileLoaderTest.java | 32 +++
.../apache/hop/lint/LintCommandOptionsTest.java | 249 +++++++++++++++++++++
.../java/org/apache/hop/lint/LintCommandTest.java | 135 ++++++++++-
.../apache/hop/lint/LintSeverityThresholdTest.java | 27 +++
.../apache/hop/lint/RuleBuilderValidationTest.java | 123 ++++++++++
.../org/apache/hop/lint/RuleEditorChoicesTest.java | 2 +-
.../hop/lint/registry/HopCoreRulePackTest.java | 4 +-
.../apache/hop/lint/registry/RuleRegistryTest.java | 127 ++++++++++-
29 files changed, 1475 insertions(+), 117 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 60f8a277ba..6ae394d785 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
@@ -41,6 +41,8 @@ hop lint --list-rules # show the effective
rule set
hop lint --help # full option list
----
+Relative paths, for the target and for options such as `--output`, `--config`
and `--baseline`, are relative to the folder the command is run from.
+
== Project and environment
`hop lint` takes the project and environment options of `hop run`.
@@ -68,7 +70,8 @@ A project chosen with `-j` or `-e` applies whatever is
linted; for a target outs
|Write the report to a file instead of stdout
|`--fail-on <severity>`
-|Exit with 1 when a finding reaches this severity. Default `ERROR`.
+|Exit with 1 when a finding reaches this severity: `ERROR` (the default),
`WARNING`, `INFO` or `NONE`.
+Any other value is refused.
|`--max-warnings <n>`
|Exit with 1 when more than `n` warnings are reported
@@ -170,6 +173,8 @@ hop lint --install-hook
This writes a `pre-commit` hook into the current repository's `.git/hooks`.
The hook lints the staged Hop files and blocks the commit when they fail.
+It lints the staged version of each file, which is what the commit holds, not
the one on disk, and it includes files renamed with `git mv`.
+Set `HOP_LINT_FAIL_ON` to change the threshold the hook fails on, for example
`HOP_LINT_FAIL_ON=WARNING`.
It is a copy, so run `hop lint --install-hook` again after upgrading Hop to
pick up changes to it.
Hop Gui can do the same for commits made from within Hop Gui. See
*Configuration -> Plugins -> Linter -> Block Git Commits on Lint Failures*,
which is *off by default*: installing and enabling the linter does not on its
own change how committing behaves.
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 ea016d7c01..be6068f081 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
@@ -119,6 +119,11 @@ Scope such a rule with `appliesTo`, naming the metadata
key — the folder name
|is a boolean with the required value
|===
+`conditionValue` is used as written: variables are not resolved in it.
+A value the condition cannot use, such as `abc` or `3,0` for `MAX_VALUE`, or
`[a-` for `MATCHES_PATTERN`, would make the rule report nothing at all.
+The rule editor refuses it, and a rule loaded from `hop-lint.yml` or a rule
pack gets a warning in the log that names the rule and the problem.
+The same goes for a condition that does not fit one of the fields the linter
provides, such as `NOT_NULL` on `transformCount`.
+
== Fields
`targetField` is the name of a property on the target. Besides the properties
Hop exposes, the linter computes a few of its own:
@@ -399,8 +404,23 @@ Hop's core rule pack is deliberately small. A rule is
enabled by default only wh
|`HOP-CHECK`
|WARNING
|Every remark from Hop's own transform and action checks
+
+|`SYSTEM-001`
+|ERROR
+|A pipeline, workflow or metadata file that could not be loaded, so nothing
was checked against it
+
+|`SYSTEM-002`
+|ERROR
+|A lint run that stopped before it finished
+
+|`SYSTEM-003`
+|ERROR
+|A metadata file, such as a connection, that could not be read
|===
+The `SYSTEM` rules are of `type: system`: like a native rule they check
nothing, and say how the linter reports a file it could not read or a run it
could not finish.
+A project can change their severity, or switch one off, in its `hop-lint.yml`
like any other rule.
+
`TRANS-002` and `WORKFLOW-002` report on the transform or action itself, so
the finding names it and the canvas can point at it.
The only element in a file is not an orphan — there is nothing for it to be
disconnected from — and neither is one whose hops are all disabled: it has
hops, and whether a disabled hop is a problem is what `STRUCT-003` asks.
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/workflow/actions/runlinter.adoc
b/docs/hop-user-manual/modules/ROOT/pages/workflow/actions/runlinter.adoc
index 530dcad5eb..54a3dea297 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/workflow/actions/runlinter.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/workflow/actions/runlinter.adoc
@@ -61,7 +61,7 @@ It does not change whether the action fails, nor the counts
in the variables. De
|Fail on
|`--fail-on`
-|Fail when a finding reaches this severity: `Error` (the default), `Warning`
or `None`.
+|Fail when a finding reaches this severity: `Error` (the default), `Warning`,
`Info` or `None`.
|Max warnings
|`--max-warnings`
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 3c367ac14b..8bcaefc797 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
@@ -107,6 +107,12 @@ public class CustomLintRule {
*/
public static final String TYPE_NATIVE = "native";
+ /**
+ * A finding the linter raises about the run itself, such as a file it could
not read. The rule
+ * only says whether that is reported and at what severity.
+ */
+ public static final String TYPE_SYSTEM = "system";
+
public CustomLintRule() {
this.id = UUID.randomUUID().toString();
this.enabled = true;
@@ -335,6 +341,19 @@ public class CustomLintRule {
return TYPE_NATIVE.equalsIgnoreCase(type);
}
+ /** Whether this rule covers a finding the linter raises about the run, such
as SYSTEM-001. */
+ public boolean isSystem() {
+ return TYPE_SYSTEM.equalsIgnoreCase(type);
+ }
+
+ /**
+ * Whether this rule has no target or condition, and only says how a finding
produced elsewhere is
+ * reported: a native or a system rule.
+ */
+ public boolean isReportOnly() {
+ return isNativeVerify() || isSystem();
+ }
+
public List<RuleClause> getClauses() {
List<RuleClause> clauses = new ArrayList<>();
clauses.add(new RuleClause(targetField, condition, conditionValue));
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/ExplorerLintGuiPlugin.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/ExplorerLintGuiPlugin.java
index 22e778f4af..7215ac7184 100644
---
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/ExplorerLintGuiPlugin.java
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/ExplorerLintGuiPlugin.java
@@ -588,17 +588,19 @@ public class ExplorerLintGuiPlugin {
processedFilesCount++;
} catch (Exception e) {
log.logError("Error processing file: " +
file.getAbsolutePath(), e);
- LintResult errorResult =
- new LintResult(
- "SYSTEM-001",
- "File Processing Error",
- "ERROR",
- "Failed to process file: " + e.getMessage(),
- LintPathUtils.normalizePath(file.getAbsolutePath()));
- results.add(errorResult);
+ List<LintResult> errorResults =
+ linter
+ .systemFinding(
+ HopLinter.SYSTEM_FILE_PROCESSING,
+ "Failed to process file: " + e.getMessage(),
+
LintPathUtils.normalizePath(file.getAbsolutePath()),
+ null)
+ .map(List::of)
+ .orElse(List.of());
+ results.addAll(errorResults);
service.publish(
LintPathUtils.normalizePath(file.getAbsolutePath()),
- List.of(errorResult),
+ errorResults,
passGeneration);
processedFilesCount++;
}
diff --git a/plugins/misc/lint/src/main/java/org/apache/hop/lint/HopLinter.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/HopLinter.java
index 1b91cf0e36..f17d2b2f86 100644
--- a/plugins/misc/lint/src/main/java/org/apache/hop/lint/HopLinter.java
+++ b/plugins/misc/lint/src/main/java/org/apache/hop/lint/HopLinter.java
@@ -18,6 +18,7 @@ package org.apache.hop.lint;
import java.io.File;
import java.io.IOException;
+import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -25,6 +26,7 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.Set;
import java.util.stream.Stream;
import org.apache.hop.core.ICheckResult;
@@ -35,6 +37,7 @@ import org.apache.hop.core.logging.LogChannel;
import org.apache.hop.core.util.Utils;
import org.apache.hop.core.variables.IVariables;
import org.apache.hop.core.variables.Variables;
+import org.apache.hop.core.xml.XmlHandler;
import org.apache.hop.lint.registry.EffectiveRuleSet;
import org.apache.hop.lint.registry.LintConfigurationException;
import org.apache.hop.lint.registry.RuleRegistry;
@@ -45,6 +48,8 @@ import org.apache.hop.pipeline.transform.TransformMeta;
import org.apache.hop.workflow.WorkflowHopMeta;
import org.apache.hop.workflow.WorkflowMeta;
import org.apache.hop.workflow.action.ActionMeta;
+import org.w3c.dom.Document;
+import org.w3c.dom.Node;
/**
* Main engine class that orchestrates the entire linting process. This class
loads configuration,
@@ -54,6 +59,25 @@ public class HopLinter {
private static final ILogChannel log = LogChannel.GENERAL;
+ /** A file that could not be read or linted. */
+ public static final String SYSTEM_FILE_PROCESSING = "SYSTEM-001";
+
+ /** A lint run that could not be completed. */
+ public static final String SYSTEM_CONFIGURATION = "SYSTEM-002";
+
+ /** A metadata file that could not be read. */
+ public static final String SYSTEM_UNREADABLE_METADATA = "SYSTEM-003";
+
+ /**
+ * The names the system findings carry when no rule pack defines them, as
when the linter is
+ * switched off. The core pack defines all three.
+ */
+ private static final Map<String, String> SYSTEM_RULE_NAMES =
+ Map.of(
+ SYSTEM_FILE_PROCESSING, "File Processing Error",
+ SYSTEM_CONFIGURATION, "Configuration Error",
+ SYSTEM_UNREADABLE_METADATA, "Unreadable Metadata");
+
private LinterConfig config;
private EffectiveRuleSet effectiveRuleSet;
@@ -314,13 +338,12 @@ public class HopLinter {
"Error processing file: " + file.getAbsolutePath() + " - " +
e.getMessage(), e);
// Add an error result for the file that couldn't be processed
- allResults.add(
- new LintResult(
- "SYSTEM-001",
- "File Processing Error",
- "ERROR",
+ systemFinding(
+ SYSTEM_FILE_PROCESSING,
"Failed to process file: " + e.getMessage(),
- file.getName()));
+ file.getName(),
+ null)
+ .ifPresent(allResults::add);
}
}
@@ -342,13 +365,9 @@ public class HopLinter {
} catch (Exception e) {
log.logError("Error during linting process: " + e.getMessage(), e);
- allResults.add(
- new LintResult(
- "SYSTEM-002",
- "Configuration Error",
- "ERROR",
- "Failed to complete linting: " + e.getMessage(),
- "system"));
+ systemFinding(
+ SYSTEM_CONFIGURATION, "Failed to complete linting: " +
e.getMessage(), "system", null)
+ .ifPresent(allResults::add);
} finally {
if (ownIndex) {
CustomRuleExecutor.setProjectIndex(null);
@@ -513,27 +532,38 @@ public class HopLinter {
/** Process a single Hop file and run all enabled rules against it */
public List<LintResult> processFile(
File file, IHopMetadataProvider metadataProvider, IVariables variables)
throws HopException {
+ return processFile(file, null, metadataProvider, variables);
+ }
+
+ /**
+ * Process a file, reading it from another file: the pre-commit hook lints
the staged version of a
+ * file, which can differ from the one on disk, but reports it as the file
in the repository.
+ *
+ * @param content the file to read, or null to read {@code file}
+ */
+ public List<LintResult> processFile(
+ File file, File content, IHopMetadataProvider metadataProvider,
IVariables variables)
+ throws HopException {
String fileName = LintPathUtils.normalizePath(file.getAbsolutePath());
log.logDetailed("Processing file: " + file.getName());
ensureConfigLoaded();
if (HopMetadataFileLoader.isMetadataJsonFile(fileName)) {
- HopMetadataFileLoader.MetadataLoad load =
HopMetadataFileLoader.read(file, metadataProvider);
+ HopMetadataFileLoader.MetadataLoad load =
+ HopMetadataFileLoader.read(file, content, metadataProvider);
if (load.isFailure()) {
// Report it. A metadata file that cannot be read passes every rule,
so staying quiet
// here would let a corrupt connection be reported as a clean one.
- return List.of(
- new LintResult(
- "SYSTEM-003",
- "Unreadable Metadata",
- "ERROR",
+ return systemFinding(
+ SYSTEM_UNREADABLE_METADATA,
"This metadata file could not be read, so no rule could be
checked against"
+ " it: "
+ load.error(),
fileName,
-
LintSourceRef.metadata(HopMetadataFileLoader.metadataNameOf(file)),
- LintResult.Origin.LINT));
+
LintSourceRef.metadata(HopMetadataFileLoader.metadataNameOf(file)))
+ .map(List::of)
+ .orElse(List.of());
}
if (load.object() == null) {
return List.of();
@@ -542,7 +572,7 @@ public class HopLinter {
runPolicyRules(load.object(), fileName), null, fileName, variables,
metadataProvider);
}
- Object hopObject = loadHopObject(file, metadataProvider, variables,
fileName);
+ Object hopObject = loadHopObject(file, content, metadataProvider,
variables, fileName);
if (hopObject == null) {
return List.of();
}
@@ -673,9 +703,16 @@ public class HopLinter {
}
private Object loadHopObject(
- File file, IHopMetadataProvider metadataProvider, IVariables variables,
String fileName)
+ File file,
+ File content,
+ IHopMetadataProvider metadataProvider,
+ IVariables variables,
+ String fileName)
throws HopException {
try {
+ if (content != null) {
+ return loadHopObjectFrom(content, file, metadataProvider, variables,
fileName);
+ }
if (fileName.toLowerCase().endsWith(".hpl")) {
return new PipelineMeta(file.getAbsolutePath(), metadataProvider,
variables);
}
@@ -690,6 +727,45 @@ public class HopLinter {
}
}
+ /**
+ * Load a pipeline or workflow from {@code content} as if it were {@code
file}, so that the
+ * filename, and the internal variables derived from it, are those of the
file in the repository.
+ */
+ private static Object loadHopObjectFrom(
+ File content,
+ File file,
+ IHopMetadataProvider metadataProvider,
+ IVariables variables,
+ String fileName)
+ throws HopException {
+ boolean pipeline = fileName.toLowerCase().endsWith(".hpl");
+ if (!pipeline && !fileName.toLowerCase().endsWith(".hwf")) {
+ log.logError("Unknown file type: " + fileName);
+ return null;
+ }
+ Document document;
+ try (InputStream inputStream = Files.newInputStream(content.toPath())) {
+ document = XmlHandler.loadXmlFile(inputStream, null, false, false);
+ } catch (IOException e) {
+ throw new HopException("Could not read " + content, e);
+ }
+ String tag = pipeline ? PipelineMeta.XML_TAG : WorkflowMeta.XML_TAG;
+ Node node = XmlHandler.getSubNode(document, tag);
+ if (node == null) {
+ throw new HopException("No <" + tag + "> element in " + fileName);
+ }
+ if (pipeline) {
+ PipelineMeta pipelineMeta = new PipelineMeta();
+ pipelineMeta.setMetadataProvider(metadataProvider);
+ pipelineMeta.loadXml(node, file.getAbsolutePath(), metadataProvider,
variables);
+ return pipelineMeta;
+ }
+ WorkflowMeta workflowMeta = new WorkflowMeta();
+ workflowMeta.setMetadataProvider(metadataProvider);
+ workflowMeta.loadXml(node, file.getAbsolutePath(), metadataProvider,
variables);
+ return workflowMeta;
+ }
+
private List<LintResult> finalizeResults(
List<LintResult> policyResults,
Object hopObject,
@@ -887,6 +963,32 @@ public class HopLinter {
return results;
}
+ /**
+ * A finding about the run itself, such as a file that could not be read,
with the name and
+ * severity the rule packs and the project give it.
+ *
+ * <p>These were hardcoded at ERROR, so a project could neither see them in
the rule list nor
+ * change their severity.
+ *
+ * @param ruleId one of the {@code SYSTEM_} ids
+ * @return the finding, or empty when the project switched the rule off
+ */
+ public Optional<LintResult> systemFinding(
+ String ruleId, String message, String fileName, LintSourceRef source) {
+ String name = SYSTEM_RULE_NAMES.getOrDefault(ruleId, ruleId);
+ String severity = "ERROR";
+ CustomLintRule rule = getEffectiveRuleSet().findRule(ruleId);
+ if (rule != null) {
+ if (!rule.isEnabled()) {
+ return Optional.empty();
+ }
+ name = Utils.isEmpty(rule.getName()) ? name : rule.getName();
+ severity = Utils.isEmpty(rule.getSeverity()) ? severity :
rule.getSeverity();
+ }
+ return Optional.of(
+ new LintResult(ruleId, name, severity, message, fileName, source,
LintResult.Origin.LINT));
+ }
+
public EffectiveRuleSet getEffectiveRuleSet() {
ensureEffectiveRuleSet();
return effectiveRuleSet;
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/HopMetadataFileLoader.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/HopMetadataFileLoader.java
index 3b06c9510a..ecbe1f23d5 100644
---
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/HopMetadataFileLoader.java
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/HopMetadataFileLoader.java
@@ -16,13 +16,18 @@
*/
package org.apache.hop.lint;
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.JsonNode;
import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.Iterator;
import org.apache.hop.core.database.DatabaseMeta;
+import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.json.HopJson;
import org.apache.hop.core.logging.ILogChannel;
import org.apache.hop.core.logging.LogChannel;
@@ -30,6 +35,7 @@ import org.apache.hop.core.util.Utils;
import org.apache.hop.metadata.api.IHopMetadata;
import org.apache.hop.metadata.api.IHopMetadataProvider;
import org.apache.hop.metadata.api.IHopMetadataSerializer;
+import org.apache.hop.metadata.serializer.json.JsonMetadataParser;
/**
* Loads Hop metadata objects from project metadata JSON files.
@@ -137,7 +143,17 @@ public final class HopMetadataFileLoader {
* which is the one outcome a linter must never produce.
*/
public static MetadataLoad read(File file, IHopMetadataProvider
metadataProvider) {
- if (file == null || !file.isFile()) {
+ return read(file, null, metadataProvider);
+ }
+
+ /**
+ * Read a metadata file from another file, as the pre-commit hook does with
the staged version.
+ *
+ * @param file the file in the project, which gives the type and the name
+ * @param content the file to read, or null to read {@code file}
+ */
+ public static MetadataLoad read(File file, File content,
IHopMetadataProvider metadataProvider) {
+ if (file == null || !(content != null ? content : file).isFile()) {
return MetadataLoad.skipped();
}
@@ -163,9 +179,14 @@ public final class HopMetadataFileLoader {
return MetadataLoad.skipped();
}
try {
- IHopMetadataSerializer<IHopMetadata> serializer =
- metadataProvider.getSerializer(managedClass);
- IHopMetadata loaded = serializer.load(name);
+ IHopMetadata loaded;
+ if (content != null) {
+ loaded = parse(content, managedClass, metadataProvider);
+ } else {
+ IHopMetadataSerializer<IHopMetadata> serializer =
+ metadataProvider.getSerializer(managedClass);
+ loaded = serializer.load(name);
+ }
if (loaded != null) {
return MetadataLoad.of(named(loaded, name));
}
@@ -178,7 +199,7 @@ public final class HopMetadataFileLoader {
// Best effort without a provider. Only relational connections can be
reconstructed by hand;
// for anything else the plugin class is what gives the document meaning.
if ("rdbms".equalsIgnoreCase(key)) {
- DatabaseMeta databaseMeta = readDatabaseMetaDirectly(file);
+ DatabaseMeta databaseMeta = readDatabaseMetaDirectly(content != null ?
content : file);
return databaseMeta != null
? MetadataLoad.of(named(databaseMeta, name))
: MetadataLoad.failed("could not be parsed as a database
connection");
@@ -189,6 +210,19 @@ public final class HopMetadataFileLoader {
return MetadataLoad.skipped();
}
+ /** Parse a metadata document the way the JSON serializer does, from any
file. */
+ private static IHopMetadata parse(
+ File content, Class<IHopMetadata> managedClass, IHopMetadataProvider
metadataProvider)
+ throws IOException, HopException {
+ try (InputStream inputStream = Files.newInputStream(content.toPath());
+ JsonParser jsonParser = new JsonFactory().createParser(inputStream)) {
+ // skip opening '{'
+ jsonParser.nextToken();
+ return new JsonMetadataParser<>(managedClass, metadataProvider)
+ .loadJsonObject(managedClass, jsonParser);
+ }
+ }
+
/** The managed class for a metadata key, or null when no plugin registers
that key. */
private static Class<IHopMetadata> managedClassFor(
IHopMetadataProvider metadataProvider, String key) {
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintCommand.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintCommand.java
index 371d4c7c5b..34f883b2d8 100644
--- a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintCommand.java
+++ b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintCommand.java
@@ -126,7 +126,9 @@ public class LintCommand implements Callable<Integer>,
IHopCommand, IHasHopMetad
@Option(
names = "--fail-on",
paramLabel = "<severity>",
- description = "Exit 1 when a finding reaches this severity. Default:
${DEFAULT-VALUE}.")
+ description =
+ "Exit 1 when a finding reaches this severity: ERROR, WARNING, INFO
or NONE. Default:"
+ + " ${DEFAULT-VALUE}.")
private String failOn = "ERROR";
@Option(
@@ -246,6 +248,11 @@ public class LintCommand implements Callable<Integer>,
IHopCommand, IHasHopMetad
}
private int run() throws Exception {
+ resolvePathOptions();
+ // Refused before anything runs: a typo here used to read as ERROR and
pass a build with
+ // warnings.
+ LintSeverity.parseFailOn(failOn);
+
if (installHook) {
installGitHook();
return 0;
@@ -272,7 +279,7 @@ public class LintCommand implements Callable<Integer>,
IHopCommand, IHasHopMetad
}
if (Utils.isEmpty(target)) {
- target = System.getProperty("target");
+ target = resolveAgainstOrigin(System.getProperty("target"));
}
if (Utils.isEmpty(target)) {
target = userDirectory();
@@ -603,6 +610,7 @@ public class LintCommand implements Callable<Integer>,
IHopCommand, IHasHopMetad
String envFailOn = System.getenv("HOP_LINT_FAIL_ON");
if (!Utils.isEmpty(envFailOn)) {
failOn = envFailOn.trim().toUpperCase();
+ LintSeverity.parseFailOn(failOn);
}
if (Utils.isEmpty(stagedFileList)) {
@@ -610,7 +618,7 @@ public class LintCommand implements Callable<Integer>,
IHopCommand, IHasHopMetad
}
prepare();
- List<File> stagedFiles =
+ List<PreCommitLintService.StagedFile> stagedFiles =
PreCommitLintService.readStagedFiles(stagedFileList, new
File(userDirectory()));
if (stagedFiles.isEmpty()) {
if (!quiet) {
@@ -618,28 +626,29 @@ public class LintCommand implements Callable<Integer>,
IHopCommand, IHasHopMetad
}
return 0;
}
+ File firstFile = stagedFiles.get(0).file();
// As strict as a plain run: a commit is not checked against default rules
because the
// project's hop-lint.yml or an installed pack could not be read.
LintRun.checkRulePacks();
HopLinter linter = new HopLinter();
- linter.loadConfigurationStrictly(stagedFiles.get(0));
+ linter.loadConfigurationStrictly(firstFile);
// The lint target for path-relative purposes is the project the staged
files live in.
- target = projectRootOf(stagedFiles.get(0));
- applyProjectTo(stagedFiles.get(0).getPath());
+ target = projectRootOf(firstFile);
+ applyProjectTo(firstFile.getPath());
IVariables variables = variables();
// Without a metadata provider a pipeline will not load at all, and
connection rules cannot
// resolve — the hook would pass commits it should have blocked.
- IHopMetadataProvider metadataProvider =
resolveMetadataProvider(stagedFiles.get(0), variables);
+ IHopMetadataProvider metadataProvider = resolveMetadataProvider(firstFile,
variables);
// A file the project excludes should not block a commit either.
- List<File> toLint = new ArrayList<>();
- for (File staged : stagedFiles) {
- if (linter.getPolicy().isExcluded(staged.getAbsolutePath(),
reportBaseDirectory())) {
+ List<PreCommitLintService.StagedFile> toLint = new ArrayList<>();
+ for (PreCommitLintService.StagedFile staged : stagedFiles) {
+ if (linter.getPolicy().isExcluded(staged.file().getAbsolutePath(),
reportBaseDirectory())) {
if (verbose) {
- System.out.println("Skipping excluded file: " + staged);
+ System.out.println("Skipping excluded file: " + staged.file());
}
continue;
}
@@ -653,7 +662,7 @@ public class LintCommand implements Callable<Integer>,
IHopCommand, IHasHopMetad
}
PreCommitLintService.Result result =
- PreCommitLintService.lintFiles(
+ PreCommitLintService.lintStagedFiles(
toLint, LintSeverity.parseFailOn(failOn), variables,
metadataProvider, true);
// The baseline matters most here: touching a legacy file would otherwise
block the commit
@@ -889,6 +898,29 @@ public class LintCommand implements Callable<Integer>,
IHopCommand, IHasHopMetad
return Utils.isEmpty(origin) ? System.getProperty("user.dir") : origin;
}
+ /**
+ * Make the target and every file option relative to where the command was
typed.
+ *
+ * <p>Java resolves a relative path against {@code user.dir}, which under
the launcher is the Hop
+ * installation: {@code hop lint .} linted the installation, {@code hop lint
naming.hpl} was not
+ * found, and {@code -o lint.sarif} wrote the report inside the installation.
+ */
+ void resolvePathOptions() {
+ target = resolveAgainstOrigin(target);
+ configFile = resolveAgainstOrigin(configFile);
+ outputFile = resolveAgainstOrigin(outputFile);
+ baselineFile = resolveAgainstOrigin(baselineFile);
+ writeBaselineFile = resolveAgainstOrigin(writeBaselineFile);
+ stagedFileList = resolveAgainstOrigin(stagedFileList);
+ }
+
+ private static String resolveAgainstOrigin(String path) {
+ if (Utils.isEmpty(path) || new File(path).isAbsolute()) {
+ return path;
+ }
+ return Paths.get(userDirectory()).resolve(path).normalize().toString();
+ }
+
private void installGitHook() throws IOException {
// Resolve from what is being linted, never from the working directory:
under the launcher that
// is the Hop installation, and the hook would land in whatever repository
Hop itself sits in.
@@ -931,18 +963,36 @@ public class LintCommand implements Callable<Integer>,
IHopCommand, IHasHopMetad
*/
String hookScript() {
// git lists staged files relative to the repository root, and the hop
launcher changes to the
- // Hop installation before it starts Java, so the paths are made absolute
here. The trap
- // removes the list however the script ends; the last command's status is
the hook's.
+ // Hop installation before it starts Java, so the paths are made absolute
here. Each line pairs
+ // a file with a copy of its staged version: the commit holds what is
staged, not what is on
+ // disk. Renamed files (R) are linted like added ones. A symlink's staged
blob is the link's
+ // target, and a filtered file's (Git LFS) is a pointer, so those are
linted on disk. The traps
+ // remove the copies however the script ends, Ctrl-C included; the last
command's status is the
+ // hook's.
return """
#!/bin/sh
ROOT="$(git rev-parse --show-toplevel)" || exit 1
- STAGED_LIST="$(mktemp)" || exit 1
- trap 'rm -f "$STAGED_LIST"' EXIT
- git diff --cached --name-only --diff-filter=ACM | while IFS= read -r
FILE; do
- case "$FILE" in
- *.hpl|*.hwf|metadata/*.json|*/metadata/*.json) printf '%s/%s\\n'
"$ROOT" "$FILE" ;;
- esac
- done > "$STAGED_LIST"
+ STAGED_DIR="$(mktemp -d)" || exit 1
+ trap 'rm -rf "$STAGED_DIR"' EXIT
+ trap 'exit 1' HUP INT TERM
+ STAGED_LIST="$STAGED_DIR/staged-files"
+ git -c core.quotePath=false diff --cached --name-only
--diff-filter=ACMR | {
+ N=0
+ while IFS= read -r FILE; do
+ case "$FILE" in
+ *.hpl|*.hwf|metadata/*.json|*/metadata/*.json)
+ MODE="$(git ls-files -s -- "$FILE" | head -n 1 | cut -d ' ' -f
1)"
+ case "$MODE:$(git check-attr filter -- "$FILE")" in
+ 120000:*) printf '%s/%s\\n' "$ROOT" "$FILE" ;;
+ *": filter: unspecified"|*": filter: unset")
+ N=$((N + 1))
+ git show ":$FILE" > "$STAGED_DIR/$N" || exit 1
+ printf '%s/%s\\t%s/%s\\n' "$ROOT" "$FILE" "$STAGED_DIR"
"$N" ;;
+ *) printf '%s/%s\\n' "$ROOT" "$FILE" ;;
+ esac ;;
+ esac
+ done
+ } > "$STAGED_LIST" || exit 1
if [ ! -s "$STAGED_LIST" ]; then
exit 0
fi
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintSeverity.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintSeverity.java
index 7a7db6b3e5..78bde14ab9 100644
--- a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintSeverity.java
+++ b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintSeverity.java
@@ -16,6 +16,8 @@
*/
package org.apache.hop.lint;
+import java.util.Arrays;
+import java.util.stream.Collectors;
import org.apache.hop.core.ICheckResult;
import org.apache.hop.core.util.Utils;
import org.apache.hop.i18n.BaseMessages;
@@ -27,6 +29,7 @@ public final class LintSeverity {
public enum FailOn implements IEnumHasCodeAndDescription {
ERROR,
WARNING,
+ INFO,
NONE;
@Override
@@ -59,6 +62,15 @@ public final class LintSeverity {
private LintSeverity() {}
+ /**
+ * The fail-on threshold for a value given on the command line or in {@code
HOP_LINT_FAIL_ON}.
+ *
+ * <p>An unknown value is refused. Reading a typo such as {@code WARNNG} as
ERROR passed a build
+ * that should have failed on its warnings, without a word.
+ *
+ * @return ERROR when the value is empty
+ * @throws IllegalArgumentException when the value is not a threshold
+ */
public static FailOn parseFailOn(String value) {
if (Utils.isEmpty(value)) {
return FailOn.ERROR;
@@ -66,7 +78,12 @@ public final class LintSeverity {
try {
return FailOn.valueOf(value.trim().toUpperCase());
} catch (IllegalArgumentException e) {
- return FailOn.ERROR;
+ throw new IllegalArgumentException(
+ "Unknown fail-on severity '"
+ + value.trim()
+ + "'. Use one of: "
+ +
Arrays.stream(FailOn.values()).map(FailOn::name).collect(Collectors.joining(",
"))
+ + ".");
}
}
@@ -80,7 +97,10 @@ public final class LintSeverity {
if ("ERROR".equalsIgnoreCase(severity)) {
return true;
}
- return failOn == FailOn.WARNING && "WARNING".equalsIgnoreCase(severity);
+ if ("WARNING".equalsIgnoreCase(severity)) {
+ return failOn == FailOn.WARNING || failOn == FailOn.INFO;
+ }
+ return failOn == FailOn.INFO && "INFO".equalsIgnoreCase(severity);
}
public static int toCheckResultType(String severity) {
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/PreCommitLintService.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/PreCommitLintService.java
index 34cd1c0136..022935755e 100644
---
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/PreCommitLintService.java
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/PreCommitLintService.java
@@ -53,6 +53,21 @@ public final class PreCommitLintService {
}
}
+ /**
+ * A file in the commit, and the copy of its staged version that the git
hook made.
+ *
+ * @param file the file in the working tree, which decides the
configuration, the project and the
+ * path findings are reported against
+ * @param content the staged version to read, or null to read {@code file}
+ */
+ public record StagedFile(File file, File content) {
+
+ /** What is linted: the staged copy when there is one, the file on disk
otherwise. */
+ public File source() {
+ return content != null ? content : file;
+ }
+ }
+
private PreCommitLintService() {}
public static Result lintFiles(
@@ -75,20 +90,40 @@ public final class PreCommitLintService {
IVariables variables,
IHopMetadataProvider metadataProvider,
boolean strict) {
+ List<StagedFile> staged = new ArrayList<>();
+ for (File file : files) {
+ staged.add(new StagedFile(file, null));
+ }
+ return lintStagedFiles(staged, failOn, variables, metadataProvider,
strict);
+ }
+
+ /**
+ * Lint the staged version of each file where the git hook copied one.
+ *
+ * @see #lintFiles(List, LintSeverity.FailOn, IVariables,
IHopMetadataProvider, boolean)
+ */
+ public static Result lintStagedFiles(
+ List<StagedFile> stagedFiles,
+ LintSeverity.FailOn failOn,
+ IVariables variables,
+ IHopMetadataProvider metadataProvider,
+ boolean strict) {
List<LintResult> allResults = new ArrayList<>();
HopLinter linter = new HopLinter();
- for (File file : files) {
- if (file == null || !file.exists()) {
+ for (StagedFile staged : stagedFiles) {
+ if (staged == null || staged.file() == null ||
!staged.source().isFile()) {
continue;
}
+ File file = staged.file();
try {
if (strict) {
// lintFile() would load the configuration again, leniently.
linter.loadConfigurationStrictly(file);
- allResults.addAll(linter.processFile(file, metadataProvider,
variables));
+ allResults.addAll(
+ linter.processFile(file, staged.content(), metadataProvider,
variables));
} else {
- if (files.size() == 1 || allResults.isEmpty()) {
+ if (stagedFiles.size() == 1 || allResults.isEmpty()) {
linter.loadConfigurationForContext(file);
}
allResults.addAll(linter.lintFile(file.getAbsolutePath(),
metadataProvider, variables));
@@ -97,13 +132,13 @@ public final class PreCommitLintService {
throw e;
} catch (Exception e) {
log.logError("Error linting file " + file.getAbsolutePath() + ": " +
e.getMessage(), e);
- allResults.add(
- new LintResult(
- "SYSTEM-001",
- "File Processing Error",
- "ERROR",
+ linter
+ .systemFinding(
+ HopLinter.SYSTEM_FILE_PROCESSING,
"Failed to process file: " + e.getMessage(),
- LintPathUtils.normalizePath(file.getAbsolutePath())));
+ LintPathUtils.normalizePath(file.getAbsolutePath()),
+ null)
+ .ifPresent(allResults::add);
}
}
@@ -116,21 +151,25 @@ public final class PreCommitLintService {
return new Result(allResults, blocking);
}
- public static List<File> readStagedFiles(String stagedFileListPath) throws
HopException {
+ public static List<StagedFile> readStagedFiles(String stagedFileListPath)
throws HopException {
return readStagedFiles(stagedFileListPath, null);
}
/**
* The staged files to lint.
*
+ * <p>Each line is a file, optionally followed by a tab and the copy of its
staged version that
+ * the git hook wrote. With a copy the file itself need not exist: it can be
staged and then
+ * deleted or changed on disk, and the commit still holds the staged version.
+ *
* @param baseDirectory what a relative path in the list is relative to. git
lists staged files
* relative to the repository root, and the hop launcher changes to the
Hop installation
* before it starts Java, so resolving them against the working
directory found none of them
* and the hook let every commit through.
*/
- public static List<File> readStagedFiles(String stagedFileListPath, File
baseDirectory)
+ public static List<StagedFile> readStagedFiles(String stagedFileListPath,
File baseDirectory)
throws HopException {
- List<File> files = new ArrayList<>();
+ List<StagedFile> files = new ArrayList<>();
File listFile = new File(stagedFileListPath);
if (!listFile.isFile()) {
throw new HopException("Staged file list not found: " +
stagedFileListPath);
@@ -138,22 +177,25 @@ public final class PreCommitLintService {
try {
for (String line : java.nio.file.Files.readAllLines(listFile.toPath())) {
- String trimmed = line.trim();
+ String[] parts = line.split("\t", 2);
+ String trimmed = parts[0].trim();
if (trimmed.isEmpty()) {
continue;
}
- File candidate = new File(trimmed);
- if (!candidate.isAbsolute() && baseDirectory != null) {
- candidate = new File(baseDirectory, trimmed);
- }
+ File candidate = resolve(trimmed, baseDirectory);
if (!isLintablePath(candidate.getAbsolutePath())) {
continue;
}
- if (candidate.isFile()) {
- files.add(candidate);
+ File content =
+ parts.length > 1 && !parts[1].isBlank()
+ ? resolve(parts[1].trim(), baseDirectory)
+ : null;
+ StagedFile staged = new StagedFile(candidate, content);
+ if (staged.source().isFile()) {
+ files.add(staged);
} else {
// A staged file the hook cannot find is a hook that checks nothing,
so say so.
- System.err.println("Staged file not found, not linted: " +
candidate.getPath());
+ System.err.println("Staged file not found, not linted: " +
staged.source().getPath());
}
}
} catch (Exception e) {
@@ -162,6 +204,14 @@ public final class PreCommitLintService {
return files;
}
+ private static File resolve(String path, File baseDirectory) {
+ File file = new File(path);
+ if (!file.isAbsolute() && baseDirectory != null) {
+ file = new File(baseDirectory, path);
+ }
+ return file;
+ }
+
public static boolean isLintablePath(String path) {
if (path == null) {
return false;
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 7a01748541..9da5762e2b 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
@@ -546,9 +546,11 @@ public class RuleBuilderDialog extends Dialog {
populateTags();
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()) {
+ // A native rule, such as HOP-CHECK, says how Hop's own verify remarks are
reported, and a
+ // system
+ // rule how a file the linter could not read is. They have no target,
field or condition, and
+ // the editor refused to save them, even a severity change.
+ if (rule.isReportOnly()) {
for (Control control :
new Control[] {
targetCombo,
@@ -730,7 +732,7 @@ public class RuleBuilderDialog extends Dialog {
return false;
}
}
- if (rule.isNativeVerify()) {
+ if (rule.isReportOnly()) {
return true;
}
if (targetCombo.getSelectionIndex() < 0) {
@@ -741,7 +743,11 @@ public class RuleBuilderDialog extends Dialog {
// Every clause, not only the one on show. Saving relies on each having a
field and condition.
captureWidgetsIntoSelectedClause();
for (int i = 0; i < editingClauses.size(); i++) {
- String problem = clauseProblem(editingClauses.get(i));
+ String problem =
+ clauseProblem(
+ editingClauses.get(i),
+ RuleTarget.values()[targetCombo.getSelectionIndex()],
+ !rule.getAppliesTo().isEmpty());
if (problem != null) {
clauseTable.setSelection(i);
loadSelectedClauseIntoWidgets();
@@ -788,6 +794,17 @@ public class RuleBuilderDialog extends Dialog {
return null;
}
+ /**
+ * What is missing from a clause or wrong with its value, or null when the
rule can use it.
+ *
+ * @param scoped whether the rule is narrowed with appliesTo
+ * @see RuleClause#valueProblem(RuleTarget, boolean)
+ */
+ static String clauseProblem(RuleClause clause, RuleTarget target, boolean
scoped) {
+ String problem = clauseProblem(clause);
+ return problem != null ? problem : clause.valueProblem(target, scoped);
+ }
+
private void saveRule() {
if (idText.getEditable()) {
rule.setId(idText.getText().trim());
@@ -799,7 +816,7 @@ public class RuleBuilderDialog extends Dialog {
rule.setHelpUri(
Utils.isEmpty(helpUriText.getText().trim()) ? null :
helpUriText.getText().trim());
rule.setTags(readTags());
- if (rule.isNativeVerify()) {
+ if (rule.isReportOnly()) {
return;
}
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/RuleClause.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/RuleClause.java
index dda5041401..ef1bbbf6c8 100644
--- a/plugins/misc/lint/src/main/java/org/apache/hop/lint/RuleClause.java
+++ b/plugins/misc/lint/src/main/java/org/apache/hop/lint/RuleClause.java
@@ -17,7 +17,12 @@
package org.apache.hop.lint;
+import java.util.List;
import java.util.Objects;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.i18n.BaseMessages;
/**
* One thing a rule checks: a field, a condition, and the value the condition
compares against.
@@ -29,6 +34,8 @@ import java.util.Objects;
*/
public class RuleClause {
+ private static final Class<?> PKG = RuleClause.class; // for i18n purposes
+
private String targetField;
private RuleCondition condition;
private String conditionValue;
@@ -65,6 +72,103 @@ public class RuleClause {
this.conditionValue = conditionValue;
}
+ /**
+ * Why this clause can never report anything, or null when it can be
evaluated.
+ *
+ * <p>A value the condition cannot use, such as {@code abc} for a maximum or
{@code [a-} for a
+ * pattern, made the rule report nothing at all and log an exception on
every run, which reads as
+ * a clean result. A missing field, condition or value is not judged here.
+ *
+ * @param target what the rule checks, for the fields the linter itself
provides
+ * @param scoped whether the rule is narrowed with {@code appliesTo}; such a
rule can read the
+ * plugin's own settings, whose types are not known here, so only the
value is judged
+ */
+ public String valueProblem(RuleTarget target, boolean scoped) {
+ if (condition == null || Utils.isEmpty(targetField)) {
+ return null;
+ }
+ if (!scoped && neverFires(RuleTargetFields.getValueKind(target,
targetField), condition)) {
+ return BaseMessages.getString(
+ PKG, "RuleClause.Problem.ConditionDoesNotFit", condition.name(),
targetField);
+ }
+ if (!condition.requiresValue() || Utils.isEmpty(conditionValue)) {
+ return null;
+ }
+ String value = conditionValue.trim();
+ switch (condition) {
+ case MAX_VALUE, MIN_VALUE, EXACT_VALUE:
+ try {
+ Double.parseDouble(value);
+ } catch (NumberFormatException e) {
+ return BaseMessages.getString(
+ PKG, "RuleClause.Problem.NotANumber", condition.name(),
conditionValue);
+ }
+ break;
+ case MAX_COLLECTION_SIZE, MIN_COLLECTION_SIZE:
+ try {
+ Integer.parseInt(value);
+ } catch (NumberFormatException e) {
+ return BaseMessages.getString(
+ PKG, "RuleClause.Problem.NotAWholeNumber", condition.name(),
conditionValue);
+ }
+ break;
+ case MATCHES_PATTERN, NOT_MATCHES_PATTERN:
+ try {
+ Pattern.compile(conditionValue);
+ } catch (PatternSyntaxException e) {
+ return BaseMessages.getString(
+ PKG,
+ "RuleClause.Problem.InvalidPattern",
+ condition.name(),
+ conditionValue,
+ e.getDescription());
+ }
+ break;
+ default:
+ break;
+ }
+ return null;
+ }
+
+ /**
+ * Whether the rule executor can never report this condition on a value of
this kind.
+ *
+ * <p>Only what it cannot fire on, not what the editor would suggest: the
text conditions read any
+ * value as text, so {@code port NO_HARDCODED} and {@code targetTransforms
CONTAINS} work.
+ */
+ private static boolean neverFires(RuleTargetFields.ValueKind kind,
RuleCondition condition) {
+ if (kind == null) {
+ return false;
+ }
+ boolean numeric =
+ condition == RuleCondition.MAX_VALUE
+ || condition == RuleCondition.MIN_VALUE
+ || condition == RuleCondition.EXACT_VALUE;
+ boolean collection =
+ condition == RuleCondition.NOT_EMPTY_COLLECTION
+ || condition == RuleCondition.MAX_COLLECTION_SIZE
+ || condition == RuleCondition.MIN_COLLECTION_SIZE;
+ return switch (kind) {
+ // A count is always there, and never reads as empty.
+ case COUNT ->
+ collection || condition == RuleCondition.NOT_NULL || condition ==
RuleCondition.NOT_EMPTY;
+ case FLAG -> numeric || collection;
+ case LIST -> numeric;
+ case TEXT -> collection;
+ };
+ }
+
+ /** The first problem {@link #valueProblem} finds in any of these clauses,
or null. */
+ public static String valueProblem(List<RuleClause> clauses, RuleTarget
target, boolean scoped) {
+ for (RuleClause clause : clauses) {
+ String problem = clause.valueProblem(target, scoped);
+ if (problem != null) {
+ return problem;
+ }
+ }
+ return null;
+ }
+
public RuleClause copy() {
return new RuleClause(targetField, condition, conditionValue);
}
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 56cea11c44..265f0ed85c 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
@@ -385,6 +385,15 @@ public class RuleManagerDialog extends Dialog {
editButton.setEnabled(hasSelection);
deleteButton.setEnabled(projectEditable);
toggleButton.setEnabled(hasSelection);
+ // "Toggle Enabled" read the same either way, and only the far-right
column said what it did.
+ toggleButton.setText(
+ BaseMessages.getString(
+ PKG,
+ !hasSelection
+ ? "RuleManagerDialog.Button.ToggleEnabled"
+ : selected.isEnabled()
+ ? "RuleManagerDialog.Button.DisableRule"
+ : "RuleManagerDialog.Button.EnableRule"));
// A project rule with a pack rule's id replaces it, so removing it brings
the pack rule back.
deleteButton.setText(
BaseMessages.getString(
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 bf367db4e6..50e3fffd5b 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
@@ -197,6 +197,85 @@ public class RuleTargetFields {
}
}
+ /** What kind of value a field the linter provides holds, as the rule
executor reads it. */
+ public enum ValueKind {
+ /** A number that is always there, such as {@code transformCount}. */
+ COUNT,
+ /** True or false. */
+ FLAG,
+ /** A list or a map, such as {@code targetTransforms} or a connection's
{@code attributes}. */
+ LIST,
+ /** Text, or null when not set. A connection's {@code port} is text. */
+ TEXT
+ }
+
+ private static final Map<String, ValueKind> VALUE_KINDS = new HashMap<>();
+
+ static {
+ for (String field :
+ List.of("transformCount", "actionCount", "hopCount", "noteCount",
"copies")) {
+ VALUE_KINDS.put(field, ValueKind.COUNT);
+ }
+ for (String field :
+ List.of(
+ "hasDisabledHops",
+ "hasOrphanedTransforms",
+ "hasOrphanedActions",
+ "hasNotes",
+ "isReferenced",
+ "distributes",
+ "errorHandling",
+ "isDummy",
+ "isOrphaned",
+ "isBlockingTransform",
+ "hasDefaultName",
+ "isStart",
+ "enabled",
+ "unconditional",
+ "evaluation")) {
+ VALUE_KINDS.put(field, ValueKind.FLAG);
+ }
+ for (String field : List.of("targetTransforms", "targetActions",
"attributes")) {
+ VALUE_KINDS.put(field, ValueKind.LIST);
+ }
+ for (String field :
+ List.of(
+ "name",
+ "description",
+ "filename",
+ "pluginId",
+ "databaseType",
+ "hostname",
+ "port",
+ "databaseName",
+ "username",
+ "password",
+ "servername",
+ "dataTablespace",
+ "indexTablespace",
+ "fromTransform",
+ "toTransform",
+ "fromAction",
+ "toAction")) {
+ VALUE_KINDS.put(field, ValueKind.TEXT);
+ }
+ }
+
+ /**
+ * The kind of value one of the target's own fields holds, or null when it
is not known: a field
+ * the target does not provide, or one whose value the linter does not read
itself, such as the
+ * secret fields of a transform.
+ */
+ public static ValueKind getValueKind(RuleTarget target, String field) {
+ if (target == RuleTarget.TRANSFORM || target == RuleTarget.ACTION) {
+ // Matched against every setting of the plugin by name, whatever its
type.
+ if (List.of("password", "secret", "credential", "apiKey",
"token").contains(field)) {
+ return null;
+ }
+ }
+ return getFieldsForTarget(target).contains(field) ? VALUE_KINDS.get(field)
: null;
+ }
+
/** Get compatible conditions for a field type */
public static List<RuleCondition> getCompatibleConditions(String field) {
// Determine field type and return compatible conditions
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/EffectiveRuleSet.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/EffectiveRuleSet.java
index 4bb9c5f4b1..04d66c312c 100644
---
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/EffectiveRuleSet.java
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/EffectiveRuleSet.java
@@ -88,7 +88,7 @@ public final class EffectiveRuleSet {
public List<CustomLintRule> getEnabledPolicyRules() {
List<CustomLintRule> enabled = new ArrayList<>();
for (CustomLintRule rule : rules) {
- if (rule.isEnabled() && !rule.isNativeVerify()) {
+ if (rule.isEnabled() && !rule.isReportOnly()) {
enabled.add(rule);
}
}
@@ -101,6 +101,16 @@ public final class EffectiveRuleSet {
* <p>A disabled native rule is not a rule that does nothing: it is the
project saying that the
* check it names should not be reported, so the classifier has to see it.
*/
+ /** The rule with this id, or null. */
+ public CustomLintRule findRule(String ruleId) {
+ for (CustomLintRule rule : rules) {
+ if (ruleId != null && ruleId.equals(rule.getId())) {
+ return rule;
+ }
+ }
+ return null;
+ }
+
public List<CustomLintRule> getNativeVerifyRules() {
List<CustomLintRule> nativeRules = new ArrayList<>();
for (CustomLintRule rule : rules) {
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 e36e9b39a9..44448fca2b 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
@@ -248,7 +248,7 @@ public final class ProjectLintYamlExporter {
}
private static Map<String, Object> toFullCustomRuleMap(CustomLintRule rule) {
- if (rule.isNativeVerify()) {
+ if (rule.isReportOnly()) {
return toFullNativeRuleMap(rule);
}
Map<String, Object> ruleConfig = new LinkedHashMap<>();
@@ -316,7 +316,7 @@ public final class ProjectLintYamlExporter {
*/
private static Map<String, Object> toFullNativeRuleMap(CustomLintRule rule) {
Map<String, Object> ruleConfig = new LinkedHashMap<>();
- ruleConfig.put("type", CustomLintRule.TYPE_NATIVE);
+ ruleConfig.put("type", rule.getType());
ruleConfig.put("enabled", rule.isEnabled());
ruleConfig.put("severity", rule.getSeverity());
if (!rule.getAppliesTo().isEmpty()) {
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/RulePackDiscovery.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/RulePackDiscovery.java
index 560f898b22..b4722d7882 100644
---
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/RulePackDiscovery.java
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/registry/RulePackDiscovery.java
@@ -219,7 +219,9 @@ public class RulePackDiscovery {
metadata.owner(),
metadata.priority(),
metadata.overrides());
- } catch (IOException e) {
+ } catch (IOException | RuntimeException e) {
+ // Loading the rules fails again, and the registry reports that with the
pack's name. A YAML
+ // error escaping from here stopped hop lint with only snakeyaml's line
and column.
LogChannel.GENERAL.logError(
"Failed to read pack metadata from "
+ yamlFile.getAbsolutePath()
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 fd807c1b6f..a4201c9708 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
@@ -28,6 +28,7 @@ import org.apache.hop.core.util.Utils;
import org.apache.hop.lint.CustomLintRule;
import org.apache.hop.lint.LinterConfig;
import org.apache.hop.lint.LinterConfigPlugin;
+import org.apache.hop.lint.RuleClause;
import org.apache.hop.lint.RuleConfig;
/**
@@ -219,12 +220,43 @@ public class RuleRegistry {
}
}
+ warnAboutRulesThatCannotReport(merged.values(), warnings);
+
LinterConfig config = buildLinterConfig(merged);
config.setEnabled(true);
return new EffectiveRuleSet(
new ArrayList<>(merged.values()), config, overlay.getPolicy(),
warnings);
}
+ /**
+ * Warn about each enabled rule whose value its condition cannot use, such
as a maximum of {@code
+ * abc} or a pattern of {@code [a-}. Such a rule never reports anything,
which reads as a clean
+ * result, so it is named the way an unknown rule id is.
+ */
+ private static void warnAboutRulesThatCannotReport(
+ Collection<CustomLintRule> rules, List<String> warnings) {
+ for (CustomLintRule rule : rules) {
+ if (!rule.isEnabled() || rule.isReportOnly() || rule.getTarget() ==
null) {
+ continue;
+ }
+ String problem =
+ RuleClause.valueProblem(
+ rule.getClauses(), rule.getTarget(),
!rule.getAppliesTo().isEmpty());
+ if (problem == null) {
+ continue;
+ }
+ String warning =
+ "Warning: rule '"
+ + rule.generateRuleId()
+ + "' "
+ + YamlRulePackParser.inPack(rule.getPackId())
+ + " cannot report anything: "
+ + problem;
+ warnings.add(warning);
+ LintWarnings.logOnce(warning, warning);
+ }
+ }
+
/** The rule of this id, ignoring case: {@code sql-002} in hop-lint.yml
means SQL-002. */
private static CustomLintRule findRule(Map<String, CustomLintRule> rules,
String ruleId) {
CustomLintRule exact = rules.get(ruleId);
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 16b97e756a..9d1c6620e0 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
@@ -39,6 +39,7 @@ import org.apache.hop.lint.RuleTarget;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
+import org.yaml.snakeyaml.error.YAMLException;
/** Parses YAML rule pack and project overlay files. */
public final class YamlRulePackParser {
@@ -75,6 +76,10 @@ public final class YamlRulePackParser {
"tags",
"helpUri");
+ /** The keys a rule covering the linter's own findings about a run may
carry. */
+ static final Set<String> SYSTEM_RULE_KEYS =
+ Set.of("type", "name", "description", "enabled", "severity", "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(
@@ -157,9 +162,51 @@ public final class YamlRulePackParser {
}
}
+ /**
+ * The {@code rules:} block, checked to be a mapping of rule ids to mappings.
+ *
+ * <p>Written with dashes, like {@code exclude:} and {@code suppress:}, it
failed with "class
+ * java.util.ArrayList cannot be cast to class java.util.Map".
+ *
+ * @param source what is being read, for the error message
+ */
+ @SuppressWarnings("unchecked")
+ private static Map<String, Object> rulesSection(Map<String, Object>
yamlData, String source)
+ throws IOException {
+ Object rules = yamlData.get("rules");
+ if (rules == null) {
+ return null;
+ }
+ if (!(rules instanceof Map<?, ?> map)) {
+ throw new IOException(
+ source
+ + ": rules: maps each rule id to its settings, as in 'DB-001:'
followed by"
+ + " 'severity: WARNING' on an indented line, and is not a
list.");
+ }
+ for (Map.Entry<?, ?> entry : map.entrySet()) {
+ if (entry.getValue() != null && !(entry.getValue() instanceof Map)) {
+ throw new IOException(
+ source
+ + ": rule '"
+ + entry.getKey()
+ + "' has to be a mapping of settings such as severity: and
enabled:, not '"
+ + entry.getValue()
+ + "'.");
+ }
+ }
+ return (Map<String, Object>) map;
+ }
+
private static Map<String, Object> readYaml(InputStream inputStream, String
source)
throws IOException {
- Object loaded = new Yaml(new SafeConstructor(new
LoaderOptions())).load(inputStream);
+ Object loaded;
+ try {
+ loaded = new Yaml(new SafeConstructor(new
LoaderOptions())).load(inputStream);
+ } catch (YAMLException e) {
+ // Read as an I/O problem, so that a broken pack is reported as that
pack's error. The
+ // callers name the file or pack; snakeyaml only says where in "reader"
it gave up.
+ throw new IOException(e.getMessage(), e);
+ }
if (loaded == null) {
return Collections.emptyMap();
}
@@ -188,11 +235,11 @@ public final class YamlRulePackParser {
for (ClassLoader loader : classLoadersToTry(anchor)) {
try (InputStream inputStream = loader.getResourceAsStream(resourcePath))
{
if (inputStream != null) {
- return loadFromStream(inputStream, defaultPackId, defaultOwner);
+ return loadFromStream(inputStream, defaultPackId, defaultOwner,
resourcePath);
}
} catch (IOException e) {
- LogChannel.GENERAL.logDetailed(
- "Failed to read rule pack " + resourcePath + " from " + loader +
": " + e.getMessage());
+ // A pack that cannot be read is reported, with its name, rather than
loaded empty.
+ throw new IllegalStateException("Failed to load rule pack " +
resourcePath, e);
}
}
@@ -256,14 +303,23 @@ public final class YamlRulePackParser {
public static List<CustomLintRule> loadFromFile(
File file, String defaultPackId, RulePackOwner defaultOwner) throws
IOException {
try (InputStream inputStream = Files.newInputStream(file.toPath())) {
- return loadFromStream(inputStream, defaultPackId, defaultOwner);
+ return loadFromStream(inputStream, defaultPackId, defaultOwner,
file.getPath());
}
}
public static List<CustomLintRule> loadFromStream(
InputStream inputStream, String defaultPackId, RulePackOwner
defaultOwner)
throws IOException {
- Map<String, Object> yamlData = readYaml(inputStream, "The rule pack");
+ return loadFromStream(inputStream, defaultPackId, defaultOwner, "The rule
pack");
+ }
+
+ /**
+ * @param source what is being read, for the error messages: a file path
where there is one
+ */
+ private static List<CustomLintRule> loadFromStream(
+ InputStream inputStream, String defaultPackId, RulePackOwner
defaultOwner, String source)
+ throws IOException {
+ Map<String, Object> yamlData = readYaml(inputStream, source);
PackMetadata metadata = parsePackMetadata(yamlData, defaultPackId,
defaultOwner, 0);
return parseRulesSection(yamlData, metadata);
}
@@ -292,8 +348,7 @@ public final class YamlRulePackParser {
}
LintPolicy policy = parsePolicy(yamlData, projectYaml);
- @SuppressWarnings("unchecked")
- Map<String, Object> rulesSection = (Map<String, Object>)
yamlData.get("rules");
+ Map<String, Object> rulesSection = rulesSection(yamlData,
projectYaml.getPath());
if (rulesSection == null || rulesSection.isEmpty()) {
return new ProjectYamlOverlay(Collections.emptyList(),
Collections.emptyMap(), policy);
}
@@ -311,6 +366,10 @@ public final class YamlRulePackParser {
projectRules.add(
parseNativeRule(
ruleId, ruleData, RulePackIds.PROJECT, RulePackOwner.PROJECT,
location));
+ } else if (isSystemRuleDefinition(ruleData)) {
+ projectRules.add(
+ parseSystemRule(
+ ruleId, ruleData, RulePackIds.PROJECT, RulePackOwner.PROJECT,
location));
} else if (isCustomRuleDefinition(ruleData)) {
CustomLintRule rule =
parseCustomRule(ruleId, ruleData, RulePackIds.PROJECT,
RulePackOwner.PROJECT, location);
@@ -420,9 +479,9 @@ public final class YamlRulePackParser {
}
private static List<CustomLintRule> parseRulesSection(
- Map<String, Object> yamlData, PackMetadata metadata) {
- @SuppressWarnings("unchecked")
- Map<String, Object> rulesSection = (Map<String, Object>)
yamlData.get("rules");
+ Map<String, Object> yamlData, PackMetadata metadata) throws IOException {
+ Map<String, Object> rulesSection =
+ rulesSection(yamlData, "Rule pack '" + metadata.packId() + "'");
if (rulesSection == null || rulesSection.isEmpty()) {
return Collections.emptyList();
}
@@ -435,6 +494,12 @@ public final class YamlRulePackParser {
rules.add(parseNativeRule(ruleId, ruleData, metadata.packId(),
metadata.owner()));
continue;
}
+ if (isSystemRuleDefinition(ruleData)) {
+ rules.add(
+ parseSystemRule(
+ ruleId, ruleData, metadata.packId(), metadata.owner(),
inPack(metadata.packId())));
+ continue;
+ }
if (!isCustomRuleDefinition(ruleData)) {
continue;
}
@@ -510,6 +575,34 @@ public final class YamlRulePackParser {
return rule;
}
+ public static boolean isSystemRuleDefinition(Map<String, Object> ruleData) {
+ return ruleData != null &&
CustomLintRule.TYPE_SYSTEM.equals(ruleData.get("type"));
+ }
+
+ /**
+ * Read a rule that says how the linter reports a problem with the run
itself, such as a file it
+ * could not read. Like a native rule it has no target or condition.
+ */
+ private static CustomLintRule parseSystemRule(
+ String ruleId,
+ Map<String, Object> ruleData,
+ String packId,
+ RulePackOwner owner,
+ String location) {
+ CustomLintRule rule = new CustomLintRule();
+ rule.setId(ruleId);
+ rule.setPackId(packId);
+ rule.setPackOwner(owner);
+ rule.setType(CustomLintRule.TYPE_SYSTEM);
+ rule.setName(stringValue(ruleData.get("name"), ruleId));
+ rule.setDescription(stringValue(ruleData.get("description"), ""));
+ rule.setEnabled(booleanValue(ruleData.get("enabled"), true));
+ rule.setSeverity(stringValue(ruleData.get("severity"), "ERROR"));
+ applyDocumentation(rule, ruleData, location);
+ warnUnknownKeys(ruleId, location, ruleData, SYSTEM_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));
diff --git a/plugins/misc/lint/src/main/resources/hop-lint-core.yml
b/plugins/misc/lint/src/main/resources/hop-lint-core.yml
index 2b2b032b1f..cc555bcadb 100644
--- a/plugins/misc/lint/src/main/resources/hop-lint-core.yml
+++ b/plugins/misc/lint/src/main/resources/hop-lint-core.yml
@@ -166,6 +166,36 @@ rules:
build; a comment stays a comment. Set severity to ERROR to treat them as
the transform meant
them, or disable this rule to leave Hop's verify remarks to the Verify
button.
+ # ------------------------------------------------------------------
+ # The linter's own findings about a run.
+ #
+ # A `system` rule does not check anything either: it says how the linter
+ # reports a file it could not read or a run it could not finish. They ship at
+ # ERROR, because a file that was not linted is a file nothing was checked
+ # against; a project can lower that, or switch one off, like any other rule.
+ # ------------------------------------------------------------------
+
+ SYSTEM-001:
+ type: system
+ enabled: true
+ severity: ERROR
+ name: "File Processing Error"
+ description: "A pipeline, workflow or metadata file could not be loaded,
so no rule was checked against it"
+
+ SYSTEM-002:
+ type: system
+ enabled: true
+ severity: ERROR
+ name: "Configuration Error"
+ description: "The lint run stopped before it finished, so the files it did
not reach were not checked"
+
+ SYSTEM-003:
+ type: system
+ enabled: true
+ severity: ERROR
+ name: "Unreadable Metadata"
+ description: "A metadata file, such as a connection, could not be read, so
no rule was checked against it"
+
# No narrowed rule ships enabled or disabled here. A check that is simply
wrong is fixed in the
# transform, which is what #8294 did for the two in Select Values; silencing
it from a rule pack
# would have left it firing for everyone who presses Verify. `appliesTo` and
`messageKey` are
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 7b3cc65bb9..e4ba795394 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
@@ -88,6 +88,10 @@ RuleBuilderDialog.Error.Clause=Clause {0}: {1}
RuleBuilderDialog.Error.FieldRequired=Field must be selected
RuleBuilderDialog.Error.ConditionRequired=Condition must be selected
RuleBuilderDialog.Error.ValueRequired=Value is required for this condition
+RuleClause.Problem.ConditionDoesNotFit={0} does not apply to {1}, so the rule
can never report anything
+RuleClause.Problem.NotANumber={0} compares with a number, and ''{1}'' is not
one. Variables are not resolved in rule values.
+RuleClause.Problem.NotAWholeNumber={0} compares with a whole number, and
''{1}'' is not one. Variables are not resolved in rule values.
+RuleClause.Problem.InvalidPattern={0} needs a regular expression, and ''{1}''
is not valid: {2}
# ---------------------------------------------------------------------------
# Rule manager dialog
@@ -108,6 +112,8 @@ RuleManagerDialog.Button.EditRule=Edit Rule
RuleManagerDialog.Button.DeleteRule=Delete Rule
RuleManagerDialog.Button.RestorePackRule=Restore Pack Rule
RuleManagerDialog.Button.ToggleEnabled=Toggle Enabled
+RuleManagerDialog.Button.DisableRule=Disable Rule
+RuleManagerDialog.Button.EnableRule=Enable Rule
RuleManagerDialog.Button.Close=Close
RuleManagerDialog.Dialog.ConfirmDelete.Title=Confirm Delete
RuleManagerDialog.Dialog.ConfirmDelete.Message=Delete project rule ''{0}''?
@@ -254,6 +260,7 @@ LintSeverity.Level.WARNING=Warning
LintSeverity.Level.INFO=Info
LintSeverity.FailOn.ERROR=Error
LintSeverity.FailOn.WARNING=Warning
+LintSeverity.FailOn.INFO=Info
LintSeverity.FailOn.NONE=None
LintReportFormat.TEXT=Text
LintReportFormat.JSON=JSON
diff --git
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/HopMetadataFileLoaderTest.java
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/HopMetadataFileLoaderTest.java
index 3984a8844c..770b07643a 100644
---
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/HopMetadataFileLoaderTest.java
+++
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/HopMetadataFileLoaderTest.java
@@ -77,6 +77,38 @@ public class HopMetadataFileLoaderTest {
assertEquals("SALES_PRD", HopMetadataFileLoader.metadataNameOf(file));
}
+ /**
+ * The git hook lints the staged version of a connection, which can differ
from the one on disk:
+ * the type and the name come from the file, the settings from the staged
copy.
+ *
+ * @see <a href="https://github.com/apache/hop/issues/9071">#9071</a>
+ */
+ @Test
+ public void aStagedCopyIsReadInsteadOfTheFile(@TempDir Path dir) throws
Exception {
+ File file =
+ metadataFile(
+ dir,
+ "rdbms",
+ "SALES_PRD.json",
+ """
+ {"rdbms": {"POSTGRESQL": {"hostname": "on-disk", "password":
"${PG_PASSWORD}"}}}
+ """);
+ Path staged = dir.resolve("staged-copy");
+ Files.writeString(
+ staged,
+ """
+ {"rdbms": {"POSTGRESQL": {"hostname": "staged", "password":
"hunter2"}}}
+ """,
+ StandardCharsets.UTF_8);
+
+ HopMetadataFileLoader.MetadataLoad load =
+ HopMetadataFileLoader.read(file, staged.toFile(), null);
+
+ DatabaseMeta databaseMeta = (DatabaseMeta) load.object();
+ assertEquals("staged", databaseMeta.getHostname());
+ assertEquals("SALES_PRD", databaseMeta.getName());
+ }
+
@Test
public void relationalConnectionsLoadWithoutAProvider(@TempDir Path dir)
throws Exception {
File file =
diff --git
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintCommandOptionsTest.java
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintCommandOptionsTest.java
new file mode 100644
index 0000000000..206f55553a
--- /dev/null
+++
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintCommandOptionsTest.java
@@ -0,0 +1,249 @@
+/*
+ * 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.assertTrue;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import picocli.CommandLine;
+
+/**
+ * {@code hop lint} paths, thresholds, system findings and the staged files
the git hook passes.
+ *
+ * @see <a href="https://github.com/apache/hop/issues/9071">#9071</a>
+ */
+public class LintCommandOptionsTest {
+
+ @TempDir private Path dir;
+
+ private Path project;
+
+ private String originDir;
+
+ @BeforeAll
+ static void initHop() throws Exception {
+ HopEnvironment.init();
+ }
+
+ /** A project with a pipeline that cannot be read, which SYSTEM-001 reports.
*/
+ @BeforeEach
+ void createProject() throws Exception {
+ project = dir.resolve("project");
+ Files.createDirectories(project);
+ writePipeline(project.resolve("customers.hpl"), "Customers");
+ Files.writeString(project.resolve("broken.hpl"), "this is not a pipeline");
+ originDir = System.getProperty("hop.origin.dir");
+ }
+
+ @AfterEach
+ void restoreOrigin() {
+ if (originDir == null) {
+ System.clearProperty("hop.origin.dir");
+ } else {
+ System.setProperty("hop.origin.dir", originDir);
+ }
+ CustomRuleExecutor.setProjectIndex(null);
+ }
+
+ private static void writePipeline(Path file, String description) throws
Exception {
+ PipelineMeta pipelineMeta = new PipelineMeta();
+ pipelineMeta.setName(file.getFileName().toString().replace(".hpl", ""));
+ pipelineMeta.setDescription(description);
+ Files.writeString(file,
pipelineMeta.getXml(Variables.getADefaultVariableSpace()));
+ }
+
+ /** Runs hop lint; returns the exit code, with stdout and stderr in {@code
output}. */
+ private static int hopLint(StringBuilder output, String... args) {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ PrintStream capture = new PrintStream(bytes, true, StandardCharsets.UTF_8);
+ PrintStream systemOut = System.out;
+ PrintStream systemErr = System.err;
+ int exitCode;
+ try {
+ System.setOut(capture);
+ System.setErr(capture);
+ exitCode = new CommandLine(new LintCommand()).execute(args);
+ } finally {
+ System.setOut(systemOut);
+ System.setErr(systemErr);
+ }
+ output.append(bytes.toString(StandardCharsets.UTF_8));
+ return exitCode;
+ }
+
+ private static JsonNode report(Path file) throws Exception {
+ return new ObjectMapper().readTree(Files.readString(file));
+ }
+
+ // ------------------------------------------------------------------
relative paths
+
+ /**
+ * The launcher runs Java from the Hop installation, so {@code hop lint .}
linted the installation
+ * and {@code -o lint.json} wrote the report there.
+ */
+ @Test
+ public void relativePathsAreResolvedAgainstWhereHopLintWasRun() throws
Exception {
+ System.setProperty("hop.origin.dir", project.toString());
+
+ StringBuilder output = new StringBuilder();
+ int status = hopLint(output, "-f", "JSON", "-o", "lint.json", "--fail-on",
"NONE", ".");
+
+ assertEquals(0, status, output.toString());
+ Path written = project.resolve("lint.json");
+ assertTrue(Files.isRegularFile(written), "the report belongs in the
project: " + output);
+ assertEquals(1, report(written).get("summary").get("total").asInt(),
output.toString());
+ }
+
+ @Test
+ public void aRelativeFileTargetIsFound() throws Exception {
+ System.setProperty("hop.origin.dir", project.toString());
+
+ StringBuilder output = new StringBuilder();
+ int status = hopLint(output, "customers.hpl");
+
+ assertEquals(0, status, output.toString());
+ assertFalse(output.toString().contains("does not exist"),
output.toString());
+ }
+
+ @Test
+ public void baselineFilesAreRelativeToo() throws Exception {
+ System.setProperty("hop.origin.dir", project.toString());
+
+ StringBuilder output = new StringBuilder();
+ assertEquals(0, hopLint(output, "--write-baseline", "baseline.json", "."),
output.toString());
+ assertTrue(Files.isRegularFile(project.resolve("baseline.json")),
output.toString());
+ assertEquals(0, hopLint(output, "--baseline", "baseline.json", "."),
output.toString());
+ }
+
+ // ------------------------------------------------------------------
--fail-on
+
+ /** A typo used to read as ERROR, and a project with warnings passed. */
+ @Test
+ public void anUnknownFailOnIsRefused() {
+ StringBuilder output = new StringBuilder();
+
+ int status = hopLint(output, "--fail-on", "WARNNG", project.toString());
+
+ assertEquals(1, status, output.toString());
+ assertTrue(output.toString().contains("Unknown fail-on severity
'WARNNG'"), output.toString());
+ }
+
+ /** --fail-on INFO used to fail on errors only. SYSTEM-001 is lowered to
INFO to have one. */
+ @Test
+ public void failOnInfoFailsOnAnInfoFinding() throws Exception {
+ Files.writeString(
+ project.resolve("hop-lint.yml"), "rules:\n SYSTEM-001:\n severity:
INFO\n");
+
+ StringBuilder output = new StringBuilder();
+ assertEquals(0, hopLint(output, "--fail-on", "WARNING",
project.toString()), output.toString());
+ assertEquals(1, hopLint(output, "--fail-on", "INFO", project.toString()),
output.toString());
+ }
+
+ // ------------------------------------------------------------------ system
findings
+
+ @Test
+ public void aSystemFindingTakesTheProjectsSeverity() throws Exception {
+ Files.writeString(
+ project.resolve("hop-lint.yml"), "rules:\n SYSTEM-001:\n severity:
WARNING\n");
+
+ StringBuilder output = new StringBuilder();
+ int status =
+ hopLint(output, "-f", "JSON", "-o",
dir.resolve("out.json").toString(), project.toString());
+
+ assertEquals(0, status, "a warning does not fail on ERROR: " + output);
+ JsonNode finding = report(dir.resolve("out.json")).get("findings").get(0);
+ assertEquals("SYSTEM-001", finding.get("ruleId").asText(),
finding.toString());
+ assertEquals("WARNING", finding.get("severity").asText(),
finding.toString());
+ assertFalse(output.toString().contains("no installed rule pack defines"),
output.toString());
+ }
+
+ @Test
+ public void aSystemRuleCanBeSwitchedOff() throws Exception {
+ Files.writeString(
+ project.resolve("hop-lint.yml"), "rules:\n SYSTEM-001:\n enabled:
false\n");
+
+ StringBuilder output = new StringBuilder();
+
+ assertEquals(0, hopLint(output, project.toString()), output.toString());
+ }
+
+ @Test
+ public void systemRulesAreListed() {
+ StringBuilder output = new StringBuilder();
+
+ assertEquals(0, hopLint(output, "--list-rules"), output.toString());
+ for (String ruleId : new String[] {"SYSTEM-001", "SYSTEM-002",
"SYSTEM-003"}) {
+ assertTrue(output.toString().contains(ruleId), ruleId + " is not listed:
" + output);
+ }
+ }
+
+ // ------------------------------------------------------------------ the
staged version
+
+ /**
+ * The staged pipeline has no description, the one on disk has. LOCAL-001
asks for one, so only
+ * linting the staged copy blocks the commit.
+ */
+ @Test
+ public void thePreCommitCheckLintsTheStagedCopy() throws Exception {
+ Files.delete(project.resolve("broken.hpl"));
+ Files.writeString(
+ project.resolve("hop-lint.yml"),
+ """
+ rules:
+ LOCAL-001:
+ type: custom
+ enabled: true
+ severity: ERROR
+ target: PIPELINE
+ targetField: description
+ condition: NOT_EMPTY
+ name: Pipeline Description
+ """);
+ Path staged = dir.resolve("staged-copy");
+ writePipeline(staged, "");
+ Path list = dir.resolve("staged.txt");
+
+ Files.writeString(list, project.resolve("customers.hpl") + "\t" + staged +
"\n");
+ StringBuilder output = new StringBuilder();
+ assertEquals(
+ 1, hopLint(output, "--pre-commit", "--staged-file", list.toString()),
output.toString());
+ assertTrue(output.toString().contains("customers.hpl"), "reported as the
file: " + output);
+ assertFalse(output.toString().contains("staged-copy"), output.toString());
+
+ // Without a staged copy the file on disk, with its description, is what
is linted.
+ Files.writeString(list, project.resolve("customers.hpl") + "\n");
+ StringBuilder disk = new StringBuilder();
+ assertEquals(
+ 0, hopLint(disk, "--pre-commit", "--staged-file", list.toString()),
disk.toString());
+ }
+}
diff --git
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintCommandTest.java
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintCommandTest.java
index 9d0a63c3a1..f2b6e4cdde 100644
--- a/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintCommandTest.java
+++ b/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintCommandTest.java
@@ -32,6 +32,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
+import java.util.TreeMap;
import org.apache.hop.core.HopVersionProvider;
import org.apache.hop.core.logging.HopLogStore;
import org.apache.hop.core.logging.LogChannel;
@@ -160,6 +161,7 @@ public class LintCommandTest {
if [ "$1" = "--staged-file" ]; then
cat "$2" > "$CAPTURE"
echo "$2" > "$CAPTURE.path"
+ cut -f2 "$2" | while IFS= read -r COPY; do cat "$COPY"; echo; done
> "$CAPTURE.content"
fi
shift
done
@@ -192,15 +194,113 @@ public class LintCommandTest {
assertEquals(1, status, "a failing lint has to block the commit");
String root = repo.toRealPath().toString();
- List<String> staged = Files.readAllLines(dir.resolve("capture"));
assertEquals(
- List.of(root + "/load/customers.hpl", root +
"/metadata/rdbms/crm.json"),
- staged.stream().map(line ->
Path.of(line).toString()).sorted().toList());
+ List.of(root + "/load/customers.hpl", root +
"/metadata/rdbms/crm.json"), stagedPaths());
String listFile = Files.readString(dir.resolve("capture.path")).trim();
assertFalse(Files.exists(Path.of(listFile)), "the staged list is left
behind: " + listFile);
}
+ /** The files the stand-in launcher was given, without the staged copies. */
+ private List<String> stagedPaths() throws Exception {
+ return Files.readAllLines(dir.resolve("capture")).stream()
+ .map(line -> Path.of(line.split("\t")[0]).toString())
+ .sorted()
+ .toList();
+ }
+
+ /**
+ * The hook linted the files on disk, so a password that was staged and then
removed only on disk
+ * was committed unchecked.
+ *
+ * @see <a href="https://github.com/apache/hop/issues/9071">#9071</a>
+ */
+ @Test
+ public void theHookLintsTheStagedVersionNotTheOneOnDisk() throws Exception {
+ assumeTrue(canRunShell());
+ Path repo = repositoryWithStagedFiles("load/customers.hpl");
+ Files.writeString(repo.resolve("load/customers.hpl"), "changed on disk
only");
+
+ run(repo, hookEnvironment(), "/bin/sh",
dir.resolve("pre-commit").toString());
+
+ assertEquals("content",
Files.readString(dir.resolve("capture.content")).trim());
+ }
+
+ /** git mv lists a renamed file as R, which --diff-filter=ACM left out of
the check. */
+ @Test
+ public void theHookLintsARenamedFile() throws Exception {
+ assumeTrue(canRunShell());
+ Path repo = repositoryWithStagedFiles("load/customers.hpl");
+ Map<String, String> noHook = Map.of();
+ assertEquals(
+ 0,
+ run(
+ repo,
+ noHook,
+ "git",
+ "-c",
+ "user.name=lint",
+ "-c",
+ "[email protected]",
+ "commit",
+ "-q",
+ "-m",
+ "first"));
+ assertEquals(0, run(repo, noHook, "git", "mv", "load/customers.hpl",
"load/clients.hpl"));
+ Files.writeString(repo.resolve("load/clients.hpl"), "content");
+
+ int status = run(repo, hookEnvironment(), "/bin/sh",
dir.resolve("pre-commit").toString());
+
+ assertEquals(1, status, "the renamed file has to be linted");
+ assertEquals(List.of(repo.toRealPath() + "/load/clients.hpl"),
stagedPaths());
+ }
+
+ /** The lines the stand-in launcher was given, file and staged copy, keyed
by the file name. */
+ private Map<String, String[]> stagedLines() throws Exception {
+ Map<String, String[]> lines = new TreeMap<>();
+ for (String line : Files.readAllLines(dir.resolve("capture"))) {
+ String[] parts = line.split("\t");
+ lines.put(Path.of(parts[0]).getFileName().toString(), parts);
+ }
+ return lines;
+ }
+
+ /**
+ * The staged blob of a symlink is the path it points to, which is not a
pipeline. Such a file is
+ * linted on disk, as it was before the hook read staged versions.
+ */
+ @Test
+ public void aSymlinkIsLintedOnDisk() throws Exception {
+ assumeTrue(canRunShell());
+ Path repo = repositoryWithStagedFiles("p/customers.hpl");
+ Files.createSymbolicLink(repo.resolve("link.hpl"),
Path.of("p/customers.hpl"));
+ assertEquals(0, run(repo, Map.of(), "git", "add", "link.hpl"));
+
+ run(repo, hookEnvironment(), "/bin/sh",
dir.resolve("pre-commit").toString());
+
+ Map<String, String[]> lines = stagedLines();
+ assertEquals(1, lines.get("link.hpl").length, "no staged copy of a
symlink");
+ assertEquals(2, lines.get("customers.hpl").length, "a plain file still
gets one");
+ }
+
+ /**
+ * A file stored with Git LFS has a pointer as its staged blob, and so does
any file behind a
+ * clean filter. The filter here is not configured, so the test does not
depend on git-lfs.
+ */
+ @Test
+ public void aFilteredFileIsLintedOnDisk() throws Exception {
+ assumeTrue(canRunShell());
+ Path repo = repositoryWithStagedFiles("lfs/customers.hpl",
"plain/orders.hpl");
+ Files.writeString(repo.resolve(".gitattributes"), "lfs/*.hpl
filter=example\n");
+ assertEquals(0, run(repo, Map.of(), "git", "add", ".gitattributes"));
+
+ run(repo, hookEnvironment(), "/bin/sh",
dir.resolve("pre-commit").toString());
+
+ Map<String, String[]> lines = stagedLines();
+ assertEquals(1, lines.get("customers.hpl").length, "no staged copy of an
LFS file");
+ assertEquals(2, lines.get("orders.hpl").length);
+ }
+
@Test
public void theHookDoesNotStartHopWithoutHopFiles() throws Exception {
assumeTrue(canRunShell());
@@ -221,12 +321,35 @@ public class LintCommandTest {
Path list = dir.resolve("staged.txt");
Files.writeString(list,
"customers.hpl\nmetadata/rdbms/crm.json\nmissing.hpl\nnotes.txt\n");
- List<File> files = PreCommitLintService.readStagedFiles(list.toString(),
repo.toFile());
+ List<PreCommitLintService.StagedFile> files =
+ PreCommitLintService.readStagedFiles(list.toString(), repo.toFile());
+
+ assertEquals(
+ List.of(
+ new
PreCommitLintService.StagedFile(repo.resolve("customers.hpl").toFile(), null),
+ new PreCommitLintService.StagedFile(
+ repo.resolve("metadata/rdbms/crm.json").toFile(), null)),
+ files);
+ }
+
+ /** A staged file can be gone from disk; its staged copy is what the commit
holds. */
+ @Test
+ public void aStagedCopyIsReadEvenWhenTheFileIsGone() throws Exception {
+ Path repo = dir.resolve("repo");
+ Files.createDirectories(repo);
+ Path copy = dir.resolve("copy-1");
+ Files.writeString(copy, "staged");
+ Path list = dir.resolve("staged.txt");
+ Files.writeString(list, repo.resolve("deleted.hpl") + "\t" + copy + "\n");
+
+ List<PreCommitLintService.StagedFile> files =
+ PreCommitLintService.readStagedFiles(list.toString(), repo.toFile());
assertEquals(
List.of(
- repo.resolve("customers.hpl").toFile(),
- repo.resolve("metadata/rdbms/crm.json").toFile()),
+ new PreCommitLintService.StagedFile(
+ repo.resolve("deleted.hpl").toFile(), copy.toFile())),
files);
+ assertEquals(copy.toFile(), files.get(0).source());
}
}
diff --git
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintSeverityThresholdTest.java
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintSeverityThresholdTest.java
index 9d5f387b63..46431660b2 100644
---
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintSeverityThresholdTest.java
+++
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintSeverityThresholdTest.java
@@ -16,7 +16,9 @@
*/
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 org.junit.jupiter.api.Test;
@@ -53,6 +55,31 @@ public class LintSeverityThresholdTest {
assertFalse(LintSeverity.meetsFailOnThreshold("WARNING", threshold));
}
+ /**
+ * --fail-on INFO read as ERROR.
+ *
+ * @see <a href="https://github.com/apache/hop/issues/9071">#9071</a>
+ */
+ @Test
+ public void failOnInfoFailsOnEverything() {
+ LintSeverity.FailOn threshold = LintSeverity.parseFailOn("info");
+
+ assertEquals(LintSeverity.FailOn.INFO, threshold);
+ assertTrue(LintSeverity.meetsFailOnThreshold("ERROR", threshold));
+ assertTrue(LintSeverity.meetsFailOnThreshold("WARNING", threshold));
+ assertTrue(LintSeverity.meetsFailOnThreshold("INFO", threshold));
+ }
+
+ /** A typo read as ERROR, so a project with warnings passed --fail-on
WARNNG. */
+ @Test
+ public void anUnknownThresholdIsRefused() {
+ IllegalArgumentException e =
+ assertThrows(IllegalArgumentException.class, () ->
LintSeverity.parseFailOn("WARNNG"));
+
+ assertTrue(e.getMessage().contains("ERROR, WARNING, INFO, NONE"),
e.getMessage());
+ assertEquals(LintSeverity.FailOn.ERROR, LintSeverity.parseFailOn(""));
+ }
+
/** An unrecognised severity must not be treated as harmless. */
@Test
public void unknownSeveritiesDoNotSlipPastAnErrorThreshold() {
diff --git
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/RuleBuilderValidationTest.java
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/RuleBuilderValidationTest.java
index 8b5f8031f1..8c9728b202 100644
---
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/RuleBuilderValidationTest.java
+++
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/RuleBuilderValidationTest.java
@@ -19,6 +19,7 @@ package org.apache.hop.lint;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Set;
import org.junit.jupiter.api.Test;
@@ -43,6 +44,128 @@ class RuleBuilderValidationTest {
assertNull(RuleBuilderDialog.clauseProblem(new RuleClause("name",
RuleCondition.NOT_NULL, "")));
}
+ /**
+ * A value its condition cannot use was accepted, and the rule never
reported anything.
+ *
+ * @see <a href="https://github.com/apache/hop/issues/9071">#9071</a>
+ */
+ @Test
+ void aValueTheConditionCannotUseIsRefused() {
+ for (String value : new String[] {"abc", "${MAXT}", "3,0"}) {
+ assertNotNull(
+ RuleBuilderDialog.clauseProblem(
+ new RuleClause("transformCount", RuleCondition.MAX_VALUE, value),
+ RuleTarget.PIPELINE,
+ false),
+ value);
+ }
+ assertNull(
+ RuleBuilderDialog.clauseProblem(
+ new RuleClause("transformCount", RuleCondition.MAX_VALUE, "3.5"),
+ RuleTarget.PIPELINE,
+ false));
+ assertNotNull(
+ RuleBuilderDialog.clauseProblem(
+ new RuleClause("transforms", RuleCondition.MAX_COLLECTION_SIZE,
"2.5"),
+ RuleTarget.PIPELINE,
+ false));
+ String pattern =
+ RuleBuilderDialog.clauseProblem(
+ new RuleClause("name", RuleCondition.MATCHES_PATTERN, "[a-"),
+ RuleTarget.PIPELINE,
+ false);
+ assertNotNull(pattern);
+ assertTrue(pattern.contains("[a-"), pattern);
+ }
+
+ @Test
+ void aConditionThatDoesNotFitTheFieldIsRefused() {
+ String problem =
+ RuleBuilderDialog.clauseProblem(
+ new RuleClause("transformCount", RuleCondition.NOT_NULL, ""),
+ RuleTarget.PIPELINE,
+ false);
+
+ assertEquals(
+ "NOT_NULL does not apply to transformCount, so the rule can never
report anything",
+ problem);
+ }
+
+ /**
+ * The text conditions read any value as text, so they work on a
connection's port, which is text,
+ * and on a list of target transforms. These were refused although the rules
report.
+ */
+ @Test
+ void aConditionTheExecutorCanFireIsKept() {
+ assertNull(
+ RuleBuilderDialog.clauseProblem(
+ new RuleClause("port", RuleCondition.NO_HARDCODED, ""),
+ RuleTarget.DATABASE_CONNECTION,
+ false));
+ assertNull(
+ RuleBuilderDialog.clauseProblem(
+ new RuleClause("port", RuleCondition.MATCHES_PATTERN, "^\\d+$"),
+ RuleTarget.DATABASE_CONNECTION,
+ false));
+ assertNull(
+ RuleBuilderDialog.clauseProblem(
+ new RuleClause("targetTransforms", RuleCondition.CONTAINS,
"Abort"),
+ RuleTarget.TRANSFORM,
+ false));
+ assertNull(
+ RuleBuilderDialog.clauseProblem(
+ new RuleClause("transformCount", RuleCondition.MATCHES_PATTERN,
"^\\d$"),
+ RuleTarget.PIPELINE,
+ false));
+ }
+
+ /** What the executor can never report: each of these reads as a clean
result. */
+ @Test
+ void aConditionTheExecutorCannotFireIsRefused() {
+ assertNotNull(
+ RuleBuilderDialog.clauseProblem(
+ new RuleClause("transformCount", RuleCondition.NOT_EMPTY, ""),
+ RuleTarget.PIPELINE,
+ false));
+ assertNotNull(
+ RuleBuilderDialog.clauseProblem(
+ new RuleClause("hasNotes", RuleCondition.MAX_VALUE, "1"),
RuleTarget.PIPELINE, false));
+ assertNotNull(
+ RuleBuilderDialog.clauseProblem(
+ new RuleClause("attributes", RuleCondition.MIN_VALUE, "1"),
+ RuleTarget.DATABASE_CONNECTION,
+ false));
+ assertNotNull(
+ RuleBuilderDialog.clauseProblem(
+ new RuleClause("name", RuleCondition.MAX_COLLECTION_SIZE, "3"),
+ RuleTarget.PIPELINE,
+ false));
+ // The secret fields of a transform match every setting by name, whatever
its type.
+ assertNull(
+ RuleBuilderDialog.clauseProblem(
+ new RuleClause("password", RuleCondition.NOT_EMPTY_COLLECTION, ""),
+ RuleTarget.TRANSFORM,
+ false));
+ }
+
+ /**
+ * A rule narrowed with appliesTo can read a plugin's own setting of the
same name, whose type is
+ * not known, so only its value is judged.
+ */
+ @Test
+ void aScopedRuleKeepsAConditionTheEditorWouldNotSuggest() {
+ assertNull(
+ RuleBuilderDialog.clauseProblem(
+ new RuleClause("port", RuleCondition.MATCHES_PATTERN, "^\\d+$"),
+ RuleTarget.TRANSFORM,
+ true));
+ assertNull(
+ RuleBuilderDialog.clauseProblem(
+ new RuleClause("commitSize", RuleCondition.MAX_VALUE, "1000"),
+ RuleTarget.TRANSFORM,
+ false));
+ }
+
@Test
void aRuleIdMustBeUsableAndFree() {
Set<String> taken = Set.of("DOC-001");
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
index bcb368798c..119bfbafc6 100644
---
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
@@ -45,7 +45,7 @@ public class RuleEditorChoicesTest {
public void everyCoreRuleFitsTheEditor() {
List<String> problems = new ArrayList<>();
for (CustomLintRule rule : new HopCoreRulePack().loadRules()) {
- if (rule.isNativeVerify()) {
+ if (rule.isReportOnly()) {
continue;
}
for (RuleClause clause : rule.getClauses()) {
diff --git
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/registry/HopCoreRulePackTest.java
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/registry/HopCoreRulePackTest.java
index 00cc602d31..9179cb2a97 100644
---
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/registry/HopCoreRulePackTest.java
+++
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/registry/HopCoreRulePackTest.java
@@ -79,7 +79,7 @@ public class HopCoreRulePackTest {
rules.stream().filter(CustomLintRule::isNativeVerify).noneMatch(rule
-> rule.isComposed()));
assertTrue(
rules.stream()
- .filter(rule -> !rule.isNativeVerify())
+ .filter(rule -> !rule.isReportOnly())
.allMatch(rule -> rule.getTarget() != null),
"every rule the linter evaluates has something to evaluate it
against");
}
@@ -113,7 +113,7 @@ public class HopCoreRulePackTest {
new HopCoreRulePack()
.loadRules().stream()
.filter(CustomLintRule::isEnabled)
- .filter(rule -> !rule.isNativeVerify())
+ .filter(rule -> !rule.isReportOnly())
.map(CustomLintRule::generateRuleId)
.sorted()
.toList();
diff --git
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/registry/RuleRegistryTest.java
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/registry/RuleRegistryTest.java
index 673c7cf6ca..ea23324f06 100644
---
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/registry/RuleRegistryTest.java
+++
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/registry/RuleRegistryTest.java
@@ -18,13 +18,17 @@ package org.apache.hop.lint.registry;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.File;
import java.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 org.apache.commons.io.FileUtils;
import org.apache.hop.core.logging.HopLogStore;
import org.apache.hop.core.logging.IHopLoggingEventListener;
import org.apache.hop.core.logging.LogLevel;
@@ -442,7 +446,7 @@ public class RuleRegistryTest {
*/
@Test
public void aPackCannotSilentlyReplaceAnotherPacksRule() {
- Map<String, CustomLintRule> merged = new java.util.LinkedHashMap<>();
+ Map<String, CustomLintRule> merged = new LinkedHashMap<>();
RuleRegistry.mergePack(
merged, packOf("hop-core", List.of(), ruleFrom("hop-core", "DB-001",
"ERROR")));
@@ -456,7 +460,7 @@ public class RuleRegistryTest {
/** Declaring the intent is what makes it allowed. */
@Test
public void aDeclaredOverrideIsApplied() {
- Map<String, CustomLintRule> merged = new java.util.LinkedHashMap<>();
+ Map<String, CustomLintRule> merged = new LinkedHashMap<>();
RuleRegistry.mergePack(
merged, packOf("hop-core", List.of(), ruleFrom("hop-core", "DB-001",
"ERROR")));
@@ -471,7 +475,7 @@ public class RuleRegistryTest {
/** A pack redefining its own rule is not a collision. */
@Test
public void aPackMayRedefineItsOwnRule() {
- Map<String, CustomLintRule> merged = new java.util.LinkedHashMap<>();
+ Map<String, CustomLintRule> merged = new LinkedHashMap<>();
RuleRegistry.mergePack(
merged, packOf("acme", List.of(), ruleFrom("acme", "ACME-001",
"WARNING")));
RuleRegistry.mergePack(
@@ -483,7 +487,7 @@ public class RuleRegistryTest {
/** Rule ids are matched without regard to case, as they are hand-written in
YAML. */
@Test
public void overrideDeclarationIgnoresCase() {
- Map<String, CustomLintRule> merged = new java.util.LinkedHashMap<>();
+ Map<String, CustomLintRule> merged = new LinkedHashMap<>();
RuleRegistry.mergePack(
merged, packOf("hop-core", List.of(), ruleFrom("hop-core", "DB-001",
"ERROR")));
@@ -691,6 +695,121 @@ public class RuleRegistryTest {
assertTrue(new RuleRegistry(discovery).getPackErrors().isEmpty());
}
+ /**
+ * A value the condition cannot use, from hop-lint.yml or a pack, made the
rule report nothing and
+ * log an exception on every run.
+ *
+ * @see <a href="https://github.com/apache/hop/issues/9071">#9071</a>
+ */
+ @Test
+ public void aRuleThatCannotReportIsNamedInAWarning() throws Exception {
+ File projectYaml = File.createTempFile("hop-lint", ".yml");
+ Files.writeString(
+ projectYaml.toPath(),
+ """
+ rules:
+ STRUCT-001:
+ enabled: true
+ conditionValue: abc
+ LOCAL-001:
+ type: custom
+ enabled: true
+ target: PIPELINE
+ targetField: name
+ condition: MATCHES_PATTERN
+ conditionValue: "[a-"
+ LOCAL-002:
+ type: custom
+ enabled: true
+ target: PIPELINE
+ targetField: transformCount
+ condition: NOT_NULL
+ LOCAL-003:
+ type: custom
+ enabled: false
+ target: PIPELINE
+ targetField: transformCount
+ condition: MAX_VALUE
+ conditionValue: "${MAXT}"
+ """);
+ try {
+ List<String> warnings =
RuleRegistry.getInstance().resolve(projectYaml).getWarnings();
+
+ assertEquals(3, warnings.size(), warnings.toString());
+ assertTrue(
+ warnings.stream().anyMatch(w -> w.contains("'STRUCT-001'") &&
w.contains("'abc'")),
+ warnings.toString());
+ assertTrue(
+ warnings.stream().anyMatch(w -> w.contains("'LOCAL-001'") &&
w.contains("[a-")),
+ warnings.toString());
+ assertTrue(
+ warnings.stream().anyMatch(w -> w.contains("'LOCAL-002'") &&
w.contains("NOT_NULL")),
+ warnings.toString());
+ } finally {
+ Files.deleteIfExists(projectYaml.toPath());
+ }
+ }
+
+ @Test
+ public void theCorePackHasNoRuleThatCannotReport() {
+ assertTrue(
+ RuleRegistry.getInstance().resolve(null).getWarnings().isEmpty(),
+ RuleRegistry.getInstance().resolve(null).getWarnings().toString());
+ }
+
+ /** rules: written with dashes failed with "ArrayList cannot be cast to
class java.util.Map". */
+ @Test
+ public void rulesWrittenAsAListAreExplained() throws Exception {
+ File projectYaml = File.createTempFile("hop-lint", ".yml");
+ Files.writeString(projectYaml.toPath(), "rules:\n - DB-001:\n
enabled: false\n");
+ try {
+ LintConfigurationException e =
+ assertThrows(
+ LintConfigurationException.class,
+ () -> RuleRegistry.getInstance().resolve(projectYaml));
+ assertTrue(e.getMessage().contains("maps each rule id to its settings"),
e.getMessage());
+ assertFalse(e.getMessage().contains("ArrayList"), e.getMessage());
+ } finally {
+ Files.deleteIfExists(projectYaml.toPath());
+ }
+ }
+
+ /**
+ * A broken hop-lint-pack.yml in a plugin folder stopped hop lint with only
snakeyaml's "while
+ * parsing a flow sequence in 'reader'", naming neither the pack nor the
file.
+ */
+ @Test
+ public void aBrokenDiscoveredPackNamesItsFile() throws Exception {
+ Path root = Files.createTempDirectory("hop-lint-discovery");
+ Path engineDir = root.resolve("plugins/misc/hop-lint");
+ Path vendorDir = root.resolve("plugins/misc/acme-lint-rules");
+ Files.createDirectories(engineDir);
+ Files.createDirectories(vendorDir);
+ Path packFile = vendorDir.resolve("hop-lint-pack.yml");
+ Files.writeString(packFile, "pack:\n id: acme\nrules:\n ACME-001:
[unterminated\n");
+ try {
+ Map<String, IHopLintRulePack> packsById = new LinkedHashMap<>();
+ new RulePackDiscovery()
+ .registerInstalledPluginYamlPacksFromEngineDir(engineDir.toFile(),
packsById);
+ List<IHopLintRulePack> packs = new ArrayList<>(packsById.values());
+ RulePackDiscovery discovery =
+ new RulePackDiscovery() {
+ @Override
+ public List<IHopLintRulePack> discoverAll() {
+ return packs;
+ }
+ };
+
+ List<String> errors = new RuleRegistry(discovery).getPackErrors();
+
+ assertEquals(1, errors.size(), errors.toString());
+ assertTrue(errors.get(0).contains(packFile.toString()), errors.get(0));
+ assertTrue(errors.get(0).contains("line"), errors.get(0));
+ } finally {
+ FileUtils.deleteDirectory(root.toFile());
+ }
+ }
+
/** A cause chain that loops back on itself must not hang the registry while
it holds its lock. */
@Test
public void aLoopingCauseChainEnds() {