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

Mutations

107

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

115

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

143

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

168

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

182

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

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

237

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

257

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

284

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

300

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

Active mutators

Tests examined


Report generated by PIT 1.30.1 support