You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ja...@apache.org on 2026/07/17 18:39:37 UTC

(grails-core) branch feat/artefact-index-seed created (now ccc1a3c86a)

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

jamesfredley pushed a change to branch feat/artefact-index-seed
in repository https://gitbox.apache.org/repos/asf/grails-core.git


      at ccc1a3c86a Add optional artefact index reader for startup scanning

This branch includes the following new commits:

     new ccc1a3c86a Add optional artefact index reader for startup scanning

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(grails-core) 01/01: Add optional artefact index reader for startup scanning

Posted by ja...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

jamesfredley pushed a commit to branch feat/artefact-index-seed
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit ccc1a3c86abf8b66d3b94b7bc344a07a2d43187c
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Fri Jul 17 14:38:55 2026 -0400

    Add optional artefact index reader for startup scanning
    
    Assisted-by: opencode:gpt-5.6-sol
---
 .../boot/config/ApplicationArtefactScanner.groovy  |  10 +-
 .../grails/boot/config/ArtefactIndexReader.java    |  83 ++++++
 .../config/ApplicationArtefactScannerSpec.groovy   | 316 +++++++++++++++++++++
 .../boot/config/excluded/ExcludedArtefact.groovy   |  25 ++
 .../boot/config/indexed/IncludedArtefact.groovy    |  25 ++
 5 files changed, 457 insertions(+), 2 deletions(-)

diff --git a/grails-core/src/main/groovy/grails/boot/config/ApplicationArtefactScanner.groovy b/grails-core/src/main/groovy/grails/boot/config/ApplicationArtefactScanner.groovy
index a69cfef692..c43483e2f4 100644
--- a/grails-core/src/main/groovy/grails/boot/config/ApplicationArtefactScanner.groovy
+++ b/grails-core/src/main/groovy/grails/boot/config/ApplicationArtefactScanner.groovy
@@ -60,8 +60,14 @@ final class ApplicationArtefactScanner {
      * @return The classes that constitute the Grails application
      */
     static Collection<Class> scanApplicationClasses(Class<?> applicationClass, Collection<String> packageNames) {
-        Collection<Class> classes = new HashSet<>()
-        classes.addAll(new ClassPathScanner().scan(applicationClass, packageNames))
+        Collection<Class> classes = new LinkedHashSet<>()
+        Collection<Class> indexedClasses = ArtefactIndexReader.read(applicationClass, packageNames)
+        if (indexedClasses == null) {
+            classes.addAll(new ClassPathScanner().scan(applicationClass, packageNames))
+        }
+        else {
+            classes.addAll(indexedClasses)
+        }
         classes.addAll(loadTransformedClasses(applicationClass.classLoader))
         return classes
     }
diff --git a/grails-core/src/main/groovy/grails/boot/config/ArtefactIndexReader.java b/grails-core/src/main/groovy/grails/boot/config/ArtefactIndexReader.java
new file mode 100644
index 0000000000..ba15333eac
--- /dev/null
+++ b/grails-core/src/main/groovy/grails/boot/config/ArtefactIndexReader.java
@@ -0,0 +1,83 @@
+/*
+ *  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
+ *
+ *    https://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 grails.boot.config;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+import grails.io.IOUtils;
+
+/**
+ * Reads the optional application artefact index.
+ *
+ * <p>The index is UTF-8 text at {@value #RESOURCE_NAME}, with one fully qualified
+ * class name per nonempty line. Any unreadable or unresolvable entry rejects the
+ * complete index so callers can use their normal classpath scan.</p>
+ */
+final class ArtefactIndexReader {
+
+    static final String RESOURCE_NAME = "META-INF/grails/artefacts.idx";
+
+    private ArtefactIndexReader() {
+    }
+
+    static Collection<Class> read(Class<?> applicationClass, Collection<String> packageNames) {
+        try {
+            URL resource = new URL(IOUtils.findRootResource(applicationClass), RESOURCE_NAME);
+            Set<Class> classes = new LinkedHashSet<>();
+            return readResource(resource, applicationClass.getClassLoader(), packageNames, classes) ? classes : null;
+        } catch (IOException ignored) {
+            return null;
+        }
+    }
+
+    private static boolean readResource(URL resource, ClassLoader classLoader, Collection<String> packageNames, Set<Class> classes) {
+        try (InputStream inputStream = resource.openStream();
+             BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
+            String className;
+            while ((className = reader.readLine()) != null) {
+                if (className.isEmpty()) {
+                    return false;
+                }
+                if (isInPackage(className, packageNames)) {
+                    classes.add(classLoader.loadClass(className));
+                }
+            }
+            return true;
+        } catch (IOException | ClassNotFoundException | LinkageError ignored) {
+            return false;
+        }
+    }
+
+    private static boolean isInPackage(String className, Collection<String> packageNames) {
+        for (String packageName : packageNames) {
+            if (packageName != null && (packageName.isEmpty() ? !className.contains(".") : className.startsWith(packageName + "."))) {
+                return true;
+            }
+        }
+        return false;
+    }
+}
diff --git a/grails-core/src/test/groovy/grails/boot/config/ApplicationArtefactScannerSpec.groovy b/grails-core/src/test/groovy/grails/boot/config/ApplicationArtefactScannerSpec.groovy
new file mode 100644
index 0000000000..1c5ebe6d84
--- /dev/null
+++ b/grails-core/src/test/groovy/grails/boot/config/ApplicationArtefactScannerSpec.groovy
@@ -0,0 +1,316 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://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 grails.boot.config
+
+import java.io.InputStream
+import java.lang.annotation.Retention
+import java.lang.annotation.RetentionPolicy
+import java.lang.reflect.Field
+import java.net.URL
+import java.net.URLClassLoader
+
+import grails.boot.config.excluded.ExcludedArtefact
+import grails.boot.config.indexed.IncludedArtefact
+import org.grails.compiler.injection.AbstractGrailsArtefactTransformer
+import spock.lang.Specification
+
+class ApplicationArtefactScannerSpec extends Specification {
+
+    private final List<File> temporaryDirectories = []
+    private final List<URLClassLoader> classLoaders = []
+    private Collection<String> transformedClassNames
+
+    void setup() {
+        transformedClassNames = new ArrayList<>(knownTransformedClassNames())
+    }
+
+    void 'uses indexed artefacts in declaration order'() {
+        given:
+        Class<?> applicationClass = applicationClass('''
+grails.boot.config.IndexedSecondArtefact
+grails.boot.config.IndexedFirstArtefact
+''')
+
+        when:
+        Collection<Class> classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass)
+
+        then:
+        indexedClassNames(classes) == [
+                IndexedSecondArtefact.name,
+                IndexedFirstArtefact.name
+        ]
+    }
+
+    void 'deduplicates indexed artefacts while preserving the first declaration'() {
+        given:
+        Class<?> applicationClass = applicationClass('''
+grails.boot.config.IndexedSecondArtefact
+grails.boot.config.IndexedFirstArtefact
+grails.boot.config.IndexedSecondArtefact
+''')
+
+        when:
+        Collection<Class> classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass)
+
+        then:
+        indexedClassNames(classes) == [
+                IndexedSecondArtefact.name,
+                IndexedFirstArtefact.name
+        ]
+    }
+
+    void 'falls back to classpath scanning when no index is present'() {
+        given:
+        Class<?> applicationClass = applicationClass(null)
+
+        when:
+        Collection<Class> classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass)
+
+        then:
+        classes*.name.contains(FallbackArtefact.name)
+    }
+
+    void 'falls back to classpath scanning when an index is malformed'() {
+        given:
+        Class<?> applicationClass = applicationClass('''
+grails.boot.config.IndexedFirstArtefact
+
+grails.boot.config.IndexedSecondArtefact
+''')
+
+        when:
+        Collection<Class> classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass)
+
+        then:
+        classes*.name.contains(FallbackArtefact.name)
+    }
+
+    void 'falls back to application scanning when only a dependency index is present'() {
+        given:
+        Class<?> applicationClass = applicationClass(null, ForeignArtefact.name)
+
+        when:
+        Collection<Class> classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass)
+
+        then:
+        classes*.name.contains(FallbackArtefact.name)
+        !classes*.name.contains(ForeignArtefact.name)
+    }
+
+    void 'does not include entries from a dependency index'() {
+        given:
+        Class<?> applicationClass = applicationClass(IndexedFirstArtefact.name, ForeignArtefact.name)
+
+        when:
+        Collection<Class> classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass)
+
+        then:
+        classes*.name.contains(IndexedFirstArtefact.name)
+        !classes*.name.contains(ForeignArtefact.name)
+    }
+
+    void 'filters indexed artefacts by package names'() {
+        given:
+        Class<?> applicationClass = applicationClass("""
+${IncludedArtefact.name}
+${ExcludedArtefact.name}
+""")
+
+        when:
+        Collection<Class> classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass, [IncludedArtefact.package.name])
+
+        then:
+        classes*.name.contains(IncludedArtefact.name)
+        !classes*.name.contains(ExcludedArtefact.name)
+    }
+
+    void 'falls back to classpath scanning when an indexed class is missing'() {
+        given:
+        Class<?> applicationClass = applicationClass('grails.boot.config.MissingArtefact')
+
+        when:
+        Collection<Class> classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass)
+
+        then:
+        classes*.name.contains(FallbackArtefact.name)
+    }
+
+    void 'falls back to classpath scanning when an indexed class has a linkage error'() {
+        given:
+        Class<?> applicationClass = applicationClass('grails.boot.config.LinkageErrorArtefact', null, 'grails.boot.config.LinkageErrorArtefact')
+
+        when:
+        Collection<Class> classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass)
+
+        then:
+        classes*.name.contains(FallbackArtefact.name)
+    }
+
+    void 'appends transformed artefacts to indexed artefacts without duplicates'() {
+        given:
+        AbstractGrailsArtefactTransformer.addToTransformedClasses(IndexedFirstArtefact.name)
+        AbstractGrailsArtefactTransformer.addToTransformedClasses(IndexedSecondArtefact.name)
+        Class<?> applicationClass = applicationClass(IndexedFirstArtefact.name)
+
+        when:
+        Collection<Class> classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass)
+
+        then:
+        indexedClassNames(classes) == [
+                IndexedFirstArtefact.name,
+                IndexedSecondArtefact.name
+        ]
+    }
+
+    void cleanup() {
+        classLoaders.each { URLClassLoader classLoader -> classLoader.close() }
+        temporaryDirectories.each { File directory -> directory.deleteDir() }
+        Collection<String> knownTransformedClassNames = knownTransformedClassNames()
+        knownTransformedClassNames.clear()
+        knownTransformedClassNames.addAll(transformedClassNames)
+    }
+
+    private Class<?> applicationClass(String index, String dependencyIndex = null, String linkageErrorClassName = null) {
+        File directory = File.createTempDir()
+        temporaryDirectories << directory
+        [IndexedApplication, IndexedFirstArtefact, IndexedSecondArtefact, FallbackArtefact, ArtefactMarker, IncludedArtefact, ExcludedArtefact].each {
+            Class<?> type -> copyClass(type, directory)
+        }
+        if (index != null) {
+            writeIndex(directory, index)
+        }
+        ClassLoader parent = ApplicationArtefactScannerSpec.classLoader
+        if (dependencyIndex != null) {
+            File dependencyDirectory = File.createTempDir()
+            temporaryDirectories << dependencyDirectory
+            copyClass(ForeignArtefact, dependencyDirectory)
+            writeIndex(dependencyDirectory, dependencyIndex)
+            URLClassLoader dependencyClassLoader = new URLClassLoader([dependencyDirectory.toURI().toURL()] as URL[], parent)
+            classLoaders << dependencyClassLoader
+            parent = dependencyClassLoader
+        }
+        URLClassLoader classLoader = new TestApplicationClassLoader([directory.toURI().toURL()] as URL[], parent, linkageErrorClassName)
+        classLoaders << classLoader
+        return classLoader.loadClass(IndexedApplication.name)
+    }
+
+    private static void writeIndex(File directory, String index) {
+        File indexFile = new File(directory, ArtefactIndexReader.RESOURCE_NAME)
+        indexFile.parentFile.mkdirs()
+        indexFile.text = index.stripIndent().trim()
+    }
+
+    private static Collection<String> knownTransformedClassNames() {
+        Field field = AbstractGrailsArtefactTransformer.getDeclaredField('KNOWN_TRANSFORMED_CLASSES')
+        field.accessible = true
+        (Collection<String>) field.get(null)
+    }
+
+    private static void copyClass(Class<?> type, File directory) {
+        String resourceName = type.name.replace('.', '/') + '.class'
+        InputStream inputStream = type.classLoader.getResourceAsStream(resourceName)
+        try {
+            File classFile = new File(directory, resourceName)
+            classFile.parentFile.mkdirs()
+            classFile.bytes = inputStream.bytes
+        } finally {
+            inputStream.close()
+        }
+    }
+
+    private static List<String> indexedClassNames(Collection<Class> classes) {
+        classes*.name.findAll { String className ->
+            className == IndexedFirstArtefact.name || className == IndexedSecondArtefact.name
+        }
+    }
+}
+
+class IndexedApplication {
+}
+
+class IndexedFirstArtefact {
+}
+
+class IndexedSecondArtefact {
+}
+
+class ForeignArtefact {
+}
+
+@ArtefactMarker
+class FallbackArtefact {
+}
+
+@Retention(RetentionPolicy.RUNTIME)
+@interface ArtefactMarker {
+}
+
+class TestApplicationClassLoader extends URLClassLoader {
+
+    private final String linkageErrorClassName
+
+    TestApplicationClassLoader(URL[] urls, ClassLoader parent, String linkageErrorClassName) {
+        super(urls, parent)
+        this.linkageErrorClassName = linkageErrorClassName
+    }
+
+    @Override
+    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
+        if (name == linkageErrorClassName) {
+            throw new NoClassDefFoundError(name)
+        }
+        if (!isFixtureClass(name)) {
+            return super.loadClass(name, resolve)
+        }
+
+        synchronized (getClassLoadingLock(name)) {
+            Class<?> loadedClass = findLoadedClass(name)
+            if (loadedClass == null) {
+                try {
+                    loadedClass = findClass(name)
+                } catch (ClassNotFoundException ignored) {
+                    return super.loadClass(name, resolve)
+                }
+            }
+            if (resolve) {
+                resolveClass(loadedClass)
+            }
+            return loadedClass
+        }
+    }
+
+    @Override
+    URL getResource(String name) {
+        if (isFixtureClassResource(name)) {
+            URL resource = findResource(name)
+            if (resource != null) {
+                return resource
+            }
+        }
+        return super.getResource(name)
+    }
+
+    private static boolean isFixtureClass(String name) {
+        name.startsWith('grails.boot.config.Indexed') || name.startsWith('grails.boot.config.indexed.') || name.startsWith('grails.boot.config.excluded.') || name == FallbackArtefact.name || name == ArtefactMarker.name
+    }
+
+    private static boolean isFixtureClassResource(String name) {
+        name.endsWith('.class') && isFixtureClass(name.substring(0, name.length() - '.class'.length()).replace('/', '.'))
+    }
+}
diff --git a/grails-core/src/test/groovy/grails/boot/config/excluded/ExcludedArtefact.groovy b/grails-core/src/test/groovy/grails/boot/config/excluded/ExcludedArtefact.groovy
new file mode 100644
index 0000000000..a475a442c5
--- /dev/null
+++ b/grails-core/src/test/groovy/grails/boot/config/excluded/ExcludedArtefact.groovy
@@ -0,0 +1,25 @@
+/*
+ *  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
+ *
+ *    https://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 grails.boot.config.excluded
+
+import grails.boot.config.ArtefactMarker
+
+@ArtefactMarker
+class ExcludedArtefact {
+}
diff --git a/grails-core/src/test/groovy/grails/boot/config/indexed/IncludedArtefact.groovy b/grails-core/src/test/groovy/grails/boot/config/indexed/IncludedArtefact.groovy
new file mode 100644
index 0000000000..6cfac753fe
--- /dev/null
+++ b/grails-core/src/test/groovy/grails/boot/config/indexed/IncludedArtefact.groovy
@@ -0,0 +1,25 @@
+/*
+ *  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
+ *
+ *    https://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 grails.boot.config.indexed
+
+import grails.boot.config.ArtefactMarker
+
+@ArtefactMarker
+class IncludedArtefact {
+}