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

bamaer pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 44d4d178d2 Fixes #8731 : Fix hop lint version, quiet, severity and 
stdout output, warn about unknown rule ids and make the pre-commit hook find 
staged files (#8739)
44d4d178d2 is described below

commit 44d4d178d27355210af97855099e1a7ad909b947
Author: Bart Maertens <[email protected]>
AuthorDate: Sat Oct 3 09:22:06 2026 +0200

    Fixes #8731 : Fix hop lint version, quiet, severity and stdout output, warn 
about unknown rule ids and make the pre-commit hook find staged files (#8739)
---
 .../modules/ROOT/pages/hop-tools/hop-lint.adoc     |   4 +-
 .../ROOT/pages/linting/lint-rule-packs.adoc        |   3 +
 .../main/java/org/apache/hop/lint/LintCommand.java | 132 +++++++++---
 .../java/org/apache/hop/lint/LintReportWriter.java |  23 +-
 .../org/apache/hop/lint/PreCommitLintService.java  |  24 ++-
 .../apache/hop/lint/registry/EffectiveRuleSet.java |  12 ++
 .../org/apache/hop/lint/registry/RuleRegistry.java |  92 +++++++-
 .../java/org/apache/hop/lint/LintCommandTest.java  | 232 +++++++++++++++++++++
 .../apache/hop/lint/registry/RuleRegistryTest.java |  90 ++++++++
 9 files changed, 567 insertions(+), 45 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 57fe1bd19c..c1f121a34b 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
@@ -115,7 +115,8 @@ hop lint --max-warnings 20 .
 
 `sarif` is the format GitHub code scanning, Azure DevOps and most review 
tooling read, so findings show up as annotations on the pull request.
 `json` is there for everything else.
-Both formats write only the report to stdout, so they can be piped.
+Stdout carries only the report, in every format, so it can be piped or 
redirected.
+Hop's log lines and notes such as the baseline count go to stderr.
 
 [[baseline]]
 == Adopting the linter on an existing project
@@ -146,6 +147,7 @@ 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 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 -> Linter -> Block Git Commits*, 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-rule-packs.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/linting/lint-rule-packs.adoc
index 08063e4863..acb597ff37 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/linting/lint-rule-packs.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/linting/lint-rule-packs.adoc
@@ -113,6 +113,9 @@ rules:
 
 This keeps the pack upgradeable: the project's decisions live with the 
project, and the pack can be replaced with a newer version without losing them.
 
+Rule ids are matched ignoring case.
+An id that no installed pack defines, such as a typo, is reported as a warning 
in the Hop log and by `hop lint`, with the closest id as a suggestion, and the 
override is ignored.
+
 The rule manager in Hop Gui writes these overrides for you.
 Editing a pack rule there never changes the pack: a change to its severity, 
threshold or enabled state is written to `hop-lint.yml` as an override, and a 
change to what the rule actually looks at is written there as a complete rule 
which replaces the pack rule of that id.
 Pack rules cannot be deleted, only switched off.
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 cb3e789505..356ca8ba96 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
@@ -36,9 +36,12 @@ import lombok.Getter;
 import lombok.Setter;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.HopVersionProvider;
 import org.apache.hop.core.encryption.Encr;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.logging.DefaultLogLevel;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.core.logging.LogLevel;
 import org.apache.hop.core.plugins.ActionPluginType;
 import org.apache.hop.core.plugins.IPlugin;
@@ -71,6 +74,7 @@ import picocli.CommandLine.Parameters;
 @Command(
     name = "lint",
     mixinStandardHelpOptions = true,
+    versionProvider = HopVersionProvider.class,
     description =
         "Check pipelines, workflows and metadata against the lint rules. Exits 
1 when a finding "
             + "reaches the --fail-on threshold (ERROR by default) or warnings 
exceed "
@@ -115,8 +119,8 @@ public class LintCommand implements Callable<Integer>, 
IHopCommand {
       names = {"-s", "--severity"},
       paramLabel = "<severity>",
       description =
-          "Report only findings at this severity: ${COMPLETION-CANDIDATES}. 
Narrows the report "
-              + "only; --fail-on decides the exit code.")
+          "Report only findings at this severity or above: 
${COMPLETION-CANDIDATES}. Narrows the "
+              + "report only; --fail-on decides the exit code.")
   private LintSeverity.Level severityFilter;
 
   @Option(
@@ -199,6 +203,11 @@ public class LintCommand implements Callable<Integer>, 
IHopCommand {
   @SuppressWarnings("java:S4507")
   @Override
   public Integer call() {
+    // Hop's console logging writes to the stdout it saw at start-up, not to 
System.out, so moving
+    // System.out does not move it. Stdout carries the report, which a CI job 
redirects to a file or
+    // pipes to jq; everything else goes to stderr.
+    PrintStream logOut = HopLogStore.OriginalSystemOut;
+    HopLogStore.OriginalSystemOut = HopLogStore.OriginalSystemErr;
     try {
       return run();
     } catch (Exception e) {
@@ -207,6 +216,8 @@ public class LintCommand implements Callable<Integer>, 
IHopCommand {
         e.printStackTrace();
       }
       return 1;
+    } finally {
+      HopLogStore.OriginalSystemOut = logOut;
     }
   }
 
@@ -286,23 +297,60 @@ public class LintCommand implements Callable<Integer>, 
IHopCommand {
       if (reportOwnsStdout) {
         System.setOut(stdout);
       }
-      outputResults(filterForDisplay(results));
+      report(results, exitCode != 0);
       return exitCode;
     } finally {
       System.setOut(stdout);
     }
   }
 
-  /** Apply {@code --severity}, which narrows the report only. */
-  private List<LintResult> filterForDisplay(List<LintResult> results) {
+  /**
+   * Apply {@code --severity}, which narrows the report only, to the severity 
given and above:
+   * {@code -s WARNING} on a project with errors has to show the errors.
+   */
+  List<LintResult> filterForDisplay(List<LintResult> results) {
     if (severityFilter == null) {
       return results;
     }
     return results.stream()
-        .filter(result -> severityFilter.name().equals(result.getSeverity()))
+        .filter(result -> atOrAbove(result.getSeverity(), severityFilter))
         .collect(Collectors.toList());
   }
 
+  /** A severity this build does not know is shown rather than hidden. */
+  private static boolean atOrAbove(String severity, LintSeverity.Level 
minimum) {
+    for (LintSeverity.Level level : LintSeverity.Level.values()) {
+      if (level.name().equalsIgnoreCase(severity)) {
+        return level.ordinal() <= minimum.ordinal();
+      }
+    }
+    return true;
+  }
+
+  /**
+   * Print the report after {@code --severity}, and say what that left out, 
the way the baseline
+   * does. Hiding findings without a word made a failing run read "No lint 
issues found."
+   */
+  private void report(List<LintResult> results, boolean failing) {
+    List<LintResult> shown = filterForDisplay(results);
+    int hidden = results.size() - shown.size();
+    if (hidden > 0 && !quiet) {
+      System.err.println(hidden + " finding(s) below " + severityFilter + " 
hidden by --severity.");
+      LintSeverity.FailOn threshold = LintSeverity.parseFailOn(failOn);
+      if (failing
+          && shown.stream()
+              .noneMatch(r -> 
LintSeverity.meetsFailOnThreshold(r.getSeverity(), threshold))) {
+        System.err.println(
+            "Failing: the findings at --fail-on "
+                + failOn
+                + " are below --severity "
+                + severityFilter
+                + " and not shown.");
+      }
+    }
+    outputResults(shown, hidden);
+  }
+
   /**
    * Hide the findings a project has already accepted, so a run reports only 
what is new.
    *
@@ -497,10 +545,14 @@ public class LintCommand implements Callable<Integer>, 
IHopCommand {
     }
   }
 
-  /** Version from the jar manifest, so it cannot drift from the build the way 
a literal does. */
+  /** The Hop version, as {@code hop --version} and {@code hop lint --version} 
print it. */
   private static String toolVersion() {
-    String version = LintCommand.class.getPackage().getImplementationVersion();
-    return version != null ? version : "development build";
+    String[] version = new HopVersionProvider().getVersion();
+    if (version.length > 0 && !Utils.isEmpty(version[0])) {
+      return version[0];
+    }
+    String lintVersion = 
LintCommand.class.getPackage().getImplementationVersion();
+    return lintVersion != null ? lintVersion : "development build";
   }
 
   private int runPreCommit() throws Exception {
@@ -514,7 +566,8 @@ public class LintCommand implements Callable<Integer>, 
IHopCommand {
     }
 
     initializeHopEnvironment();
-    List<File> stagedFiles = 
PreCommitLintService.readStagedFiles(stagedFileList);
+    List<File> stagedFiles =
+        PreCommitLintService.readStagedFiles(stagedFileList, new 
File(userDirectory()));
     if (stagedFiles.isEmpty()) {
       if (!quiet) {
         System.out.println("No staged Hop files to lint.");
@@ -559,7 +612,7 @@ public class LintCommand implements Callable<Integer>, 
IHopCommand {
     // on findings that were already there before the change.
     List<LintResult> results = applyBaseline(result.getResults());
 
-    outputResults(filterForDisplay(results));
+    report(results, shouldFail(results, LintSeverity.parseFailOn(failOn)));
 
     if (shouldFail(results, LintSeverity.parseFailOn(failOn))) {
       long blocking =
@@ -639,20 +692,18 @@ public class LintCommand implements Callable<Integer>, 
IHopCommand {
   }
 
   /**
-   * Keep Hop's engine logging off stdout when stdout is carrying a report.
+   * {@code --quiet} reports findings only, so Hop's progress logging goes 
too. Warnings about the
+   * configuration, such as a rule id that does not exist, are logged at the 
minimal level and stay.
    *
-   * <p>Hop logs to the console by default. That is fine for the text report, 
but {@code -f sarif}
-   * piped to another tool has to be a valid document, and interleaved log 
lines make it garbage. An
-   * explicit {@code --output} file keeps the two streams apart, so logging is 
left alone there.
+   * <p>The general log channel exists before this command runs and read the 
default level when it
+   * was created, so it has to be set on its own; setting the default alone 
changed nothing.
    */
   private void quietenHopLogging() {
-    boolean reportOwnsStdout = format != LintReportFormat.TEXT && outputFile 
== null;
-    if (verbose) {
+    if (verbose || !quiet) {
       return;
     }
-    if (quiet || reportOwnsStdout) {
-      DefaultLogLevel.setLogLevel(LogLevel.NOTHING);
-    }
+    DefaultLogLevel.setLogLevel(LogLevel.MINIMAL);
+    LogChannel.GENERAL.setLogLevel(LogLevel.MINIMAL);
   }
 
   private void loadConfiguration(HopLinter linter, String targetPath) throws 
IOException {
@@ -793,14 +844,21 @@ public class LintCommand implements Callable<Integer>, 
IHopCommand {
    * working when Hop is upgraded or moved, and so the same hook can be 
committed to a repository
    * that several people clone.
    */
-  private String hookScript() {
+  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.
     return """
         #!/bin/sh
-        set -e
-        STAGED_LIST="$(mktemp)"
-        git diff --cached --name-only --diff-filter=ACM > "$STAGED_LIST"
-        if ! grep -E '\\.(hpl|hwf)$|/metadata/.*\\.json$' "$STAGED_LIST" > 
/dev/null 2>&1; then
-          rm -f "$STAGED_LIST"
+        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"
+        if [ ! -s "$STAGED_LIST" ]; then
           exit 0
         fi
         HOP="${HOP_HOME:-}/hop"
@@ -809,24 +867,30 @@ public class LintCommand implements Callable<Integer>, 
IHopCommand {
         fi
         if [ -z "$HOP" ] || [ ! -x "$HOP" ]; then
           echo "The hop launcher was not found. Set HOP_HOME, or put hop on 
the PATH." >&2
-          rm -f "$STAGED_LIST"
           exit 1
         fi
-        "$HOP" lint --pre-commit --staged-file "$STAGED_LIST" \
+        "$HOP" lint --pre-commit --staged-file "$STAGED_LIST" \\
           ${HOP_LINT_FAIL_ON:+--fail-on "$HOP_LINT_FAIL_ON"}
-        STATUS=$?
-        rm -f "$STAGED_LIST"
-        exit $STATUS
         """;
   }
 
-  private void outputResults(List<LintResult> results) {
+  /**
+   * @param hidden how many findings {@code --severity} left out, so an empty 
text report does not
+   *     claim there were none
+   */
+  private void outputResults(List<LintResult> results, int hidden) {
     String report;
     try {
-      report = LintReportWriter.render(format, results, toolVersion(), 
reportBaseDirectory());
+      if (format == LintReportFormat.TEXT && results.isEmpty() && hidden > 0) {
+        report = "No findings at " + severityFilter + " or above.\n";
+      } else if (format == LintReportFormat.TEXT) {
+        report = LintReportWriter.renderText(results, !quiet);
+      } else {
+        report = LintReportWriter.render(format, results, toolVersion(), 
reportBaseDirectory());
+      }
     } catch (Exception e) {
       System.err.println("Error rendering the " + format.getId() + " report: " 
+ e.getMessage());
-      report = LintReportWriter.renderText(results);
+      report = LintReportWriter.renderText(results, !quiet);
     }
 
     if (outputFile != null) {
diff --git 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintReportWriter.java 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintReportWriter.java
index 5217cd35d2..d86e3c1b9d 100644
--- a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintReportWriter.java
+++ b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintReportWriter.java
@@ -52,24 +52,33 @@ public final class LintReportWriter {
         return renderSarif(results, toolVersion, baseDirectory);
       case TEXT:
       default:
-        return renderText(results);
+        return renderText(results, true);
     }
   }
 
   // ---------------------------------------------------------------- text
 
   public static String renderText(List<LintResult> results) {
+    return renderText(results, true);
+  }
+
+  /**
+   * @param summary whether to open with the totals; {@code --quiet} reports 
the findings only
+   */
+  public static String renderText(List<LintResult> results, boolean summary) {
     StringBuilder out = new StringBuilder();
     if (results.isEmpty()) {
       return "No lint issues found.\n";
     }
 
-    out.append("Lint Results Summary:\n");
-    out.append("===================\n");
-    out.append("Total Issues: ").append(results.size()).append('\n');
-    out.append("Errors: ").append(countBySeverity(results, 
"ERROR")).append('\n');
-    out.append("Warnings: ").append(countBySeverity(results, 
"WARNING")).append('\n');
-    out.append("Info: ").append(countBySeverity(results, 
"INFO")).append("\n\n");
+    if (summary) {
+      out.append("Lint Results Summary:\n");
+      out.append("===================\n");
+      out.append("Total Issues: ").append(results.size()).append('\n');
+      out.append("Errors: ").append(countBySeverity(results, 
"ERROR")).append('\n');
+      out.append("Warnings: ").append(countBySeverity(results, 
"WARNING")).append('\n');
+      out.append("Info: ").append(countBySeverity(results, 
"INFO")).append("\n\n");
+    }
 
     for (String severity : new String[] {"ERROR", "WARNING", "INFO"}) {
       List<LintResult> group =
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 77954090f7..8b1cce6720 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
@@ -93,6 +93,19 @@ public final class PreCommitLintService {
   }
 
   public static List<File> readStagedFiles(String stagedFileListPath) throws 
HopException {
+    return readStagedFiles(stagedFileListPath, null);
+  }
+
+  /**
+   * The staged files to lint.
+   *
+   * @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)
+      throws HopException {
     List<File> files = new ArrayList<>();
     File listFile = new File(stagedFileListPath);
     if (!listFile.isFile()) {
@@ -106,8 +119,17 @@ public final class PreCommitLintService {
           continue;
         }
         File candidate = new File(trimmed);
-        if (candidate.isFile() && isLintablePath(trimmed)) {
+        if (!candidate.isAbsolute() && baseDirectory != null) {
+          candidate = new File(baseDirectory, trimmed);
+        }
+        if (!isLintablePath(candidate.getAbsolutePath())) {
+          continue;
+        }
+        if (candidate.isFile()) {
           files.add(candidate);
+        } 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());
         }
       }
     } catch (Exception e) {
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 8f17447167..4bb9c5f4b1 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
@@ -29,12 +29,19 @@ public final class EffectiveRuleSet {
   private final List<CustomLintRule> rules;
   private final LinterConfig config;
   private final LintPolicy policy;
+  private final List<String> warnings;
 
   public EffectiveRuleSet(List<CustomLintRule> rules, LinterConfig config) {
     this(rules, config, LintPolicy.empty());
   }
 
   public EffectiveRuleSet(List<CustomLintRule> rules, LinterConfig config, 
LintPolicy policy) {
+    this(rules, config, policy, List.of());
+  }
+
+  public EffectiveRuleSet(
+      List<CustomLintRule> rules, LinterConfig config, LintPolicy policy, 
List<String> warnings) {
+    this.warnings = warnings != null ? List.copyOf(warnings) : List.of();
     this.rules =
         rules != null
             ? Collections.unmodifiableList(new ArrayList<>(rules))
@@ -43,6 +50,11 @@ public final class EffectiveRuleSet {
     this.policy = policy != null ? policy : LintPolicy.empty();
   }
 
+  /** Problems with the project's hop-lint.yml that did not stop it loading, 
such as unknown ids. */
+  public List<String> getWarnings() {
+    return warnings;
+  }
+
   /** What the project excludes from linting, and which findings it has 
accepted. */
   public LintPolicy getPolicy() {
     return policy;
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 c838161e95..746aaca0f7 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
@@ -18,8 +18,12 @@ package org.apache.hop.lint.registry;
 
 import java.io.File;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.lint.CustomLintRule;
@@ -47,6 +51,13 @@ public class RuleRegistry {
    */
   private volatile Map<String, CustomLintRule> packRules;
 
+  /**
+   * The unknown rule id warnings already logged, per hop-lint.yml. Resolution 
runs once per file
+   * and on every background check, so logging each time repeated the same 
line for as long as the
+   * project kept the id.
+   */
+  private final Set<String> loggedWarnings = ConcurrentHashMap.newKeySet();
+
   public static RuleRegistry getInstance() {
     return INSTANCE;
   }
@@ -138,6 +149,7 @@ public class RuleRegistry {
     }
 
     ProjectYamlOverlay overlay = ProjectYamlOverlay.empty();
+    List<String> warnings = new ArrayList<>();
     if (projectYaml != null && projectYaml.exists()) {
       try {
         overlay = YamlRulePackParser.parseProjectYaml(projectYaml);
@@ -146,9 +158,19 @@ public class RuleRegistry {
         }
         for (Map.Entry<String, ProjectYamlOverlay.ProjectRuleOverlay> entry :
             overlay.getOverlays().entrySet()) {
-          CustomLintRule existing = merged.get(entry.getKey());
+          CustomLintRule existing = findRule(merged, entry.getKey());
           if (existing != null) {
             entry.getValue().applyTo(existing);
+          } else {
+            // Applied to nothing, a typo such as SQL-02 for SQL-002 left the 
rule as it was with
+            // no sign anything had gone wrong.
+            String warning = unknownRuleWarning(entry.getKey(), 
merged.keySet(), projectYaml);
+            warnings.add(warning);
+            if (loggedWarnings.add(projectYaml.getAbsolutePath() + '\n' + 
warning)) {
+              LogChannel.GENERAL.logMinimal(warning);
+            } else {
+              LogChannel.GENERAL.logDetailed(warning);
+            }
           }
         }
         LogChannel.GENERAL.logDetailed(
@@ -168,7 +190,73 @@ public class RuleRegistry {
 
     LinterConfig config = buildLinterConfig(merged);
     config.setEnabled(true);
-    return new EffectiveRuleSet(new ArrayList<>(merged.values()), config, 
overlay.getPolicy());
+    return new EffectiveRuleSet(
+        new ArrayList<>(merged.values()), config, overlay.getPolicy(), 
warnings);
+  }
+
+  /** 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);
+    if (exact != null) {
+      return exact;
+    }
+    for (Map.Entry<String, CustomLintRule> entry : rules.entrySet()) {
+      if (entry.getKey().equalsIgnoreCase(ruleId)) {
+        return entry.getValue();
+      }
+    }
+    return null;
+  }
+
+  /**
+   * A warning, not an error: a project may tune a rule from a pack that is 
not installed on every
+   * machine that lints it, and that should not stop the run.
+   */
+  static String unknownRuleWarning(String ruleId, Collection<String> knownIds, 
File projectYaml) {
+    StringBuilder warning =
+        new StringBuilder("Warning: ")
+            .append(projectYaml.getName())
+            .append(" changes rule '")
+            .append(ruleId)
+            .append("', which no installed rule pack defines; the change is 
ignored.");
+    String closest = closestId(ruleId, knownIds);
+    if (closest != null) {
+      warning.append(" Did you mean ").append(closest).append("?");
+    }
+    return warning.toString();
+  }
+
+  /** The known id within two edits of the one given, or null. */
+  private static String closestId(String ruleId, Collection<String> knownIds) {
+    String best = null;
+    int bestDistance = 3;
+    for (String known : knownIds) {
+      int distance = editDistance(ruleId.toUpperCase(), known.toUpperCase());
+      if (distance < bestDistance) {
+        bestDistance = distance;
+        best = known;
+      }
+    }
+    return best;
+  }
+
+  private static int editDistance(String a, String b) {
+    int[] previous = new int[b.length() + 1];
+    int[] current = new int[b.length() + 1];
+    for (int j = 0; j <= b.length(); j++) {
+      previous[j] = j;
+    }
+    for (int i = 1; i <= a.length(); i++) {
+      current[0] = i;
+      for (int j = 1; j <= b.length(); j++) {
+        int substitution = previous[j - 1] + (a.charAt(i - 1) == b.charAt(j - 
1) ? 0 : 1);
+        current[j] = Math.min(substitution, Math.min(previous[j] + 1, 
current[j - 1] + 1));
+      }
+      int[] swap = previous;
+      previous = current;
+      current = swap;
+    }
+    return previous[b.length()];
   }
 
   public EffectiveRuleSet resolveForContext(File context) {
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
new file mode 100644
index 0000000000..9d0a63c3a1
--- /dev/null
+++ b/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintCommandTest.java
@@ -0,0 +1,232 @@
+/*
+ * 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.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.PrintStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.core.HopVersionProvider;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.logging.LogChannel;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import picocli.CommandLine;
+
+/**
+ * The {@code hop lint} command line.
+ *
+ * @see <a href="https://github.com/apache/hop/issues/8731";>#8731</a>
+ */
+public class LintCommandTest {
+
+  @TempDir private Path dir;
+
+  private static LintResult finding(String ruleId, String severity) {
+    return new LintResult(ruleId, ruleId, severity, "message", 
"/tmp/project/a.hpl");
+  }
+
+  @Test
+  public void versionComesFromHop() {
+    CommandLine commandLine = new CommandLine(new LintCommand());
+
+    assertInstanceOf(
+        HopVersionProvider.class,
+        commandLine.getCommandSpec().versionProvider(),
+        "-V printed " + "nothing without a version provider");
+  }
+
+  @Test
+  public void severityIsAMinimum() {
+    LintCommand command = new LintCommand();
+    command.setSeverityFilter(LintSeverity.Level.WARNING);
+
+    List<LintResult> shown =
+        command.filterForDisplay(
+            List.of(finding("A-1", "ERROR"), finding("B-1", "WARNING"), 
finding("C-1", "INFO")));
+
+    assertEquals(
+        List.of("A-1", "B-1"), 
shown.stream().map(LintResult::getRuleId).toList(), "-s WARNING");
+  }
+
+  @Test
+  public void quietLeavesOutTheSummary() {
+    List<LintResult> results = List.of(finding("A-1", "ERROR"));
+
+    String quiet = LintReportWriter.renderText(results, false);
+
+    assertFalse(quiet.contains("Lint Results Summary"), quiet);
+    assertTrue(quiet.contains("A-1"), quiet);
+    assertTrue(LintReportWriter.renderText(results).contains("Lint Results 
Summary"));
+  }
+
+  /**
+   * Hop logs to the stdout it saw at start-up, so piping a JSON report to jq 
got log lines in front
+   * of the document.
+   */
+  @Test
+  public void aJsonReportIsAloneOnStdout() throws Exception {
+    Files.createDirectories(dir.resolve("project"));
+    ByteArrayOutputStream captured = new ByteArrayOutputStream();
+    PrintStream capture = new PrintStream(captured, true, 
StandardCharsets.UTF_8);
+
+    PrintStream systemOut = System.out;
+    PrintStream logOut = HopLogStore.OriginalSystemOut;
+    int exitCode;
+    try {
+      System.setOut(capture);
+      HopLogStore.OriginalSystemOut = capture;
+      LogChannel.GENERAL.logBasic("A log line that must not reach stdout");
+      captured.reset();
+
+      exitCode =
+          new CommandLine(new LintCommand())
+              .execute("-f", "JSON", dir.resolve("project").toString());
+    } finally {
+      System.setOut(systemOut);
+      HopLogStore.OriginalSystemOut = logOut;
+    }
+
+    String stdout = captured.toString(StandardCharsets.UTF_8);
+    assertEquals(0, exitCode, stdout);
+    JsonNode report = new ObjectMapper().readTree(stdout);
+    assertEquals(0, report.get("summary").get("total").asInt(), stdout);
+    assertTrue(stdout.trim().startsWith("{"), stdout);
+  }
+
+  // ------------------------------------------------------------------ 
pre-commit hook
+
+  private static boolean canRunShell() {
+    return !System.getProperty("os.name").toLowerCase().contains("win")
+        && new File("/bin/sh").canExecute();
+  }
+
+  private static int run(Path workDir, Map<String, String> env, String... 
command)
+      throws Exception {
+    ProcessBuilder builder = new 
ProcessBuilder(command).directory(workDir.toFile());
+    builder.environment().putAll(env);
+    builder.redirectErrorStream(true);
+    Process process = builder.start();
+    process.getInputStream().readAllBytes();
+    return process.waitFor();
+  }
+
+  /** A repository with staged files, and a stand-in launcher that records 
what it was given. */
+  private Path repositoryWithStagedFiles(String... files) throws Exception {
+    Path repo = dir.resolve("repo");
+    Files.createDirectories(repo);
+    assumeTrue(run(repo, Map.of(), "git", "init", "-q") == 0, "git is needed 
for this test");
+    for (String file : files) {
+      Path path = repo.resolve(file);
+      Files.createDirectories(path.getParent());
+      Files.writeString(path, "content");
+    }
+    assertEquals(0, run(repo, Map.of(), "git", "add", "."));
+
+    Path hopHome = dir.resolve("hop-home");
+    Files.createDirectories(hopHome);
+    Path launcher = hopHome.resolve("hop");
+    Files.writeString(
+        launcher,
+        """
+        #!/bin/sh
+        while [ $# -gt 0 ]; do
+          if [ "$1" = "--staged-file" ]; then
+            cat "$2" > "$CAPTURE"
+            echo "$2" > "$CAPTURE.path"
+          fi
+          shift
+        done
+        exit 1
+        """);
+    assertTrue(launcher.toFile().setExecutable(true));
+
+    Files.writeString(dir.resolve("pre-commit"), new 
LintCommand().hookScript());
+    return repo;
+  }
+
+  private Map<String, String> hookEnvironment() {
+    return Map.of(
+        "HOP_HOME", dir.resolve("hop-home").toString(),
+        "CAPTURE", dir.resolve("capture").toString());
+  }
+
+  /**
+   * git lists staged files relative to the repository root, and the launcher 
changes to the Hop
+   * installation, so none were found and every commit passed. Metadata at the 
root of the
+   * repository was skipped too: "metadata/rdbms/x.json" has no leading slash.
+   */
+  @Test
+  public void theHookPassesAbsolutePathsAndBlocksTheCommit() throws Exception {
+    assumeTrue(canRunShell());
+    Path repo =
+        repositoryWithStagedFiles("load/customers.hpl", 
"metadata/rdbms/crm.json", "notes.txt");
+
+    int status = run(repo, hookEnvironment(), "/bin/sh", 
dir.resolve("pre-commit").toString());
+
+    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());
+
+    String listFile = Files.readString(dir.resolve("capture.path")).trim();
+    assertFalse(Files.exists(Path.of(listFile)), "the staged list is left 
behind: " + listFile);
+  }
+
+  @Test
+  public void theHookDoesNotStartHopWithoutHopFiles() throws Exception {
+    assumeTrue(canRunShell());
+    Path repo = repositoryWithStagedFiles("notes.txt");
+
+    int status = run(repo, hookEnvironment(), "/bin/sh", 
dir.resolve("pre-commit").toString());
+
+    assertEquals(0, status);
+    assertFalse(Files.exists(dir.resolve("capture")), "hop was started for a 
text file");
+  }
+
+  @Test
+  public void stagedPathsAreResolvedAgainstTheRepository() throws Exception {
+    Path repo = dir.resolve("repo");
+    Files.createDirectories(repo.resolve("metadata/rdbms"));
+    Files.writeString(repo.resolve("customers.hpl"), "content");
+    Files.writeString(repo.resolve("metadata/rdbms/crm.json"), "{}");
+    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());
+
+    assertEquals(
+        List.of(
+            repo.resolve("customers.hpl").toFile(),
+            repo.resolve("metadata/rdbms/crm.json").toFile()),
+        files);
+  }
+}
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 2475f5dd5a..97c99dabe0 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
@@ -22,8 +22,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.io.File;
 import java.nio.file.Files;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.logging.IHopLoggingEventListener;
+import org.apache.hop.core.logging.LogLevel;
 import org.apache.hop.lint.CustomLintRule;
 import org.apache.hop.lint.RuleCombinator;
 import org.junit.jupiter.api.Test;
@@ -432,4 +436,90 @@ public class RuleRegistryTest {
       projectYaml.delete();
     }
   }
+
+  /**
+   * A typo in hop-lint.yml used to change nothing and say nothing: SQL-002 
stayed disabled.
+   *
+   * @see <a href="https://github.com/apache/hop/issues/8731";>#8731</a>
+   */
+  @Test
+  public void anUnknownRuleIdIsReportedWithTheClosestId() throws Exception {
+    File projectYaml = File.createTempFile("hop-lint", ".yml");
+    Files.writeString(projectYaml.toPath(), "rules:\n  SQL-02:\n    enabled: 
true\n");
+    try {
+      EffectiveRuleSet rules = RuleRegistry.getInstance().resolve(projectYaml);
+
+      assertEquals(1, rules.getWarnings().size(), 
rules.getWarnings().toString());
+      String warning = rules.getWarnings().get(0);
+      assertTrue(warning.contains("'SQL-02'"), warning);
+      assertTrue(warning.contains("Did you mean SQL-002?"), warning);
+      assertFalse(
+          rules.getRules().stream()
+              .filter(rule -> "SQL-002".equals(rule.generateRuleId()))
+              .findFirst()
+              .orElseThrow()
+              .isEnabled());
+    } finally {
+      Files.deleteIfExists(projectYaml.toPath());
+    }
+  }
+
+  @Test
+  public void aRuleIdInAnotherCaseIsTheSameRule() throws Exception {
+    File projectYaml = File.createTempFile("hop-lint", ".yml");
+    Files.writeString(projectYaml.toPath(), "rules:\n  trans-002:\n    
enabled: false\n");
+    try {
+      EffectiveRuleSet rules = RuleRegistry.getInstance().resolve(projectYaml);
+
+      assertTrue(rules.getWarnings().isEmpty(), 
rules.getWarnings().toString());
+      assertFalse(
+          rules.getRules().stream()
+              .filter(rule -> "TRANS-002".equals(rule.generateRuleId()))
+              .findFirst()
+              .orElseThrow()
+              .isEnabled());
+    } finally {
+      Files.deleteIfExists(projectYaml.toPath());
+    }
+  }
+
+  /**
+   * Hop Gui resolves the rules for every file it lints and on every 
background check, so a warning
+   * logged each time filled the log for as long as the project kept the id.
+   */
+  @Test
+  public void anUnknownRuleIdIsLoggedOnce() throws Exception {
+    HopLogStore.init();
+    File projectYaml = File.createTempFile("hop-lint", ".yml");
+    Files.writeString(projectYaml.toPath(), "rules:\n  NO-SUCH-RULE:\n    
enabled: true\n");
+    List<String> logged = new ArrayList<>();
+    IHopLoggingEventListener listener =
+        event -> {
+          if (event.getLevel() == LogLevel.MINIMAL
+              && 
String.valueOf(event.getMessage()).contains("'NO-SUCH-RULE'")) {
+            logged.add(String.valueOf(event.getMessage()));
+          }
+        };
+    HopLogStore.getAppender().addLoggingEventListener(listener);
+    try {
+      EffectiveRuleSet first = RuleRegistry.getInstance().resolve(projectYaml);
+      EffectiveRuleSet second = 
RuleRegistry.getInstance().resolve(projectYaml);
+
+      assertEquals(1, logged.size(), logged.toString());
+      assertEquals(1, first.getWarnings().size());
+      assertEquals(first.getWarnings(), second.getWarnings(), "every 
resolution still reports it");
+    } finally {
+      HopLogStore.getAppender().removeLoggingEventListener(listener);
+      Files.deleteIfExists(projectYaml.toPath());
+    }
+  }
+
+  @Test
+  public void anIdFarFromAnyRuleGetsNoSuggestion() throws Exception {
+    String warning =
+        RuleRegistry.unknownRuleWarning(
+            "COMPLETELY-DIFFERENT", List.of("SQL-002", "DB-001"), new 
File("hop-lint.yml"));
+
+    assertFalse(warning.contains("Did you mean"), warning);
+  }
 }

Reply via email to