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 663ea3080a Rework janino scanner to make it lighter in mem and faster 
(#7190)
663ea3080a is described below

commit 663ea3080a4f6c8d19decd128d2b7294f9335ea5
Author: Romain Manni-Bucau <[email protected]>
AuthorDate: Tue Jun 2 13:58:30 2026 +0200

    Rework janino scanner to make it lighter in mem and faster (#7190)
---
 .../apache/hop/core/plugins/HopURLClassLoader.java |  25 ++
 .../hop/core/vfs/HopVfsNetworkProvidersTest.java   |  42 ++-
 plugins/transforms/janino/pom.xml                  |   5 +
 .../transforms/janino/function/FunctionLib.java    |  83 ++---
 .../janino/scanner/ClassLoaderScanner.java         | 143 +++++++++
 .../janino/scanner/JarExclusionsLoader.java        |  74 +++++
 .../resources/ClassLoaderScanner.ignored-jars.txt  | 343 +++++++++++++++++++++
 .../janino/scanner/ClassLoaderScannerTest.java     | 112 +++++++
 .../janino/scanner/FunctionGenerator.java          | 109 +++++++
 .../transforms/janino/scanner/FunctionLibTest.java |  76 +++++
 .../janino/scanner/JarExclusionsLoaderTest.java    |  78 +++++
 11 files changed, 1045 insertions(+), 45 deletions(-)

diff --git 
a/core/src/main/java/org/apache/hop/core/plugins/HopURLClassLoader.java 
b/core/src/main/java/org/apache/hop/core/plugins/HopURLClassLoader.java
index d1df3e390c..999661c862 100644
--- a/core/src/main/java/org/apache/hop/core/plugins/HopURLClassLoader.java
+++ b/core/src/main/java/org/apache/hop/core/plugins/HopURLClassLoader.java
@@ -21,10 +21,13 @@ import java.io.InputStream;
 import java.net.URL;
 import java.net.URLClassLoader;
 import java.security.ProtectionDomain;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Supplier;
 
 public class HopURLClassLoader extends URLClassLoader {
 
   private String name;
+  private final ConcurrentHashMap<Class<?>, Object> cache = new 
ConcurrentHashMap<>();
 
   public HopURLClassLoader(URL[] url, ClassLoader classLoader) {
     super(url, classLoader);
@@ -35,9 +38,31 @@ public class HopURLClassLoader extends URLClassLoader {
     this.name = name;
   }
 
+  public <T> T get(final Class<T> key) {
+    return key.cast(cache.get(key));
+  }
+
+  public <T> T computeIfAbsent(final Class<T> key, Supplier<T> provider) {
+    return key.cast(cache.computeIfAbsent(key, k -> provider));
+  }
+
   @Override
   protected void addURL(URL url) {
     super.addURL(url);
+    // invalidate the cache since it is wrong if related to the classloader,
+    // do not lock since we assume addURL is called in a safe context
+    cache.values().stream()
+        .filter(AutoCloseable.class::isInstance)
+        .map(AutoCloseable.class::cast)
+        .forEach(
+            it -> {
+              try {
+                it.close();
+              } catch (final Exception e) {
+                // no-op
+              }
+            });
+    cache.clear();
   }
 
   @Override
diff --git 
a/core/src/test/java/org/apache/hop/core/vfs/HopVfsNetworkProvidersTest.java 
b/core/src/test/java/org/apache/hop/core/vfs/HopVfsNetworkProvidersTest.java
index 928730fba1..c5c2c8385f 100644
--- a/core/src/test/java/org/apache/hop/core/vfs/HopVfsNetworkProvidersTest.java
+++ b/core/src/test/java/org/apache/hop/core/vfs/HopVfsNetworkProvidersTest.java
@@ -17,6 +17,7 @@
 package org.apache.hop.core.vfs;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.fail;
 
 import com.sun.net.httpserver.HttpServer;
 import com.sun.net.httpserver.HttpsConfigurator;
@@ -24,7 +25,9 @@ import com.sun.net.httpserver.HttpsServer;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.net.InetAddress;
 import java.net.InetSocketAddress;
+import java.net.UnknownHostException;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -70,6 +73,7 @@ class HopVfsNetworkProvidersTest {
   private static final String FTP_PASS = "secret";
   private static final String SFTP_USER = "alice";
   private static final String SFTP_PASS = "secret";
+  private static String LOCALHOST;
 
   @TempDir static Path sharedRoot;
 
@@ -93,19 +97,27 @@ class HopVfsNetworkProvidersTest {
   private static SshServer sshServer;
   private static int sftpPort;
 
+  static {
+    try {
+      LOCALHOST = InetAddress.getLocalHost().getHostAddress();
+    } catch (final UnknownHostException e) {
+      fail(e);
+    }
+  }
+
   @BeforeAll
   static void startServers() throws Exception {
     keyStorePath = generateTestKeyStore();
 
     // HTTP
-    httpServer = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
+    httpServer = HttpServer.create(new InetSocketAddress(LOCALHOST, 0), 0);
     httpPort = httpServer.getAddress().getPort();
     httpServer.createContext("/payload.txt", new 
FixedPayloadHandler("http-payload"));
     httpServer.start();
 
     // HTTPS
     SSLContext sslContext = buildServerSslContext(keyStorePath);
-    httpsServer = HttpsServer.create(new InetSocketAddress("localhost", 0), 0);
+    httpsServer = HttpsServer.create(new InetSocketAddress(LOCALHOST, 0), 0);
     httpsServer.setHttpsConfigurator(new HttpsConfigurator(sslContext));
     httpsServer.createContext("/secure.txt", new 
FixedPayloadHandler("https-payload"));
     httpsServer.start();
@@ -158,19 +170,22 @@ class HopVfsNetworkProvidersTest {
   @Test
   @DisplayName("http:// fetches a payload from an embedded HttpServer")
   void httpProviderReadsFromEmbeddedServer() throws Exception {
-    assertEquals("http-payload", readToString("http://localhost:"; + httpPort + 
"/payload.txt"));
+    assertEquals(
+        "http-payload", readToString("http://"; + LOCALHOST + ":" + httpPort + 
"/payload.txt"));
   }
 
   @Test
   @DisplayName("https:// fetches a payload over TLS from an embedded 
HttpsServer")
   void httpsProviderReadsFromEmbeddedServer() throws Exception {
-    assertEquals("https-payload", readToString("https://localhost:"; + 
httpsPort + "/secure.txt"));
+    assertEquals(
+        "https-payload", readToString("https://"; + LOCALHOST + ":" + httpsPort 
+ "/secure.txt"));
   }
 
   @Test
   @DisplayName("ftp:// fetches a payload from an embedded Apache FtpServer")
   void ftpProviderReadsFromEmbeddedServer() throws Exception {
-    String url = "ftp://"; + FTP_USER + ":" + FTP_PASS + "@localhost:" + 
ftpPort + "/greeting.txt";
+    String url =
+        "ftp://"; + FTP_USER + ":" + FTP_PASS + "@" + LOCALHOST + ":" + ftpPort 
+ "/greeting.txt";
     FileSystemOptions opts = new FileSystemOptions();
     FtpFileSystemConfigBuilder.getInstance().setPassiveMode(opts, true);
     assertEquals("ftp-payload", readWithOptions(url, opts));
@@ -179,7 +194,8 @@ class HopVfsNetworkProvidersTest {
   @Test
   @DisplayName("ftps:// fetches a payload over TLS from an embedded Apache 
FtpServer")
   void ftpsProviderReadsFromEmbeddedServer() throws Exception {
-    String url = "ftps://" + FTP_USER + ":" + FTP_PASS + "@localhost:" + 
ftpsPort + "/greeting.txt";
+    String url =
+        "ftps://" + FTP_USER + ":" + FTP_PASS + "@" + LOCALHOST + ":" + 
ftpsPort + "/greeting.txt";
     FileSystemOptions opts = new FileSystemOptions();
     FtpsFileSystemConfigBuilder ftps = 
FtpsFileSystemConfigBuilder.getInstance();
     ftps.setPassiveMode(opts, true);
@@ -191,7 +207,15 @@ class HopVfsNetworkProvidersTest {
   @DisplayName("sftp:// fetches a payload from an embedded Apache MINA SSHD 
server")
   void sftpProviderReadsFromEmbeddedServer() throws Exception {
     String url =
-        "sftp://"; + SFTP_USER + ":" + SFTP_PASS + "@localhost:" + sftpPort + 
"/greeting.txt";
+        "sftp://";
+            + SFTP_USER
+            + ":"
+            + SFTP_PASS
+            + "@"
+            + LOCALHOST
+            + ":"
+            + sftpPort
+            + "/greeting.txt";
     assertEquals("sftp-payload", readToString(url));
   }
 
@@ -238,7 +262,7 @@ class HopVfsNetworkProvidersTest {
                 "-dname",
                 "CN=localhost, OU=Hop, O=Apache, L=Test, S=Test, C=US",
                 "-ext",
-                "SAN=DNS:localhost,IP:127.0.0.1",
+                "SAN=DNS:localhost,IP:" + LOCALHOST,
                 "-noprompt")
             .redirectErrorStream(true)
             .start();
@@ -302,7 +326,7 @@ class HopVfsNetworkProvidersTest {
 
   private static SshServer startSftp(Path home) throws IOException {
     SshServer sshd = SshServer.setUpDefaultServer();
-    sshd.setHost("localhost");
+    sshd.setHost(LOCALHOST);
     sshd.setPort(0);
     sshd.setKeyPairProvider(new 
SimpleGeneratorHostKeyProvider(sharedRoot.resolve("hostkey.ser")));
     sshd.setPasswordAuthenticator(AcceptAllPasswordAuthenticator.INSTANCE);
diff --git a/plugins/transforms/janino/pom.xml 
b/plugins/transforms/janino/pom.xml
index f1a5ca59d9..7c38d76d9e 100644
--- a/plugins/transforms/janino/pom.xml
+++ b/plugins/transforms/janino/pom.xml
@@ -39,6 +39,11 @@
             <artifactId>hop-transform-rowgenerator</artifactId>
             <version>${project.version}</version>
         </dependency>
+        <dependency>
+            <groupId>org.apache.xbean</groupId>
+            <artifactId>xbean-finder-shaded</artifactId>
+            <version>4.30</version>
+        </dependency>
         <dependency>
             <groupId>org.codehaus.janino</groupId>
             <artifactId>janino</artifactId>
diff --git 
a/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/function/FunctionLib.java
 
b/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/function/FunctionLib.java
index 37e4072725..a2fd8ae861 100644
--- 
a/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/function/FunctionLib.java
+++ 
b/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/function/FunctionLib.java
@@ -31,13 +31,14 @@ import java.util.Set;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.plugins.HopURLClassLoader;
 import org.apache.hop.core.plugins.IPlugin;
 import org.apache.hop.core.plugins.PluginRegistry;
 import org.apache.hop.core.plugins.TransformPluginType;
 import org.apache.hop.core.util.Utils;
+import org.apache.hop.pipeline.transforms.janino.scanner.ClassLoaderScanner;
 
 public class FunctionLib {
-
   private List<FunctionDescription> functions;
 
   public FunctionLib() throws HopException {
@@ -46,49 +47,56 @@ public class FunctionLib {
       PluginRegistry registry = PluginRegistry.getInstance();
       IPlugin plugin = registry.getPlugin(TransformPluginType.class, "Janino");
       ClassLoader loader = registry.getClassLoader(plugin);
-      Set<Class<?>> classes =
-          findAllClassesUsingGoogleGuice(loader, 
"org.apache.hop.pipeline.transforms.janino");
-
-      for (Class<?> clazz : classes) {
-        Method[] methods = clazz.getMethods();
-        for (Method method : methods) {
-          JaninoFunction annotation = 
method.getAnnotation(JaninoFunction.class);
-          List<FunctionExample> functionExamples = new ArrayList<>();
-          if (annotation != null) {
-            if (!Utils.isEmpty(annotation.examples())) {
-              ObjectMapper mapper = new ObjectMapper();
-              JsonNode arrayNode = mapper.readTree(annotation.examples());
-              for (JsonNode jsonNode : arrayNode) {
-                functionExamples.add(
-                    new FunctionExample(
-                        jsonNode.get("expression").asText(),
-                        jsonNode.get("result").asText(),
-                        jsonNode.get("level").asText(),
-                        jsonNode.get("comment").asText()));
-              }
-            }
-
-            FunctionDescription functionDescription =
-                new FunctionDescription(
-                    annotation.category(),
-                    annotation.name(),
-                    annotation.description(),
-                    annotation.syntax(),
-                    annotation.returns(),
-                    null,
-                    annotation.semantics(),
-                    clazz.getCanonicalName(),
-                    functionExamples);
-            functions.add(functionDescription);
-          }
+
+      if (loader instanceof HopURLClassLoader hucl) {
+        var cached = hucl.get(CachedFunctions.class);
+        if (cached != null) {
+          functions.addAll(cached.functions());
+          return;
         }
       }
 
+      doScan(loader);
     } catch (Exception e) {
       throw new HopException(e);
     }
   }
 
+  private void doScan(ClassLoader loader) throws IOException {
+    for (Method method :
+        new ClassLoaderScanner()
+            .findMethodsWithAnnotationInPackage(
+                loader, "org.apache.hop.pipeline.transforms.janino", 
JaninoFunction.class)) {
+      JaninoFunction annotation = method.getAnnotation(JaninoFunction.class);
+      List<FunctionExample> functionExamples = new ArrayList<>();
+      if (!Utils.isEmpty(annotation.examples())) {
+        ObjectMapper mapper = new ObjectMapper();
+        JsonNode arrayNode = mapper.readTree(annotation.examples());
+        for (JsonNode jsonNode : arrayNode) {
+          functionExamples.add(
+              new FunctionExample(
+                  jsonNode.get("expression").asText(),
+                  jsonNode.get("result").asText(),
+                  jsonNode.get("level").asText(),
+                  jsonNode.get("comment").asText()));
+        }
+      }
+
+      FunctionDescription functionDescription =
+          new FunctionDescription(
+              annotation.category(),
+              annotation.name(),
+              annotation.description(),
+              annotation.syntax(),
+              annotation.returns(),
+              null,
+              annotation.semantics(),
+              method.getDeclaringClass().getCanonicalName(),
+              functionExamples);
+      functions.add(functionDescription);
+    }
+  }
+
   /**
    * @return the functions
    */
@@ -172,6 +180,7 @@ public class FunctionLib {
     return null;
   }
 
+  @Deprecated // shouldn't be public, kept for legacy and external usage
   public Set<Class<?>> findAllClassesUsingGoogleGuice(ClassLoader classLoader, 
String packageName)
       throws IOException {
     return ClassPath.from(classLoader).getAllClasses().stream()
@@ -188,4 +197,6 @@ public class FunctionLib {
             })
         .collect(Collectors.toSet());
   }
+
+  private record CachedFunctions(List<FunctionDescription> functions) {}
 }
diff --git 
a/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/scanner/ClassLoaderScanner.java
 
b/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/scanner/ClassLoaderScanner.java
new file mode 100644
index 0000000000..5f41550f68
--- /dev/null
+++ 
b/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/scanner/ClassLoaderScanner.java
@@ -0,0 +1,143 @@
+/*
+ * 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.pipeline.transforms.janino.scanner;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+import java.net.JarURLConnection;
+import java.net.URL;
+import java.util.Collection;
+import java.util.function.Predicate;
+import java.util.jar.JarFile;
+import java.util.stream.Stream;
+import org.apache.xbean.finder.AnnotationFinder;
+import org.apache.xbean.finder.ClassLoaders;
+import org.apache.xbean.finder.archive.Archive;
+import org.apache.xbean.finder.archive.CompositeArchive;
+import org.apache.xbean.finder.archive.FileArchive;
+import org.apache.xbean.finder.archive.FilteredArchive;
+import org.apache.xbean.finder.archive.JarArchive;
+import org.apache.xbean.finder.filter.Filters;
+
+// note: for now manifest.mf classpath are not handled, todo: review if needed 
(unlikely normally)
+public final class ClassLoaderScanner {
+  public Collection<Method> findMethodsWithAnnotationInPackage(
+      ClassLoader classLoader, String packageName, Class<? extends Annotation> 
annotation)
+      throws IOException {
+    final var urls = ClassLoaders.findUrls(classLoader);
+    // we cache the result of the scanning so no need to cache the exclusions 
- should save mem
+    final var jarFilter = new 
JarExclusionsLoader().load("ClassLoaderScanner.ignored-jars.txt");
+
+    // next line would work but doesn't cut fast enough the jar selection
+    // and requires a refiltering and iterating over all entries for a poor - 
almost always 0 - gain
+    // final var archives = ClasspathArchive.archives(classLoader, urls);
+    final var archives =
+        urls.stream()
+            .flatMap(
+                it ->
+                    switch (it.getProtocol()) {
+                      case "jar" -> {
+                        final var archive = new JarArchive(classLoader, it);
+                        yield !jarHasPackage(archive.getUrl(), jarFilter, 
packageName)
+                            ? Stream.<Archive>empty()
+                            : Stream.of(archive);
+                      }
+                      case "file" -> {
+                        try { // validate it is a jar else fallback on plain 
directory
+                          final var jarUrl = new URL("jar", "", 
it.toExternalForm() + "!/");
+                          final var juc = (JarURLConnection) 
jarUrl.openConnection();
+                          juc.getJarFile();
+                          final var archive = new JarArchive(classLoader, 
jarUrl);
+                          yield !jarHasPackage(archive.getUrl(), jarFilter, 
packageName)
+                              ? Stream.empty()
+                              : Stream.of(archive);
+                        } catch (final IOException e) {
+                          final var archive = new FileArchive(classLoader, it);
+                          yield !dirHasPackage(archive.getDir(), packageName)
+                              ? Stream.empty()
+                              : Stream.of(archive);
+                        }
+                      }
+                      default -> Stream.empty();
+                    })
+            .toList();
+
+    final var aggregated =
+        new FilteredArchive(
+            archives.size() == 1 ? archives.getFirst() : new 
CompositeArchive(archives),
+            Filters.packages(packageName));
+    try {
+      // todo: review if we try to check if META-INF/jandex.idx exists in the 
jar/dir
+      //       and use it instead of doing a bytecode scanning with asm
+      //       -> requires dev to use jandex but can be more consistent with 
plugins
+      //          -> likely better: drop all that and do scanning at build 
time with ServiceLoader
+      // registration using a maven plugin or CLI to generate the right 
metadata
+      return new AnnotationFinder(aggregated, 
true).findAnnotatedMethods(annotation);
+    } finally {
+      if (aggregated instanceof AutoCloseable a) { // Composite/Jar archives
+        try {
+          a.close();
+        } catch (Exception e) {
+          // no-op, ignored
+        }
+      }
+    }
+  }
+
+  private boolean jarHasPackage(URL location, Predicate<String> jarFilter, 
String packageName) {
+    File jarFile = null;
+    var idx = 0;
+    try {
+      var jarPath =
+          FileArchive.decode(
+              "jar".equalsIgnoreCase(location.getProtocol())
+                  ? new URL(
+                          location.getPath().endsWith("!/")
+                              ? location
+                                  .getPath()
+                                  .substring(0, 
location.getPath().lastIndexOf("!/"))
+                              : location.getPath())
+                      .getPath()
+                  : location.getPath());
+      for (var jp = jarPath;
+          !(jarFile = new File(jp)).exists() && (idx = jarPath.indexOf("!/", 
idx + 1)) > 0;
+          jp = jarPath.substring(0, idx)) {}
+      try (final var jar = new JarFile(jarFile)) {
+        // todo: enhance with mjar support but so unlikely that we don't care 
for now
+        final var hasPck = jar.getEntry(packageName.replace('.', '/') + '/') 
!= null;
+        if (!hasPck) {
+          return false;
+        }
+
+        // if excluded with our default rules try to match an explicit marker
+        // this is a marker file enabling to force the jar scan even if 
excluded by default
+        if (!jarFilter.test(jarFile.getName())) {
+          return jar.getEntry("META-INF/org.apache.hop.janino") != null;
+        }
+        return true;
+      }
+    } catch (IOException e) {
+      return false;
+    }
+  }
+
+  private boolean dirHasPackage(File location, String packageName) {
+    return new File(location, packageName.replace('.', '/')).exists();
+  }
+}
diff --git 
a/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/scanner/JarExclusionsLoader.java
 
b/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/scanner/JarExclusionsLoader.java
new file mode 100644
index 0000000000..092449e7ff
--- /dev/null
+++ 
b/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/scanner/JarExclusionsLoader.java
@@ -0,0 +1,74 @@
+/*
+ * 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.pipeline.transforms.janino.scanner;
+
+import static java.util.Optional.ofNullable;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.function.Predicate;
+import org.apache.xbean.finder.filter.Filters;
+
+public class JarExclusionsLoader {
+  public Predicate<String> load(String resourcePath) {
+    try (var is =
+        ofNullable(Thread.currentThread().getContextClassLoader())
+            .orElseGet(ClassLoader::getSystemClassLoader)
+            .getResourceAsStream(resourcePath)) {
+      if (is == null) {
+        throw new IllegalArgumentException("Resource not found: " + 
resourcePath);
+      }
+      return load(is);
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+  }
+
+  public Predicate<String> load(InputStream inputStream) {
+    final var ignoredPrefixes = new ArrayList<String>();
+    final var notIgnoredPrefixes = new ArrayList<String>();
+    try (BufferedReader reader =
+        new BufferedReader(new InputStreamReader(inputStream, 
StandardCharsets.UTF_8))) {
+      String line;
+      while ((line = reader.readLine()) != null) {
+        line = line.trim();
+        if (line.isEmpty() || line.startsWith("#")) {
+          continue;
+        }
+        if (line.startsWith("!")) {
+          notIgnoredPrefixes.add(line.substring(1));
+        } else {
+          ignoredPrefixes.add(line);
+        }
+      }
+      if (notIgnoredPrefixes.isEmpty()) {
+        final var onlyIgnored = Filters.prefixes(ignoredPrefixes.toArray(new 
String[0]));
+        return p -> !onlyIgnored.accept(p);
+      }
+      final var ignored = Filters.prefixes(ignoredPrefixes.toArray(new 
String[0]));
+      final var notIgnored = Filters.prefixes(notIgnoredPrefixes.toArray(new 
String[0]));
+      return p -> notIgnored.accept(p) || !ignored.accept(p);
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+  }
+}
diff --git 
a/plugins/transforms/janino/src/main/resources/ClassLoaderScanner.ignored-jars.txt
 
b/plugins/transforms/janino/src/main/resources/ClassLoaderScanner.ignored-jars.txt
new file mode 100644
index 0000000000..b232aaf491
--- /dev/null
+++ 
b/plugins/transforms/janino/src/main/resources/ClassLoaderScanner.ignored-jars.txt
@@ -0,0 +1,343 @@
+# 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.
+
+accessors-smart
+adal4j
+adapter-rxjava
+aircompressor
+airline
+amazon-kinesis-client
+angus-
+animal-sniffer-annotations
+annotations
+antlr
+antlr4-
+aopalliance
+apache-client
+apache-mime4j-
+api-
+apiguardian-
+apicurio-
+Apple
+args4j
+arns
+arrow-
+asm
+async-
+auth
+auto-common
+automaton
+auto-
+avro
+aws-
+azure-
+batik-
+bcjmail-
+bcprov-
+beam-
+bson
+byte-buddy
+caffeine
+cassandra-all
+checker-qual
+chill_2.12
+chill-java
+chronicle-
+classgraph
+clickhouse-
+client
+cloudwatch
+common
+com.
+concurrent-
+config
+conscrypt-openjdk-uber
+content-type
+converter-
+crate-
+crt-
+curvesapi
+dd-
+dec
+derby
+detector-
+dnsjava
+dom4j
+drools-
+dropbox-
+duckdb
+dynamodb
+ecj
+ejml-
+encoder
+endpoints-
+error_
+eventstream
+exporter-
+failureaccess
+fastutil
+flatbuffers-java
+flight-
+flink-
+flogger
+flogger
+fontbox
+force-
+gapic-
+gax
+gcsio
+generex
+google-
+graal-
+groovy
+grpc-
+gson
+guava
+guice
+h2
+hadoop-
+hamcrest
+HdrHistogram
+high-
+hive-
+hk2-
+hop-action-
+hop-assemblies-
+hop-core
+hop-databases-
+hop-engine
+hop-misc-
+hop-resolvers-
+hop-tech-
+hop-transform-
+!hop-transform-janino
+hop-ui
+hop-valuetypes-
+hppc
+hsqldb
+httpclient
+http-client-
+httpcore
+icu4j
+ini4j
+ipaddress
+istack-
+j2objc-
+jackcess
+jackcess
+jackrabbit-
+jackson-
+jai-imageio-core
+jakarta.
+jamm
+jandex
+janino
+java-
+JavaEWAH
+javafaker
+java-libpst
+javaparser-core
+javapoet
+javassist
+javax.
+jaxb-
+jaxen
+jbcrypt
+jbig2-imageio
+jcip-annotations
+jcl-over-slf4j
+jcodings
+jcommander
+jctools-core
+jdbc-v2
+jdom2
+jediterm-core
+jediterm-ui
+jempbox
+jersey-
+jettison
+jetty-
+jffi
+jhighlight
+jline
+jmatio
+jna
+jna-
+joda-time
+jollyday
+joni
+jsch
+jsendnsca
+json
+json4s-
+json-
+jsoup
+jsp-api
+jspecify
+jsr305
+js-
+jt400
+jtokkit
+jts-core
+jul-
+junit-
+juniversalchardet
+junrar
+jvm-
+jwarc
+jython-
+kafka-
+kaml
+kerb-
+kie-
+kinesis
+kotlinpoet
+kotlinpoet-
+kotlin-
+kotlinx-
+kryo
+langchain4j-
+lang-tag
+libphonenumber
+lingua
+listenablefuture
+log4j-
+logging-
+logredactor
+lombok
+lz4-
+managed-kafka-auth-login-handler
+mariadb-java-client
+metadata-extractor
+metrics-
+minimal-json
+minio
+minlog
+mockito-
+monetdb-
+moshi
+msal4j
+mssql-
+mvel2
+mxdump
+mysql-
+native-protocol
+neo4j-java-driver
+netty-
+nimbus-jose-jwt
+oauth2-oidc-sdk
+objenesis-
+odfdom-java
+ohc-
+okhttp
+okio
+openai4j
+opencensus-
+opencsv
+opentelemetry-
+opentest4j-
+org.eclipse.
+org.osgi.
+osgi.
+osgi-
+paranamer
+parquet-
+parso
+pdfbox
+pdfbox-
+perfmark-api
+picocli
+poi
+postgresql
+profiles
+protobuf-
+proto-google-
+proton-j
+psjava
+pty4j
+py4j
+qpid-proton-
+re2j
+reactive-streams
+reactor-
+redshift-jdbc42
+regions
+reload4j
+reporter-
+retrofit
+rhino-all
+RoaringBitmap
+rome
+rxjava
+s2a-
+s3
+saxon
+scala-
+sdk-
+serializer
+shared-resourcemapping
+sigar
+simple-xml-safe
+sjk-
+slf4j-
+snakeyaml
+snappy-java
+snmp4j
+snowball-
+snowflake-
+sns
+spark-
+SparseBitSet
+splunk
+spotbugs-annotations
+sqlite-jdbc
+sqs
+sshd-
+ST4
+stanford-corenlp
+stax2-api
+stream
+sts
+swagger-
+swiftpoet
+third-party-jackson-
+threetenbp
+threeten-extra
+tika-
+tinylog-
+txw2
+ucanaccess
+util
+utils
+vault-java-driver
+vertica-jdbc
+vorbis-java-
+waffle-jna
+webservices-api
+websocket-
+wire-
+woodstox-core
+wsdl4j
+xalan
+xbean-
+xercesImpl
+xml-
+xmlbeans
+xmlgraphics-commons
+xmpbox
+xmpcore
+xom
+xz
+zstd-jni
\ No newline at end of file
diff --git 
a/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/janino/scanner/ClassLoaderScannerTest.java
 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/janino/scanner/ClassLoaderScannerTest.java
new file mode 100644
index 0000000000..926b195fcb
--- /dev/null
+++ 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/janino/scanner/ClassLoaderScannerTest.java
@@ -0,0 +1,112 @@
+/*
+ * 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.pipeline.transforms.janino.scanner;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Method;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.Path;
+import java.util.Collection;
+import org.apache.hop.pipeline.transforms.janino.function.JaninoFunction;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class ClassLoaderScannerTest {
+  @Test
+  void findsAnnotatedMethodsInExplodedDirectory(@TempDir Path tempDir) throws 
Exception {
+    Path classesDir = tempDir.resolve("classes");
+    FunctionGenerator.writeToDirectory(classesDir);
+
+    try (URLClassLoader cl =
+        new URLClassLoader(
+            new URL[] {classesDir.toUri().toURL()},
+            Thread.currentThread().getContextClassLoader())) {
+      ClassLoaderScanner scanner = new ClassLoaderScanner();
+      Collection<Method> methods =
+          scanner.findMethodsWithAnnotationInPackage(
+              cl, "org.apache.hop.pipeline.transforms.janino.test", 
JaninoFunction.class);
+
+      assertFalse(methods.isEmpty(), "Should find @JaninoFunction methods in 
exploded directory");
+      assertTrue(
+          methods.stream().anyMatch(m -> "nvl".equals(m.getName())), "Should 
include nvl method");
+    }
+  }
+
+  @Test
+  void findsAnnotatedMethodsInJar(@TempDir Path tempDir) throws Exception {
+    Path jarFile = tempDir.resolve("test-functions.jar");
+    FunctionGenerator.writeToJar(jarFile);
+
+    try (URLClassLoader cl =
+        new URLClassLoader(
+            new URL[] {jarFile.toUri().toURL()}, 
Thread.currentThread().getContextClassLoader())) {
+      ClassLoaderScanner scanner = new ClassLoaderScanner();
+      Collection<Method> methods =
+          scanner.findMethodsWithAnnotationInPackage(
+              cl, "org.apache.hop.pipeline.transforms.janino.test", 
JaninoFunction.class);
+
+      assertFalse(methods.isEmpty(), "Should find @JaninoFunction methods in 
jar");
+      assertTrue(
+          methods.stream().anyMatch(m -> "nvl".equals(m.getName())), "Should 
include nvl method");
+    }
+  }
+
+  @Test
+  void returnsEmptyForPackageWithNoMatchingAnnotation(@TempDir Path tempDir) 
throws Exception {
+    Path classesDir = tempDir.resolve("empty");
+    FunctionGenerator.writeToDirectory(classesDir);
+
+    try (URLClassLoader cl =
+        new URLClassLoader(
+            new URL[] {classesDir.toUri().toURL()},
+            Thread.currentThread().getContextClassLoader())) {
+      ClassLoaderScanner scanner = new ClassLoaderScanner();
+      Collection<Method> methods =
+          scanner.findMethodsWithAnnotationInPackage(
+              cl, "org.apache.hop.pipeline.transforms.janino.scanner", 
JaninoFunction.class);
+
+      assertTrue(methods.isEmpty(), "Should find no @JaninoFunction when 
scanning wrong package");
+    }
+  }
+
+  @Test
+  void everyReturnedMethodCarriesTheAnnotation(@TempDir Path tempDir) throws 
Exception {
+    Path classesDir = tempDir.resolve("annotated");
+    FunctionGenerator.writeToDirectory(classesDir);
+
+    try (URLClassLoader cl =
+        new URLClassLoader(
+            new URL[] {classesDir.toUri().toURL()},
+            Thread.currentThread().getContextClassLoader())) {
+      ClassLoaderScanner scanner = new ClassLoaderScanner();
+      Collection<Method> methods =
+          scanner.findMethodsWithAnnotationInPackage(
+              cl, "org.apache.hop.pipeline.transforms.janino.test", 
JaninoFunction.class);
+
+      assertFalse(methods.isEmpty());
+      for (Method m : methods) {
+        assertNotNull(
+            m.getAnnotation(JaninoFunction.class),
+            "Method " + m + " must be annotated with @JaninoFunction");
+      }
+    }
+  }
+}
diff --git 
a/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/janino/scanner/FunctionGenerator.java
 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/janino/scanner/FunctionGenerator.java
new file mode 100644
index 0000000000..c53c3a8b7c
--- /dev/null
+++ 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/janino/scanner/FunctionGenerator.java
@@ -0,0 +1,109 @@
+/*
+ * 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.pipeline.transforms.janino.scanner;
+
+import static org.apache.xbean.asm9.Opcodes.ACC_PUBLIC;
+import static org.apache.xbean.asm9.Opcodes.ACC_STATIC;
+import static org.apache.xbean.asm9.Opcodes.ACC_SUPER;
+import static org.apache.xbean.asm9.Opcodes.ALOAD;
+import static org.apache.xbean.asm9.Opcodes.ARETURN;
+import static org.apache.xbean.asm9.Opcodes.IFNONNULL;
+import static org.apache.xbean.asm9.Opcodes.V20;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+import org.apache.xbean.asm9.AnnotationVisitor;
+import org.apache.xbean.asm9.ClassWriter;
+import org.apache.xbean.asm9.Label;
+import org.apache.xbean.asm9.MethodVisitor;
+
+class FunctionGenerator {
+
+  private static final String INTERNAL_CLASS_NAME =
+      "org/apache/hop/pipeline/transforms/janino/test/TestFunctions";
+
+  private static final String JANINO_FUNCTION_DESC =
+      "Lorg/apache/hop/pipeline/transforms/janino/function/JaninoFunction;";
+
+  static byte[] generateClass() {
+    ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
+    cw.visit(V20, ACC_PUBLIC | ACC_SUPER, INTERNAL_CLASS_NAME, null, 
"java/lang/Object", null);
+
+    generateNvlMethod(cw);
+
+    cw.visitEnd();
+    return cw.toByteArray();
+  }
+
+  private static void generateNvlMethod(ClassWriter cw) {
+    MethodVisitor mv =
+        cw.visitMethod(
+            ACC_PUBLIC | ACC_STATIC,
+            "nvl",
+            "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;",
+            null,
+            null);
+
+    AnnotationVisitor av = mv.visitAnnotation(JANINO_FUNCTION_DESC, true);
+    av.visit("name", "nvl");
+    av.visit("category", "General");
+    av.visit("description", "Implements Oracle style NVL function.");
+    av.visit("syntax", "nvl(source, def)");
+    av.visit("returns", "String");
+    av.visit("semantics", "If source == null or empty return def");
+    av.visit(
+        "examples",
+        
"[{\"expression\":\"nvl(null,\\\"bar\\\")\",\"result\":\"bar\",\"level\":\"1\",\"comment\":\"null
 returns bar\"}]");
+    av.visitEnd();
+
+    Label notNull = new Label();
+    mv.visitCode();
+    mv.visitVarInsn(ALOAD, 0);
+    mv.visitJumpInsn(IFNONNULL, notNull);
+    mv.visitVarInsn(ALOAD, 1);
+    mv.visitInsn(ARETURN);
+    mv.visitLabel(notNull);
+    mv.visitVarInsn(ALOAD, 0);
+    mv.visitInsn(ARETURN);
+    mv.visitMaxs(0, 0);
+    mv.visitEnd();
+  }
+
+  static void writeToDirectory(Path dir) throws IOException {
+    Path classFile = dir.resolve(INTERNAL_CLASS_NAME + ".class");
+    Files.createDirectories(classFile.getParent());
+    Files.write(classFile, generateClass());
+  }
+
+  static void writeToJar(Path jarFile) throws IOException {
+    try (JarOutputStream jos = new 
JarOutputStream(Files.newOutputStream(jarFile))) {
+      String[] parts = INTERNAL_CLASS_NAME.split("/");
+      String prefix = "";
+      for (int i = 0; i < parts.length - 1; i++) {
+        prefix += parts[i] + "/";
+        jos.putNextEntry(new JarEntry(prefix));
+        jos.closeEntry();
+      }
+      jos.putNextEntry(new JarEntry(INTERNAL_CLASS_NAME + ".class"));
+      jos.write(generateClass());
+      jos.closeEntry();
+    }
+  }
+}
diff --git 
a/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/janino/scanner/FunctionLibTest.java
 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/janino/scanner/FunctionLibTest.java
new file mode 100644
index 0000000000..a87ef713b4
--- /dev/null
+++ 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/janino/scanner/FunctionLibTest.java
@@ -0,0 +1,76 @@
+/*
+ * 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.pipeline.transforms.janino.scanner;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.Path;
+import java.util.List;
+import org.apache.hop.core.plugins.IPlugin;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.plugins.TransformPluginType;
+import org.apache.hop.pipeline.transforms.janino.function.FunctionDescription;
+import org.apache.hop.pipeline.transforms.janino.function.FunctionLib;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.MockedStatic;
+
+class FunctionLibTest {
+  // note: using mockito since current IoC design is not very friendly to tests
+  //       with the static singleton pattern
+  @Test
+  void scansAndPopulatesFunctionsFromClasspath(@TempDir Path tempDir) throws 
Exception {
+    Path classesDir = tempDir.resolve("classes");
+    FunctionGenerator.writeToDirectory(classesDir);
+
+    try (URLClassLoader scanCl =
+        new URLClassLoader(
+            new URL[] {classesDir.toUri().toURL()}, 
FunctionLibTest.class.getClassLoader())) {
+      IPlugin plugin = mock(IPlugin.class);
+      PluginRegistry registry = mock(PluginRegistry.class);
+      when(registry.getPlugin(TransformPluginType.class, 
"Janino")).thenReturn(plugin);
+      when(registry.getClassLoader(plugin)).thenReturn(scanCl);
+
+      try (MockedStatic<PluginRegistry> mocked = 
mockStatic(PluginRegistry.class)) {
+        mocked.when(PluginRegistry::getInstance).thenReturn(registry);
+
+        FunctionLib lib = new FunctionLib();
+        List<FunctionDescription> functions = lib.getFunctions();
+
+        assertFalse(functions.isEmpty(), "Should find at least one 
@JaninoFunction method");
+        assertTrue(
+            functions.stream().anyMatch(f -> "nvl".equals(f.getName())),
+            "Should include the nvl method");
+
+        FunctionDescription nvl =
+            functions.stream().filter(f -> 
"nvl".equals(f.getName())).findFirst().get();
+        assertEquals("General", nvl.getCategory());
+        assertEquals("String", nvl.getReturns());
+        assertNotNull(nvl.getFunctionExamples());
+        assertFalse(nvl.getFunctionExamples().isEmpty());
+      }
+    }
+  }
+}
diff --git 
a/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/janino/scanner/JarExclusionsLoaderTest.java
 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/janino/scanner/JarExclusionsLoaderTest.java
new file mode 100644
index 0000000000..3abb61bd10
--- /dev/null
+++ 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/janino/scanner/JarExclusionsLoaderTest.java
@@ -0,0 +1,78 @@
+/*
+ * 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.pipeline.transforms.janino.scanner;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.function.Predicate;
+import org.junit.jupiter.api.Test;
+
+class JarExclusionsLoaderTest {
+  @Test
+  void loadsFromResourceFile() {
+    var factory = new JarExclusionsLoader();
+    Predicate<String> keep = 
factory.load("ClassLoaderScanner.ignored-jars.txt");
+    assertFalse(keep.test("asm-9.8.jar"));
+    assertFalse(keep.test("hop-transform-rowgenerator-2.19.0.jar"));
+    assertTrue(keep.test("hop-transform-janino-2.19.0.jar"));
+  }
+
+  @Test
+  void excludesKnownPrefix() {
+    var keep = exclusions("asm");
+    assertFalse(keep.test("asm-9.8.jar"));
+    assertFalse(keep.test("asm"));
+    assertTrue(keep.test("jackson-core-2.21.1.jar"));
+  }
+
+  @Test
+  void notIgnoredPrefixTakesPrecedence() {
+    var keep = exclusions("hop-transform-\n!hop-transform-janino");
+    assertFalse(keep.test("hop-transform-rowgenerator-2.19.0.jar"));
+    assertTrue(keep.test("hop-transform-janino-2.19.0.jar"));
+  }
+
+  @Test
+  void emptyContentKeepsEverything() {
+    var keep = exclusions("");
+    assertTrue(keep.test("anything.jar"));
+  }
+
+  @Test
+  void skipsBlankLines() {
+    var keep = exclusions("\n\nasm\n\n");
+    assertFalse(keep.test("asm-9.8.jar"));
+    assertTrue(keep.test("jackson-core.jar"));
+  }
+
+  @Test
+  void multipleIgnoresAndNotIgnores() {
+    var keep = exclusions("jackson-\n!jackson-databind\nasm\n!asm-analysis");
+    assertFalse(keep.test("jackson-core-2.21.1.jar"));
+    assertTrue(keep.test("jackson-databind-2.21.1.jar"));
+    assertFalse(keep.test("asm-9.8.jar"));
+    assertTrue(keep.test("asm-analysis-9.8.jar"));
+  }
+
+  private Predicate<String> exclusions(String content) {
+    return new JarExclusionsLoader()
+        .load(new 
ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)));
+  }
+}


Reply via email to