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 0d18753202 Fixes #8732 : Lint in the project context: -j and -e for 
hop lint, and the project index for Lint Project and Lint Selected Folder 
(#8742)
0d18753202 is described below

commit 0d187532020bbb14feb0b77cf7e020fdb3fdb94e
Author: Bart Maertens <[email protected]>
AuthorDate: Sat Oct 3 11:36:23 2026 +0200

    Fixes #8732 : Lint in the project context: -j and -e for hop lint, and the 
project index for Lint Project and Lint Selected Folder (#8742)
---
 .../hop/core/config/plugin/ConfigPlugin.java       |   1 +
 .../modules/ROOT/pages/hop-tools/hop-lint.adoc     |  15 ++
 .../org/apache/hop/lint/CustomRuleExecutor.java    |   5 +
 .../org/apache/hop/lint/ExplorerLintGuiPlugin.java |  87 +++----
 .../main/java/org/apache/hop/lint/HopLinter.java   |  56 ++++-
 .../main/java/org/apache/hop/lint/LintCommand.java | 174 +++++++++++---
 .../java/org/apache/hop/lint/LintPathUtils.java    |  13 ++
 .../org/apache/hop/lint/ProjectContextTest.java    | 250 +++++++++++++++++++++
 .../projects/config/ProjectsLintOptionPlugin.java  |  27 +++
 9 files changed, 559 insertions(+), 69 deletions(-)

diff --git 
a/core/src/main/java/org/apache/hop/core/config/plugin/ConfigPlugin.java 
b/core/src/main/java/org/apache/hop/core/config/plugin/ConfigPlugin.java
index 9143f53802..89b58180b4 100644
--- a/core/src/main/java/org/apache/hop/core/config/plugin/ConfigPlugin.java
+++ b/core/src/main/java/org/apache/hop/core/config/plugin/ConfigPlugin.java
@@ -37,6 +37,7 @@ public @interface ConfigPlugin {
   String CATEGORY_DOC = "doc";
   String CATEGORY_PYTHON = "python";
   String CATEGORY_NAMING = "naming";
+  String CATEGORY_LINT = "lint";
   String CATEGORY_GUI = "gui";
   String CATEGORY_EXPORT = "export";
 
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 c1f121a34b..becac14f5e 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
@@ -36,16 +36,31 @@ See xref:linting/index.adoc[Linting] for what the linter 
does, and xref:linting/
 hop lint                              # lint the current directory
 hop lint pipelines/load.hpl           # lint one file
 hop lint /path/to/project             # lint a folder
+hop lint -e dev /path/to/project      # lint with the variables of an 
environment
 hop lint --list-rules                 # show the effective rule set
 hop lint --help                       # full option list
 ----
 
+== Project and environment
+
+`hop lint` takes the project and environment options of `hop run`.
+Hop's own checks then see the variables a pipeline is run with, so a transform 
that reads `'${INPUT_FOLDER}'` from the environment is checked against that 
folder rather than reported as unable to find it.
+Without `-j` or `-e` the default project and environment from the Hop 
configuration apply, but only to what lies inside that project.
+Anything else, such as another project's folder or a commit checked by the 
pre-commit hook, is linted with the `metadata` folder found above it.
+A project chosen with `-j` or `-e` applies whatever is linted; for a target 
outside it, `hop lint` prints a warning.
+
 == Options
 
 [cols="1,3", options="header"]
 |===
 |Option |Description
 
+|`-j`, `--project <name>`
+|Lint with this project's variables and metadata
+
+|`-e`, `--environment <name>`
+|Lint with this environment's variables, and its project's
+
 |`--format <format>`
 |Report format: `text` (default), `json` or `sarif`
 
diff --git 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/CustomRuleExecutor.java 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/CustomRuleExecutor.java
index 1fe5f6be25..faeec3e56e 100644
--- 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/CustomRuleExecutor.java
+++ 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/CustomRuleExecutor.java
@@ -87,6 +87,11 @@ public class CustomRuleExecutor {
     }
   }
 
+  /** Whether a project index is in place for the rules evaluated on this 
thread. */
+  public static boolean hasProjectIndex() {
+    return PROJECT_INDEX.get().isPopulated();
+  }
+
   /**
    * Make the file being linted available to the rules evaluated on this 
thread.
    *
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 4c7988d96d..a7c311ee31 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
@@ -540,47 +540,58 @@ public class ExplorerLintGuiPlugin {
             List<LintResult> results = new java.util.ArrayList<>();
             int processedFilesCount = 0;
 
-            for (String filePath : hopFilePaths) {
-              File file = new File(filePath);
-              if (progressDialog.isCancelled()) {
-                log.logDetailed("Folder linting cancelled by user");
-                return;
-              }
+            // The unreferenced pipeline and workflow rules need the whole 
project, not just the
+            // folder: a pipeline in it may be called from anywhere in the 
project.
+            CustomRuleExecutor.setProjectIndex(
+                linter.buildProjectIndex(folderPath, metadataProvider, 
variables));
+            try {
+
+              for (String filePath : hopFilePaths) {
+                File file = new File(filePath);
+                if (progressDialog.isCancelled()) {
+                  log.logDetailed("Folder linting cancelled by user");
+                  return;
+                }
 
-              try {
-                progressDialog.updateProgress(
-                    "Processing: " + file.getName(), processedFilesCount, 
hopFilePaths.size());
-                String normalizedPath = 
LintPathUtils.normalizePath(file.getAbsolutePath());
-                List<LintResult> fileResults;
-                if (normalizedPath.toLowerCase().endsWith(".hpl")) {
-                  PipelineMeta pipelineMeta =
-                      new PipelineMeta(file.getAbsolutePath(), 
metadataProvider, variables);
-                  fileResults =
-                      PipelineLintResultsBuilder.build(
-                          pipelineMeta, normalizedPath, metadataProvider, 
variables);
-                } else if 
(HopMetadataFileLoader.isMetadataJsonFile(normalizedPath)) {
-                  fileResults = linter.processFile(file, metadataProvider, 
variables);
-                } else {
-                  fileResults = linter.processFile(file, metadataProvider, 
variables);
+                try {
+                  progressDialog.updateProgress(
+                      "Processing: " + file.getName(), processedFilesCount, 
hopFilePaths.size());
+                  String normalizedPath = 
LintPathUtils.normalizePath(file.getAbsolutePath());
+                  List<LintResult> fileResults;
+                  if (normalizedPath.toLowerCase().endsWith(".hpl")) {
+                    PipelineMeta pipelineMeta =
+                        new PipelineMeta(file.getAbsolutePath(), 
metadataProvider, variables);
+                    fileResults =
+                        PipelineLintResultsBuilder.build(
+                            pipelineMeta, normalizedPath, metadataProvider, 
variables);
+                  } else if 
(HopMetadataFileLoader.isMetadataJsonFile(normalizedPath)) {
+                    fileResults = linter.processFile(file, metadataProvider, 
variables);
+                  } else {
+                    fileResults = linter.processFile(file, metadataProvider, 
variables);
+                  }
+                  results.addAll(fileResults);
+                  LintResultsManager.getInstance()
+                      .updateResultsForFile(normalizedPath, fileResults);
+                  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);
+                  LintResultsManager.getInstance()
+                      .updateResultsForFile(
+                          LintPathUtils.normalizePath(file.getAbsolutePath()),
+                          List.of(errorResult));
+                  processedFilesCount++;
                 }
-                results.addAll(fileResults);
-                
LintResultsManager.getInstance().updateResultsForFile(normalizedPath, 
fileResults);
-                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);
-                LintResultsManager.getInstance()
-                    .updateResultsForFile(
-                        LintPathUtils.normalizePath(file.getAbsolutePath()), 
List.of(errorResult));
-                processedFilesCount++;
               }
+            } finally {
+              CustomRuleExecutor.setProjectIndex(null);
             }
 
             progressDialog.setComplete("Completed. Found " + results.size() + 
" issues");
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 23b41b9f77..455128b7c9 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
@@ -32,6 +32,7 @@ import org.apache.hop.core.IProgressMonitor;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.logging.ILogChannel;
 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.lint.registry.EffectiveRuleSet;
@@ -195,6 +196,21 @@ public class HopLinter {
     List<LintResult> allResults = new ArrayList<>();
     long startTime = System.currentTimeMillis();
 
+    if (variables == null) {
+      variables = Variables.getADefaultVariableSpace();
+    }
+    // Lint Project in Hop Gui called this without an index, so the rules that 
need the whole
+    // project (STRUCT-004 and STRUCT-005, unreferenced pipelines and 
workflows) were skipped there
+    // while the CLI, which built its own, reported them.
+    boolean ownIndex = false;
+    if (!CustomRuleExecutor.hasProjectIndex()) {
+      LintProjectIndex index = buildProjectIndex(projectPath, 
metadataProvider, variables);
+      if (index != null) {
+        CustomRuleExecutor.setProjectIndex(index);
+        ownIndex = true;
+      }
+    }
+
     try {
       log.logBasic("Starting linter with project path: " + projectPath);
 
@@ -220,12 +236,6 @@ public class HopLinter {
               + fileDiscoveryTime
               + "ms)");
 
-      // Create default variables if not provided
-      if (variables == null) {
-        log.logBasic("Variables is null, creating default variable space");
-        variables = Variables.getADefaultVariableSpace();
-      }
-
       // Process each file
       long fileProcessingStart = System.currentTimeMillis();
       int processedFiles = 0;
@@ -317,11 +327,45 @@ public class HopLinter {
               "ERROR",
               "Failed to complete linting: " + e.getMessage(),
               "system"));
+    } finally {
+      if (ownIndex) {
+        CustomRuleExecutor.setProjectIndex(null);
+      }
     }
 
     return allResults;
   }
 
+  /**
+   * The references in the project a lint target belongs to, for the rules 
that need the whole
+   * project to answer.
+   *
+   * <p>The index covers {@code PROJECT_HOME} when the target lies inside it, 
so a folder or a file
+   * is judged against everything that could call it, not only its neighbours. 
Outside a project a
+   * folder is indexed on its own; a single file outside a project gets no 
index, and those rules
+   * stay quiet rather than guess.
+   *
+   * @return the index, or null when the target has no project to index
+   */
+  public LintProjectIndex buildProjectIndex(
+      String targetPath, IHopMetadataProvider metadataProvider, IVariables 
variables) {
+    File target = new File(targetPath).getAbsoluteFile();
+    String root = null;
+    String projectHome = variables == null ? null : 
variables.getVariable("PROJECT_HOME");
+    if (!Utils.isEmpty(projectHome) && LintPathUtils.isWithin(target, new 
File(projectHome))) {
+      root = new File(projectHome).getAbsolutePath();
+    } else if (target.isDirectory()) {
+      root = target.getPath();
+    }
+    if (root == null) {
+      return null;
+    }
+    LintProjectIndex index =
+        LintProjectIndex.build(findHopFiles(root), metadataProvider, 
variables);
+    log.logDetailed("Indexed " + index.getIndexedFiles().size() + " file(s) 
under " + root);
+    return index;
+  }
+
   /**
    * Find all .hpl and .hwf files in the project directory
    *
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 356ca8ba96..f481f76ae8 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
@@ -37,6 +37,8 @@ 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.config.plugin.ConfigPlugin;
+import org.apache.hop.core.config.plugin.IConfigOptions;
 import org.apache.hop.core.encryption.Encr;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.logging.DefaultLogLevel;
@@ -50,10 +52,12 @@ import org.apache.hop.core.plugins.TransformPluginType;
 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.hop.Hop;
 import org.apache.hop.hop.plugin.HopCommand;
 import org.apache.hop.hop.plugin.IHopCommand;
 import org.apache.hop.lint.registry.EffectiveRuleSet;
 import org.apache.hop.lint.registry.RuleRegistry;
+import org.apache.hop.metadata.api.IHasHopMetadataProvider;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.metadata.serializer.json.JsonMetadataProvider;
 import org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
@@ -80,7 +84,7 @@ import picocli.CommandLine.Parameters;
             + "reaches the --fail-on threshold (ERROR by default) or warnings 
exceed "
             + "--max-warnings.")
 @HopCommand(id = "lint", description = "Check Hop files against the lint 
rules")
-public class LintCommand implements Callable<Integer>, IHopCommand {
+public class LintCommand implements Callable<Integer>, IHopCommand, 
IHasHopMetadataProvider {
 
   // Deliberately no static ILogChannel field here. Touching LogChannel loads 
Hop's configuration
   // during class initialisation, which prints to stdout before main() gets a 
chance to run — and
@@ -189,14 +193,28 @@ public class LintCommand implements Callable<Integer>, 
IHopCommand {
   private IVariables commandVariables;
   private MultiMetadataProvider metadataProvider;
 
+  /** Whether -j, -e or a default project enabled a project, whose metadata 
then applies. */
+  private boolean projectEnabled;
+
+  /** Whether the project was asked for with -j or -e, here or on the hop 
command itself. */
+  private boolean projectChosen;
+
+  /** The variables as they were before a project was enabled, to fall back 
to. */
+  private IVariables variablesBeforeProject;
+
+  private boolean prepared;
+
   @Override
   public void initialize(
-      CommandLine cmd, IVariables variables, MultiMetadataProvider 
metadataProvider) {
+      CommandLine cmd, IVariables variables, MultiMetadataProvider 
metadataProvider)
+      throws HopException {
     this.cmd = cmd;
     this.commandVariables = variables;
     this.metadataProvider = metadataProvider;
     // The hand-rolled parser upper-cased these, so "--severity warning" has 
to keep working.
     cmd.setCaseInsensitiveEnumValuesAllowed(true);
+    // -j and -e, from the projects plugin, as hop run and the other commands 
have them.
+    Hop.addMixinPlugins(cmd, ConfigPlugin.CATEGORY_LINT);
   }
 
   // The stack trace is only printed when the user asks for it with --verbose
@@ -232,17 +250,17 @@ public class LintCommand implements Callable<Integer>, 
IHopCommand {
     }
 
     if (!Utils.isEmpty(listFieldsFor)) {
-      initializeHopEnvironment();
+      prepare();
       return printFields(listFieldsFor);
     }
 
     if (listMetadataTypes) {
-      initializeHopEnvironment();
+      prepare();
       return printMetadataTypes();
     }
 
     if (listRules) {
-      initializeHopEnvironment();
+      prepare();
       printRuleList();
       return 0;
     }
@@ -265,7 +283,8 @@ public class LintCommand implements Callable<Integer>, 
IHopCommand {
 
     try {
       printRunHeader(target);
-      initializeHopEnvironment();
+      prepare();
+      applyProjectTo(target);
 
       HopLinter linter = new HopLinter();
       loadConfiguration(linter, target);
@@ -429,8 +448,9 @@ public class LintCommand implements Callable<Integer>, 
IHopCommand {
    */
   private int printMetadataTypes() {
     try {
-      IVariables variables = Variables.getADefaultVariableSpace();
       String targetPath = Utils.isEmpty(target) ? userDirectory() : target;
+      applyProjectTo(targetPath);
+      IVariables variables = variables();
       IHopMetadataProvider provider = resolveMetadataProvider(new 
File(targetPath), variables);
       if (provider == null) {
         System.err.println("No metadata provider available; cannot list 
metadata types.");
@@ -565,7 +585,7 @@ public class LintCommand implements Callable<Integer>, 
IHopCommand {
       throw new IllegalArgumentException("--pre-commit requires --staged-file 
<path>");
     }
 
-    initializeHopEnvironment();
+    prepare();
     List<File> stagedFiles =
         PreCommitLintService.readStagedFiles(stagedFileList, new 
File(userDirectory()));
     if (stagedFiles.isEmpty()) {
@@ -580,8 +600,9 @@ public class LintCommand implements Callable<Integer>, 
IHopCommand {
 
     // 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());
 
-    IVariables variables = Variables.getADefaultVariableSpace();
+    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);
@@ -671,6 +692,103 @@ public class LintCommand implements Callable<Integer>, 
IHopCommand {
     System.out.println();
   }
 
+  /**
+   * Start Hop and apply {@code -j} / {@code -e}, once.
+   *
+   * <p>Without this the run used an empty set of variables: a Get File Names 
transform reading
+   * {@code ${JDBC_PROPERTIES_FOLDER}} from the project's environment reported 
"No files can be
+   * found to read", where Verify in Hop Gui, with the environment active, was 
clean. As with {@code
+   * hop run}, the default project and environment from hop-config.json are 
enabled when neither
+   * option is given; {@link #applyProjectTo} then decides whether they apply 
to what is linted.
+   */
+  private void prepare() throws HopException {
+    if (prepared) {
+      return;
+    }
+    prepared = true;
+    initializeHopEnvironment();
+    if (cmd == null) {
+      return;
+    }
+    IVariables variables = variables();
+    // "hop -j x lint" enables the project before this command runs, and 
leaves its name behind.
+    projectChosen =
+        !Utils.isEmpty(variables.getVariable("HOP_PROJECT_NAME"))
+            || optionGiven("-j", "--project", "-e", "--environment");
+    variablesBeforeProject = new Variables();
+    variablesBeforeProject.copyFrom(variables);
+    for (Object mixin : cmd.getMixins().values()) {
+      if (mixin instanceof IConfigOptions options
+          && options.handleOption(LogChannel.GENERAL, this, variables)) {
+        projectEnabled = true;
+      }
+    }
+  }
+
+  private boolean optionGiven(String... names) {
+    CommandLine.ParseResult parseResult = cmd.getParseResult();
+    if (parseResult == null) {
+      return false;
+    }
+    for (String name : names) {
+      if (parseResult.hasMatchedOption(name)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /** The variables Hop was started with, with the project's and environment's 
on top. */
+  private IVariables variables() {
+    if (commandVariables == null) {
+      commandVariables = Variables.getADefaultVariableSpace();
+    }
+    return commandVariables;
+  }
+
+  /**
+   * Keep the enabled project's variables and metadata only where they belong: 
when -j or -e asked
+   * for it, or when what is linted lies inside it.
+   *
+   * <p>A stock hop-config.json has a default project. Applied to everything, 
it made {@code hop
+   * lint /path/to/other-project} and the pre-commit hook read the default 
project's connections
+   * instead of the other project's own {@code metadata/} folder, and report 
connections that exist
+   * as missing. A default project that does not contain the target is 
dropped, and the target's own
+   * metadata folder is used as before.
+   */
+  private void applyProjectTo(String path) {
+    if (!projectEnabled) {
+      return;
+    }
+    String projectHome = variables().getVariable("PROJECT_HOME");
+    boolean contains =
+        !Utils.isEmpty(projectHome)
+            && !Utils.isEmpty(path)
+            && LintPathUtils.isWithin(new File(path), new File(projectHome));
+    if (contains) {
+      return;
+    }
+    if (projectChosen) {
+      System.err.println(
+          "Warning: "
+              + path
+              + " is outside the project in use ("
+              + projectHome
+              + "); its variables and metadata apply.");
+      return;
+    }
+    commandVariables = variablesBeforeProject;
+    projectEnabled = false;
+    if (verbose) {
+      System.out.println(
+          "Not using the default project ("
+              + projectHome
+              + "): it does not contain "
+              + path
+              + ". Choose a project with -j or -e.");
+    }
+  }
+
   /**
    * Bring up Hop far enough to load pipelines and workflows properly.
    *
@@ -726,33 +844,30 @@ public class LintCommand implements Callable<Integer>, 
IHopCommand {
 
   private List<LintResult> runLinting(HopLinter linter, String targetPath) 
throws Exception {
     File targetFile = new File(targetPath);
-    IVariables variables = Variables.getADefaultVariableSpace();
+    IVariables variables = variables();
     IHopMetadataProvider metadataProvider = 
resolveMetadataProvider(targetFile, variables);
 
     if (targetFile.isFile()) {
       if (verbose) {
         System.out.println("Linting file: " + targetPath);
       }
-      return new ArrayList<>(linter.processFile(targetFile, metadataProvider, 
variables));
+      // A file in a project is judged against the whole project, so it can be 
reported as
+      // called by nothing; outside one there is nothing to judge it against.
+      CustomRuleExecutor.setProjectIndex(
+          linter.buildProjectIndex(targetPath, metadataProvider, variables));
+      try {
+        return new ArrayList<>(linter.processFile(targetFile, 
metadataProvider, variables));
+      } finally {
+        CustomRuleExecutor.setProjectIndex(null);
+      }
     }
     if (targetFile.isDirectory()) {
       if (verbose) {
         System.out.println("Linting directory: " + targetPath);
       }
-      // Index the project's references first, so that rules which depend on 
the project as a whole
-      // — whether a pipeline is called by anything, whether a connection is 
used — have something
-      // to read. Only a directory lint can build this; a single file has no 
project to see.
-      List<String> projectFiles = linter.findHopFiles(targetPath);
-      LintProjectIndex index = LintProjectIndex.build(projectFiles, 
metadataProvider, variables);
-      if (verbose) {
-        System.out.println("Indexed " + index.getIndexedFiles().size() + " 
file(s) for references");
-      }
-      CustomRuleExecutor.setProjectIndex(index);
-      try {
-        return new ArrayList<>(linter.run(targetPath, metadataProvider, 
variables, null));
-      } finally {
-        CustomRuleExecutor.setProjectIndex(null);
-      }
+      // The run indexes the project's references itself, for the rules that 
need the project
+      // as a whole: whether a pipeline is called by anything, whether a 
connection is used.
+      return new ArrayList<>(linter.run(targetPath, metadataProvider, 
variables, null));
     }
     throw new IllegalArgumentException("Target does not exist: " + targetPath);
   }
@@ -766,6 +881,15 @@ public class LintCommand implements Callable<Integer>, 
IHopCommand {
    * about it: point the linter at a pipeline deep in a project and it still 
finds the project.
    */
   private IHopMetadataProvider resolveMetadataProvider(File target, IVariables 
variables) {
+    // An enabled project brings its own metadata, parent projects included, 
from wherever its
+    // configuration says it lives.
+    if (projectEnabled && metadataProvider != null) {
+      if (verbose) {
+        System.out.println(
+            "Using the metadata of project home: " + 
variables.getVariable("PROJECT_HOME"));
+      }
+      return metadataProvider;
+    }
     File metadataFolder = findMetadataFolder(target);
     if (metadataFolder == null) {
       if (verbose) {
diff --git 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintPathUtils.java 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintPathUtils.java
index 913e28bac2..177b38fd6f 100644
--- a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintPathUtils.java
+++ b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintPathUtils.java
@@ -17,6 +17,7 @@
 package org.apache.hop.lint;
 
 import java.io.File;
+import java.io.IOException;
 import org.apache.commons.vfs2.FileObject;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.vfs.HopVfs;
@@ -74,6 +75,18 @@ public final class LintPathUtils {
     return normalizedA.equalsIgnoreCase(normalizedB);
   }
 
+  /** Whether a file or folder is the folder given or lies somewhere below it. 
*/
+  public static boolean isWithin(File path, File folder) {
+    if (path == null || folder == null) {
+      return false;
+    }
+    try {
+      return 
path.getCanonicalFile().toPath().startsWith(folder.getCanonicalFile().toPath());
+    } catch (IOException e) {
+      return 
path.getAbsoluteFile().toPath().startsWith(folder.getAbsoluteFile().toPath());
+    }
+  }
+
   /** Explorer passes the full VFS path as {@code path} and the basename as 
{@code name}. */
   public static String resolveExplorerFilePath(String path, String name) {
     if (Utils.isEmpty(path)) {
diff --git 
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/ProjectContextTest.java 
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/ProjectContextTest.java
new file mode 100644
index 0000000000..51179d9feb
--- /dev/null
+++ 
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/ProjectContextTest.java
@@ -0,0 +1,250 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.lint;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import org.apache.hop.core.config.plugin.IConfigOptions;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.metadata.api.IHasHopMetadataProvider;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import picocli.CommandLine;
+
+/**
+ * Linting in the context of a project.
+ *
+ * <p>{@code hop lint} ignored the project and its environment, so variables 
they define were
+ * unresolved. Lint Project and Lint Selected Folder in Hop Gui left out the 
rules that need the
+ * whole project, STRUCT-004 and STRUCT-005, which the CLI reported.
+ *
+ * @see <a href="https://github.com/apache/hop/issues/8732";>#8732</a>
+ */
+public class ProjectContextTest {
+
+  @TempDir private Path dir;
+
+  private Path project;
+
+  @BeforeEach
+  void createProject() throws Exception {
+    project = dir.resolve("project");
+    Files.createDirectories(project.resolve("pipelines/load"));
+    Files.writeString(
+        project.resolve("hop-lint.yml"), "rules:\n  STRUCT-004:\n    enabled: 
true\n");
+    writePipeline(project.resolve("pipelines/load/customers.hpl"));
+    writePipeline(project.resolve("pipelines/report.hpl"));
+  }
+
+  @AfterEach
+  void clearIndex() {
+    CustomRuleExecutor.setProjectIndex(null);
+  }
+
+  private static void writePipeline(Path file) throws Exception {
+    PipelineMeta pipelineMeta = new PipelineMeta();
+    pipelineMeta.setName(file.getFileName().toString().replace(".hpl", ""));
+    Files.writeString(file, 
pipelineMeta.getXml(Variables.getADefaultVariableSpace()));
+  }
+
+  private IVariables projectVariables() {
+    IVariables variables = Variables.getADefaultVariableSpace();
+    variables.setVariable("PROJECT_HOME", project.toString());
+    return variables;
+  }
+
+  private static long count(List<LintResult> results, String ruleId) {
+    return results.stream().filter(r -> ruleId.equals(r.getRuleId())).count();
+  }
+
+  /** What Lint Project in Hop Gui does: run() with no index of its own. */
+  @Test
+  public void aProjectLintReportsUnreferencedPipelines() {
+    List<LintResult> results =
+        new HopLinter()
+            .run(project.toString(), new MemoryMetadataProvider(), 
projectVariables(), null);
+
+    assertEquals(2, count(results, "STRUCT-004"), results.toString());
+    assertFalse(CustomRuleExecutor.hasProjectIndex(), "the run leaves no index 
behind");
+  }
+
+  /** A folder is indexed against the whole project, not only itself. */
+  @Test
+  public void aFolderInAProjectIsIndexedAgainstTheProject() {
+    LintProjectIndex index =
+        new HopLinter()
+            .buildProjectIndex(
+                project.resolve("pipelines/load").toString(), null, 
projectVariables());
+
+    assertTrue(
+        index.getIndexedFiles().stream().anyMatch(f -> 
f.endsWith("pipelines/report.hpl")),
+        index.getIndexedFiles().toString());
+  }
+
+  @Test
+  public void aFolderOutsideAProjectIsIndexedOnItsOwn() {
+    LintProjectIndex index =
+        new HopLinter()
+            .buildProjectIndex(
+                project.resolve("pipelines/load").toString(),
+                null,
+                Variables.getADefaultVariableSpace());
+
+    assertEquals(1, index.getIndexedFiles().size(), 
index.getIndexedFiles().toString());
+  }
+
+  @Test
+  public void aSingleFileOutsideAProjectHasNoIndex() {
+    assertNull(
+        new HopLinter()
+            .buildProjectIndex(
+                project.resolve("pipelines/report.hpl").toString(),
+                null,
+                Variables.getADefaultVariableSpace()));
+  }
+
+  // ------------------------------------------------------------------ hop 
lint -j / -e
+
+  /**
+   * Stands in for the projects plugin's -j / -e. It enables its project 
whether or not -j is given,
+   * as the real one enables the default project of hop-config.json.
+   */
+  public static class FakeProjectOptions implements IConfigOptions {
+    @CommandLine.Option(
+        names = {"-j", "--project"},
+        description = "The project")
+    private String projectName;
+
+    private final String projectHome;
+    private final MultiMetadataProvider projectMetadata;
+
+    FakeProjectOptions(String projectHome, MultiMetadataProvider 
projectMetadata) {
+      this.projectHome = projectHome;
+      this.projectMetadata = projectMetadata;
+    }
+
+    @Override
+    public boolean handleOption(
+        ILogChannel log, IHasHopMetadataProvider hasHopMetadataProvider, 
IVariables variables) {
+      variables.setVariable("PROJECT_HOME", projectHome);
+      hasHopMetadataProvider.setMetadataProvider(projectMetadata);
+      return true;
+    }
+  }
+
+  /** Runs hop lint with the stand-in project; returns stdout and stderr. */
+  private String[] lint(String projectHome, String... args) throws Exception {
+    LintCommand command = new LintCommand();
+    CommandLine commandLine = new CommandLine(command);
+    command.initialize(
+        commandLine,
+        Variables.getADefaultVariableSpace(),
+        new MultiMetadataProvider(Variables.getADefaultVariableSpace()));
+    MultiMetadataProvider projectMetadata =
+        new MultiMetadataProvider(Variables.getADefaultVariableSpace());
+    commandLine.addMixin("project", new FakeProjectOptions(projectHome, 
projectMetadata));
+
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    ByteArrayOutputStream err = new ByteArrayOutputStream();
+    PrintStream systemOut = System.out;
+    PrintStream systemErr = System.err;
+    try {
+      System.setOut(new PrintStream(out, true, StandardCharsets.UTF_8));
+      System.setErr(new PrintStream(err, true, StandardCharsets.UTF_8));
+      commandLine.execute(args);
+    } finally {
+      System.setOut(systemOut);
+      System.setErr(systemErr);
+    }
+    return new String[] {
+      out.toString(StandardCharsets.UTF_8), 
err.toString(StandardCharsets.UTF_8)
+    };
+  }
+
+  @Test
+  public void theProjectsMetadataIsUsed() throws Exception {
+    String[] output = lint(project.toString(), "-v", 
project.resolve("pipelines").toString());
+
+    assertTrue(output[0].contains("Using the metadata of project home"), 
output[0]);
+    assertFalse(output[1].contains("outside the project"), output[1]);
+  }
+
+  /** Another project, with its own metadata folder, outside the default one. 
*/
+  private Path otherProject() throws Exception {
+    Path other = dir.resolve("other");
+    Files.createDirectories(other.resolve("metadata/rdbms"));
+    writePipeline(other.resolve("orders.hpl"));
+    return other;
+  }
+
+  /**
+   * A stock hop-config.json has a default project. Applied to a project it 
does not contain, it
+   * replaced that project's own metadata and reported its connections as 
missing.
+   */
+  @Test
+  public void aDefaultProjectDoesNotApplyToAnotherProject() throws Exception {
+    Path other = otherProject();
+
+    String[] output = lint(project.toString(), "-v", other.toString());
+
+    assertTrue(
+        output[0].contains("Using metadata folder: " + 
other.resolve("metadata")), output[0]);
+    assertFalse(output[0].contains("Using the metadata of project home"), 
output[0]);
+    assertFalse(output[1].contains("outside the project"), output[1]);
+  }
+
+  @Test
+  public void theHookUsesTheMetadataOfTheProjectItCommitsTo() throws Exception 
{
+    Path other = otherProject();
+    Path staged = dir.resolve("staged.txt");
+    Files.writeString(staged, other.resolve("orders.hpl") + "\n");
+
+    String[] output =
+        lint(project.toString(), "-v", "--pre-commit", "--staged-file", 
staged.toString());
+
+    assertTrue(
+        output[0].contains("Using metadata folder: " + 
other.resolve("metadata")), output[0]);
+    assertFalse(output[0].contains("Using the metadata of project home"), 
output[0]);
+  }
+
+  /** A project asked for with -j applies wherever the target is, with a 
warning. */
+  @Test
+  public void aChosenProjectAppliesOutsideItsHomeWithAWarning() throws 
Exception {
+    Path other = otherProject();
+
+    String[] output = lint(project.toString(), "-v", "-j", "default", 
other.toString());
+
+    assertTrue(output[0].contains("Using the metadata of project home"), 
output[0]);
+    assertTrue(output[1].contains("is outside the project in use"), output[1]);
+  }
+}
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsLintOptionPlugin.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsLintOptionPlugin.java
new file mode 100644
index 0000000000..0b2b1a5e2a
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsLintOptionPlugin.java
@@ -0,0 +1,27 @@
+/*
+ * 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.projects.config;
+
+import org.apache.hop.core.config.plugin.ConfigPlugin;
+import org.apache.hop.core.config.plugin.IConfigOptions;
+
+@ConfigPlugin(
+    id = "ProjectsLintOptionPlugin",
+    description = "Project and Environment configuration options for hop lint",
+    category = ConfigPlugin.CATEGORY_LINT)
+public class ProjectsLintOptionPlugin extends ProjectsOptionPlugin implements 
IConfigOptions {}

Reply via email to