PluginLoader.java

1
// SPDX-License-Identifier: Apache-2.0
2
// Copyright 2026 Egothor
3
// Copyright 2026 Accenture
4
package org.egothor.methodatlas.command;
5
6
import java.io.IOException;
7
import java.util.ArrayList;
8
import java.util.LinkedHashSet;
9
import java.util.List;
10
import java.util.ServiceLoader;
11
import java.util.Set;
12
import java.util.logging.Level;
13
import java.util.logging.Logger;
14
15
import org.egothor.methodatlas.api.CredentialDetector;
16
import org.egothor.methodatlas.api.CredentialDetectorConfig;
17
import org.egothor.methodatlas.api.SourcePatcher;
18
import org.egothor.methodatlas.api.TestDiscovery;
19
import org.egothor.methodatlas.api.TestDiscoveryConfig;
20
21
/**
22
 * Resolves and configures discovery plugins via {@link ServiceLoader}.
23
 *
24
 * <p>
25
 * Each plugin JAR ships a service registration file under
26
 * {@code META-INF/services/} listing its implementation of
27
 * {@link TestDiscovery} (and, optionally, {@link SourcePatcher}). This loader
28
 * walks the classpath, instantiates every registered provider, applies the
29
 * run-time {@link TestDiscoveryConfig} via {@code configure}, and verifies that
30
 * every provider declares a unique {@code pluginId()}.
31
 * </p>
32
 *
33
 * <h2>Lifecycle</h2>
34
 *
35
 * <p>
36
 * Instances are intended to be created once per CLI run and injected into the
37
 * {@link Command} implementations that need them. The loader itself is
38
 * stateless — no instance fields — so a single loader can be shared between
39
 * commands that participate in the same orchestration. The lifecycle of the
40
 * loaded providers is owned by the caller: a typical usage closes them in a
41
 * {@code finally} block via {@link #closeAll(List)}.
42
 * </p>
43
 *
44
 * <h2>Thread safety</h2>
45
 *
46
 * <p>
47
 * This class is thread-safe. {@link ServiceLoader#load(Class)} resolution is
48
 * idempotent per classloader, and no shared mutable state is maintained.
49
 * </p>
50
 *
51
 * @see TestDiscovery
52
 * @see SourcePatcher
53
 * @see CredentialDetector
54
 * @see Command
55
 * @since 1.0.0
56
 */
57
public final class PluginLoader {
58
59
    private static final Logger LOG = Logger.getLogger(PluginLoader.class.getName());
60
61
    /**
62
     * Creates a new plugin loader. The loader carries no instance state and is
63
     * safe to share across commands within a single CLI run.
64
     */
65
    public PluginLoader() {
66
        // Intentionally empty; PluginLoader is stateless.
67
    }
68
69
    /**
70
     * Loads all {@link TestDiscovery} providers registered via
71
     * {@link ServiceLoader}, configures each one with {@code config}, and
72
     * returns them in registration order.
73
     *
74
     * <p>
75
     * The returned providers are open resources. Callers must close them
76
     * through {@link #closeAll(List)} in a {@code finally} block to release
77
     * any per-provider resources (file handles, native processes, etc.).
78
     * </p>
79
     *
80
     * <p>
81
     * Time complexity is {@code O(p)} in the number of providers; the
82
     * ServiceLoader lookup itself is dominated by classpath scanning.
83
     * </p>
84
     *
85
     * @param config run-time configuration forwarded to every provider via
86
     *               {@link TestDiscovery#configure}; must not be {@code null}
87
     * @return non-empty list of configured providers in registration order
88
     * @throws IllegalStateException if no providers are found on the classpath,
89
     *                               or if two providers share the same
90
     *                               {@link TestDiscovery#pluginId()}
91
     */
92
    @SuppressWarnings("PMD.CloseResource") // callers own the lifecycle and must close via closeAll()
93
    public List<TestDiscovery> loadProviders(TestDiscoveryConfig config) {
94
        List<TestDiscovery> providers = new ArrayList<>();
95
        for (TestDiscovery provider : ServiceLoader.load(TestDiscovery.class)) {
96 1 1. loadProviders : removed call to org/egothor/methodatlas/api/TestDiscovery::configure → KILLED
            provider.configure(config);
97
            providers.add(provider);
98
        }
99 2 1. loadProviders : removed conditional - replaced equality check with false → SURVIVED
2. loadProviders : removed conditional - replaced equality check with true → KILLED
        if (providers.isEmpty()) {
100
            throw new IllegalStateException(
101
                    "No TestDiscovery providers found on the classpath. "
102
                    + "Ensure at least one provider JAR ships the service registration file "
103
                    + "META-INF/services/org.egothor.methodatlas.api.TestDiscovery.");
104
        }
105 1 1. loadProviders : removed call to org/egothor/methodatlas/command/PluginLoader::requireUniqueDiscoveryIds → SURVIVED
        requireUniqueDiscoveryIds(providers);
106 1 1. loadProviders : replaced return value with Collections.emptyList for org/egothor/methodatlas/command/PluginLoader::loadProviders → KILLED
        return providers;
107
    }
108
109
    /**
110
     * Loads all {@link SourcePatcher} providers registered via
111
     * {@link ServiceLoader}, configures each one with {@code config}, and
112
     * returns them in registration order.
113
     *
114
     * <p>
115
     * Unlike {@link #loadProviders}, returning an empty list is legitimate:
116
     * languages that do not support source write-back (such as TypeScript or
117
     * Python) ship no patcher.
118
     * </p>
119
     *
120
     * @param config run-time configuration forwarded to every patcher via
121
     *               {@link SourcePatcher#configure}; must not be {@code null}
122
     * @return possibly-empty list of configured patchers in registration order
123
     * @throws IllegalStateException if two patchers share the same
124
     *                               {@link SourcePatcher#pluginId()}
125
     */
126
    public List<SourcePatcher> loadPatchers(TestDiscoveryConfig config) {
127
        List<SourcePatcher> patchers = new ArrayList<>();
128
        for (SourcePatcher patcher : ServiceLoader.load(SourcePatcher.class)) {
129 1 1. loadPatchers : removed call to org/egothor/methodatlas/api/SourcePatcher::configure → SURVIVED
            patcher.configure(config);
130
            patchers.add(patcher);
131
        }
132 1 1. loadPatchers : removed call to org/egothor/methodatlas/command/PluginLoader::requireUniquePatcherIds → SURVIVED
        requireUniquePatcherIds(patchers);
133 1 1. loadPatchers : replaced return value with Collections.emptyList for org/egothor/methodatlas/command/PluginLoader::loadPatchers → KILLED
        return patchers;
134
    }
135
136
    /**
137
     * Closes every provider in the list, logging any {@link IOException} at
138
     * {@link Level#FINE} and continuing so that all providers are attempted.
139
     *
140
     * <p>
141
     * This method never throws: a provider whose {@code close} fails leaves
142
     * its resources in an indeterminate state, but the orchestration layer
143
     * always exits cleanly. Failures are observable through the FINE-level
144
     * log.
145
     * </p>
146
     *
147
     * @param providers list of providers to close; must not be {@code null}
148
     */
149
    @SuppressWarnings("PMD.CloseResource") // this method IS the close mechanism; p.close() is called explicitly
150
    public void closeAll(List<TestDiscovery> providers) {
151
        for (TestDiscovery p : providers) {
152
            try {
153 1 1. closeAll : removed call to org/egothor/methodatlas/api/TestDiscovery::close → KILLED
                p.close();
154
            } catch (IOException e) {
155
                if (LOG.isLoggable(Level.FINE)) {
156
                    LOG.log(Level.FINE, "Failed to close provider " + p.pluginId(), e);
157
                }
158
            }
159
        }
160
    }
161
162
    /**
163
     * Verifies that every {@link TestDiscovery} provider in the list has a
164
     * unique {@link TestDiscovery#pluginId()}.
165
     *
166
     * <p>
167
     * This method is {@code static} because it is a pure validation with no
168
     * instance dependencies — test code calls it directly with handcrafted
169
     * provider lists, and the instance loader calls it after a
170
     * {@code ServiceLoader} sweep. Time complexity is {@code O(p)} in the
171
     * number of providers.
172
     * </p>
173
     *
174
     * @param providers list of providers to validate; must not be {@code null}
175
     * @throws IllegalStateException if two or more providers share the same id
176
     */
177
    @SuppressWarnings("PMD.CloseResource") // providers are owned by the caller; this method does not close them
178
    public static void requireUniqueDiscoveryIds(List<TestDiscovery> providers) {
179
        Set<String> seen = new LinkedHashSet<>();
180
        for (TestDiscovery p : providers) {
181
            String id = p.pluginId();
182 2 1. requireUniqueDiscoveryIds : removed conditional - replaced equality check with true → KILLED
2. requireUniqueDiscoveryIds : removed conditional - replaced equality check with false → KILLED
            if (!seen.add(id)) {
183
                throw new IllegalStateException(
184
                        "Duplicate TestDiscovery plugin ID \"" + id + "\": two or more "
185
                        + "registered providers claim the same pluginId(). "
186
                        + "Each provider must declare a unique identifier.");
187
            }
188
        }
189
    }
190
191
    /**
192
     * Verifies that every {@link SourcePatcher} in the list has a unique
193
     * {@link SourcePatcher#pluginId()}.
194
     *
195
     * @param patchers list of patchers to validate; must not be {@code null}
196
     * @throws IllegalStateException if two or more patchers share the same id
197
     */
198
    public static void requireUniquePatcherIds(List<SourcePatcher> patchers) {
199
        Set<String> seen = new LinkedHashSet<>();
200
        for (SourcePatcher p : patchers) {
201
            String id = p.pluginId();
202 2 1. requireUniquePatcherIds : removed conditional - replaced equality check with true → KILLED
2. requireUniquePatcherIds : removed conditional - replaced equality check with false → KILLED
            if (!seen.add(id)) {
203
                throw new IllegalStateException(
204
                        "Duplicate SourcePatcher plugin ID \"" + id + "\": two or more "
205
                        + "registered patchers claim the same pluginId(). "
206
                        + "Each patcher must declare a unique identifier.");
207
            }
208
        }
209
    }
210
211
    /**
212
     * Loads all {@link CredentialDetector} providers registered via
213
     * {@link ServiceLoader}, configures each with {@code config}, and returns
214
     * them in registration order. An empty list is legitimate — it means no
215
     * credential detector is on the classpath and the feature is unavailable.
216
     *
217
     * @param config runtime configuration forwarded to each detector; never {@code null}
218
     * @return possibly-empty list of configured detectors in registration order
219
     * @throws IllegalStateException if two detectors share the same {@code detectorId()}
220
     */
221
    @SuppressWarnings("PMD.CloseResource") // callers own the lifecycle and must close via closeAllCredentialDetectors()
222
    public List<CredentialDetector> loadCredentialDetectors(CredentialDetectorConfig config) {
223
        List<CredentialDetector> detectors = new ArrayList<>();
224
        for (CredentialDetector detector : ServiceLoader.load(CredentialDetector.class)) {
225 1 1. loadCredentialDetectors : removed call to org/egothor/methodatlas/api/CredentialDetector::configure → SURVIVED
            detector.configure(config);
226
            detectors.add(detector);
227
        }
228 1 1. loadCredentialDetectors : removed call to org/egothor/methodatlas/command/PluginLoader::requireUniqueCredentialDetectorIds → SURVIVED
        requireUniqueCredentialDetectorIds(detectors);
229 1 1. loadCredentialDetectors : replaced return value with Collections.emptyList for org/egothor/methodatlas/command/PluginLoader::loadCredentialDetectors → KILLED
        return detectors;
230
    }
231
232
    /**
233
     * Verifies that every {@link CredentialDetector} in the list has a unique
234
     * {@link CredentialDetector#detectorId()}.
235
     *
236
     * @param detectors detectors to validate; never {@code null}
237
     * @throws IllegalStateException if two or more detectors share the same id
238
     */
239
    // Detectors are owned by the caller; closing them here would be wrong.
240
    @SuppressWarnings("PMD.CloseResource")
241
    public static void requireUniqueCredentialDetectorIds(List<CredentialDetector> detectors) {
242
        Set<String> seen = new LinkedHashSet<>();
243
        for (CredentialDetector d : detectors) {
244
            String id = d.detectorId();
245 2 1. requireUniqueCredentialDetectorIds : removed conditional - replaced equality check with false → KILLED
2. requireUniqueCredentialDetectorIds : removed conditional - replaced equality check with true → KILLED
            if (!seen.add(id)) {
246
                throw new IllegalStateException(
247
                        "Duplicate CredentialDetector id \"" + id + "\": two or more registered "
248
                        + "detectors claim the same detectorId(). Each detector must declare a "
249
                        + "unique identifier.");
250
            }
251
        }
252
    }
253
254
    /**
255
     * Closes every detector in the list, logging any {@link IOException}
256
     * at {@link Level#FINE} and continuing so that all detectors are attempted.
257
     * Never throws.
258
     *
259
     * @param detectors detectors to close; must not be {@code null}
260
     */
261
    @SuppressWarnings("PMD.CloseResource") // this method IS the close mechanism; d.close() is called explicitly
262
    public void closeAllCredentialDetectors(List<CredentialDetector> detectors) {
263
        for (CredentialDetector d : detectors) {
264
            try {
265 1 1. closeAllCredentialDetectors : removed call to org/egothor/methodatlas/api/CredentialDetector::close → NO_COVERAGE
                d.close();
266
            } catch (IOException e) {
267
                if (LOG.isLoggable(Level.FINE)) {
268
                    LOG.log(Level.FINE, "Failed to close detector " + d.detectorId(), e);
269
                }
270
            }
271
        }
272
    }
273
}

Mutations

96

1.1
Location : loadProviders
Killed by : org.egothor.methodatlas.GitHubAnnotationsEmitterTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.GitHubAnnotationsEmitterTest]/[method:app_githubAnnotationsMode_emptyDirectoryProducesNoOutput(java.nio.file.Path)]
removed call to org/egothor/methodatlas/api/TestDiscovery::configure → KILLED

99

1.1
Location : loadProviders
Killed by : org.egothor.methodatlas.command.PluginLoaderTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.PluginLoaderTest]/[method:loadProviders_realClasspath_returnsAtLeastOneProvider()]
removed conditional - replaced equality check with true → KILLED

2.2
Location : loadProviders
Killed by : none
removed conditional - replaced equality check with false → SURVIVED
Covering tests

105

1.1
Location : loadProviders
Killed by : none
removed call to org/egothor/methodatlas/command/PluginLoader::requireUniqueDiscoveryIds → SURVIVED
Covering tests

106

1.1
Location : loadProviders
Killed by : org.egothor.methodatlas.command.PluginLoaderTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.PluginLoaderTest]/[method:loadProviders_realClasspath_returnsAtLeastOneProvider()]
replaced return value with Collections.emptyList for org/egothor/methodatlas/command/PluginLoader::loadProviders → KILLED

129

1.1
Location : loadPatchers
Killed by : none
removed call to org/egothor/methodatlas/api/SourcePatcher::configure → SURVIVED
Covering tests

132

1.1
Location : loadPatchers
Killed by : none
removed call to org/egothor/methodatlas/command/PluginLoader::requireUniquePatcherIds → SURVIVED
Covering tests

133

1.1
Location : loadPatchers
Killed by : org.egothor.methodatlas.MethodAtlasAppApplyTagsFromCsvTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.MethodAtlasAppApplyTagsFromCsvTest]/[method:applyTagsFromCsv_verbose_revealsFqcnMismatch(java.nio.file.Path)]
replaced return value with Collections.emptyList for org/egothor/methodatlas/command/PluginLoader::loadPatchers → KILLED

153

1.1
Location : closeAll
Killed by : org.egothor.methodatlas.command.PluginLoaderTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.PluginLoaderTest]/[method:closeAll_failureInOneProvider_doesNotPreventOthers()]
removed call to org/egothor/methodatlas/api/TestDiscovery::close → KILLED

182

1.1
Location : requireUniqueDiscoveryIds
Killed by : org.egothor.methodatlas.command.PluginLoaderTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.PluginLoaderTest]/[method:requireUniqueDiscoveryIds_distinctIds_doesNotThrow()]
removed conditional - replaced equality check with true → KILLED

2.2
Location : requireUniqueDiscoveryIds
Killed by : org.egothor.methodatlas.command.PluginLoaderTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.PluginLoaderTest]/[method:requireUniqueDiscoveryIds_duplicateId_throwsWithIdInMessage()]
removed conditional - replaced equality check with false → KILLED

202

1.1
Location : requireUniquePatcherIds
Killed by : org.egothor.methodatlas.command.PluginLoaderTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.PluginLoaderTest]/[method:requireUniquePatcherIds_distinctIds_doesNotThrow()]
removed conditional - replaced equality check with true → KILLED

2.2
Location : requireUniquePatcherIds
Killed by : org.egothor.methodatlas.command.PluginLoaderTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.PluginLoaderTest]/[method:requireUniquePatcherIds_duplicateId_throwsWithIdInMessage()]
removed conditional - replaced equality check with false → KILLED

225

1.1
Location : loadCredentialDetectors
Killed by : none
removed call to org/egothor/methodatlas/api/CredentialDetector::configure → SURVIVED
Covering tests

228

1.1
Location : loadCredentialDetectors
Killed by : none
removed call to org/egothor/methodatlas/command/PluginLoader::requireUniqueCredentialDetectorIds → SURVIVED
Covering tests

229

1.1
Location : loadCredentialDetectors
Killed by : org.egothor.methodatlas.command.PluginLoaderCredentialDetectorTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.PluginLoaderCredentialDetectorTest]/[method:loadsBuiltInDetectorFromClasspath()]
replaced return value with Collections.emptyList for org/egothor/methodatlas/command/PluginLoader::loadCredentialDetectors → KILLED

245

1.1
Location : requireUniqueCredentialDetectorIds
Killed by : org.egothor.methodatlas.command.PluginLoaderCredentialDetectorTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.PluginLoaderCredentialDetectorTest]/[method:rejectsDuplicateDetectorIds()]
removed conditional - replaced equality check with false → KILLED

2.2
Location : requireUniqueCredentialDetectorIds
Killed by : org.egothor.methodatlas.command.PluginLoaderCredentialDetectorTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.PluginLoaderCredentialDetectorTest]/[method:acceptsUniqueDetectorIds()]
removed conditional - replaced equality check with true → KILLED

265

1.1
Location : closeAllCredentialDetectors
Killed by : none
removed call to org/egothor/methodatlas/api/CredentialDetector::close → NO_COVERAGE

Active mutators

Tests examined


Report generated by PIT 1.22.1