| 1 | package org.egothor.methodatlas; | |
| 2 | ||
| 3 | import java.io.IOException; | |
| 4 | import java.io.PrintWriter; | |
| 5 | import java.nio.file.Files; | |
| 6 | import java.nio.file.Path; | |
| 7 | import java.util.ArrayList; | |
| 8 | import java.util.HashMap; | |
| 9 | import java.util.LinkedHashMap; | |
| 10 | import java.util.LinkedHashSet; | |
| 11 | import java.util.List; | |
| 12 | import java.util.Map; | |
| 13 | import java.util.Set; | |
| 14 | import java.util.logging.Level; | |
| 15 | import java.util.logging.Logger; | |
| 16 | import java.util.stream.Stream; | |
| 17 | ||
| 18 | import org.egothor.methodatlas.api.ScanRecord; | |
| 19 | import org.egothor.methodatlas.api.SourcePatcher; | |
| 20 | ||
| 21 | /** | |
| 22 | * Engine that applies annotation changes to source files driven by a | |
| 23 | * reviewed MethodAtlas CSV export. | |
| 24 | * | |
| 25 | * <p> | |
| 26 | * The {@code -apply-tags-from-csv} workflow allows a security or development | |
| 27 | * team to review MethodAtlas output in a CSV file, adjust the {@code tags} and | |
| 28 | * {@code display_name} columns to reflect the desired annotation state, and | |
| 29 | * then replay those decisions back into the source code. The CSV acts as a | |
| 30 | * <em>desired-state specification</em>: after a successful run, re-running | |
| 31 | * MethodAtlas on the same source tree would reproduce an equivalent CSV. | |
| 32 | * </p> | |
| 33 | * | |
| 34 | * <h2>Invariant</h2> | |
| 35 | * <p> | |
| 36 | * The CSV must be complete — it must contain one row for every test method | |
| 37 | * currently present in the scanned source tree (matching the configured file | |
| 38 | * suffixes and test annotations). A method that appears in the source but not | |
| 39 | * in the CSV, or in the CSV but not in the source, constitutes a | |
| 40 | * <em>mismatch</em> and is reported as a warning. When a mismatch limit is | |
| 41 | * configured, the engine aborts without making any source changes if the | |
| 42 | * number of mismatches reaches or exceeds that limit. | |
| 43 | * </p> | |
| 44 | * | |
| 45 | * <h2>What is applied</h2> | |
| 46 | * <ul> | |
| 47 | * <li>{@code tags} column — the exact set of {@code @Tag} annotations desired | |
| 48 | * on the method; existing tags not in the list are removed, missing tags | |
| 49 | * are added</li> | |
| 50 | * <li>{@code display_name} column — the desired {@code @DisplayName} text; | |
| 51 | * empty means "remove any existing {@code @DisplayName}"</li> | |
| 52 | * </ul> | |
| 53 | * | |
| 54 | * <h2>Mismatch handling</h2> | |
| 55 | * <p> | |
| 56 | * If {@code mismatchLimit} is {@code -1} (no limit), mismatches are logged as | |
| 57 | * warnings and the engine proceeds. If {@code mismatchLimit} is {@code >= 0}, | |
| 58 | * the engine first counts mismatches and aborts with exit code {@code 1} when | |
| 59 | * the count is {@code >= mismatchLimit}. Setting the limit to {@code 1} | |
| 60 | * therefore causes any mismatch to abort the run without touching source files. | |
| 61 | * </p> | |
| 62 | * | |
| 63 | * <p> | |
| 64 | * This class is a non-instantiable utility holder. | |
| 65 | * </p> | |
| 66 | * | |
| 67 | * @see SourcePatcher | |
| 68 | * @see MethodAtlasApp | |
| 69 | */ | |
| 70 | public final class ApplyTagsFromCsvEngine { | |
| 71 | ||
| 72 | private static final Logger LOG = Logger.getLogger(ApplyTagsFromCsvEngine.class.getName()); | |
| 73 | ||
| 74 | private ApplyTagsFromCsvEngine() { | |
| 75 | } | |
| 76 | ||
| 77 | /** | |
| 78 | * Applies annotation changes from a reviewed CSV to source files. | |
| 79 | * | |
| 80 | * <p> | |
| 81 | * Source-method inventory (for mismatch detection) and source file write-back | |
| 82 | * are both delegated to the supplied {@link SourcePatcher} implementations via | |
| 83 | * {@link SourcePatcher#discoverMethodsByClass(java.nio.file.Path)} and | |
| 84 | * {@link SourcePatcher#patch(java.nio.file.Path, java.util.Map, java.util.Map, | |
| 85 | * java.io.PrintWriter)} respectively. | |
| 86 | * </p> | |
| 87 | * | |
| 88 | * @param csvFile path to a MethodAtlas CSV produced with a previous | |
| 89 | * scan and reviewed by the team; must contain | |
| 90 | * {@code fqcn}, {@code method}, {@code tags}, and | |
| 91 | * {@code display_name} columns | |
| 92 | * @param roots source root directories to scan for test files | |
| 93 | * @param mismatchLimit maximum number of mismatches before aborting; | |
| 94 | * {@code -1} means no limit (warn and proceed) | |
| 95 | * @param patchers list of configured {@link SourcePatcher} implementations | |
| 96 | * @param log writer for progress and summary output | |
| 97 | * @return {@code 0} on success, {@code 1} when the mismatch limit is | |
| 98 | * exceeded or a fatal error occurs | |
| 99 | * @throws IOException if the CSV file or source files cannot be read or | |
| 100 | * written | |
| 101 | */ | |
| 102 | public static int apply(Path csvFile, List<Path> roots, | |
| 103 | int mismatchLimit, List<SourcePatcher> patchers, PrintWriter log) | |
| 104 | throws IOException { | |
| 105 | ||
| 106 | // ── Step 1: load the desired-state CSV ──────────────────────────────── | |
| 107 | List<ScanRecord> records = DeltaReport.loadRecords(csvFile); | |
| 108 |
2
1. apply : removed conditional - replaced equality check with true → KILLED 2. apply : removed conditional - replaced equality check with false → KILLED |
if (records.isEmpty()) { |
| 109 |
1
1. apply : removed call to java/io/PrintWriter::println → KILLED |
log.println("Apply-tags-from-csv: CSV file contains no records — nothing to apply."); |
| 110 | return 0; | |
| 111 | } | |
| 112 | ||
| 113 |
1
1. apply : Replaced integer multiplication with division → SURVIVED |
Map<String, ScanRecord> desiredState = new HashMap<>(records.size() * 2); |
| 114 | for (ScanRecord r : records) { | |
| 115 | desiredState.put(key(r.fqcn(), r.method()), r); | |
| 116 | } | |
| 117 | ||
| 118 | // ── Step 2: scan source to build current method inventory ───────────── | |
| 119 | // Build a map from source file path → list of (fqcn, methodName) pairs | |
| 120 | // by asking each SourcePatcher to discover methods in supported files. | |
| 121 | Map<Path, List<MethodKey>> sourceIndex = | |
| 122 | buildSourceIndex(roots, patchers); | |
| 123 | ||
| 124 | Set<String> sourceKeys = new LinkedHashSet<>(); | |
| 125 | for (List<MethodKey> methods : sourceIndex.values()) { | |
| 126 | for (MethodKey mk : methods) { | |
| 127 | sourceKeys.add(key(mk.fqcn(), mk.method())); | |
| 128 | } | |
| 129 | } | |
| 130 | ||
| 131 | // ── Step 3: compute mismatches (symmetric difference) ───────────────── | |
| 132 | Set<String> inCsvNotSource = new LinkedHashSet<>(desiredState.keySet()); | |
| 133 | inCsvNotSource.removeAll(sourceKeys); | |
| 134 | ||
| 135 | Set<String> inSourceNotCsv = new LinkedHashSet<>(sourceKeys); | |
| 136 | inSourceNotCsv.removeAll(desiredState.keySet()); | |
| 137 | ||
| 138 |
1
1. apply : Replaced integer addition with subtraction → KILLED |
int mismatchCount = inCsvNotSource.size() + inSourceNotCsv.size(); |
| 139 | ||
| 140 | // ── Step 4: enforce mismatch limit ──────────────────────────────────── | |
| 141 |
6
1. apply : changed conditional boundary → SURVIVED 2. apply : changed conditional boundary → KILLED 3. apply : removed conditional - replaced comparison check with false → KILLED 4. apply : removed conditional - replaced comparison check with false → KILLED 5. apply : removed conditional - replaced comparison check with true → KILLED 6. apply : removed conditional - replaced comparison check with true → KILLED |
if (mismatchLimit >= 0 && mismatchCount >= mismatchLimit) { |
| 142 |
1
1. apply : replaced int return with 0 for org/egothor/methodatlas/ApplyTagsFromCsvEngine::apply → KILLED |
return reportMismatchesAndAbort(inCsvNotSource, inSourceNotCsv, mismatchCount, mismatchLimit, log); |
| 143 | } | |
| 144 | ||
| 145 | // ── Step 5: warn about mismatches (no limit, or below limit) ────────── | |
| 146 |
1
1. apply : removed call to org/egothor/methodatlas/ApplyTagsFromCsvEngine::warnMismatches → SURVIVED |
warnMismatches(inCsvNotSource, inSourceNotCsv); |
| 147 | ||
| 148 | // ── Step 6: apply changes file by file ──────────────────────────────── | |
| 149 |
1
1. apply : replaced int return with 0 for org/egothor/methodatlas/ApplyTagsFromCsvEngine::apply → SURVIVED |
return applyFilesLoop(sourceIndex, desiredState, patchers, mismatchCount, log); |
| 150 | } | |
| 151 | ||
| 152 | private static int reportMismatchesAndAbort(Set<String> inCsvNotSource, Set<String> inSourceNotCsv, | |
| 153 | int mismatchCount, int mismatchLimit, PrintWriter log) { | |
| 154 | for (String k : inCsvNotSource) { | |
| 155 |
1
1. reportMismatchesAndAbort : removed call to java/io/PrintWriter::println → KILLED |
log.println("MISMATCH (in CSV, not in source): " + k); |
| 156 | } | |
| 157 | for (String k : inSourceNotCsv) { | |
| 158 |
1
1. reportMismatchesAndAbort : removed call to java/io/PrintWriter::println → SURVIVED |
log.println("MISMATCH (in source, not in CSV): " + k); |
| 159 | } | |
| 160 |
1
1. reportMismatchesAndAbort : removed call to java/io/PrintWriter::println → KILLED |
log.println("Apply-tags-from-csv aborted: " + mismatchCount |
| 161 | + " mismatch(es) >= limit " + mismatchLimit + ". No source files were modified."); | |
| 162 |
1
1. reportMismatchesAndAbort : replaced int return with 0 for org/egothor/methodatlas/ApplyTagsFromCsvEngine::reportMismatchesAndAbort → KILLED |
return 1; |
| 163 | } | |
| 164 | ||
| 165 | private static void warnMismatches(Set<String> inCsvNotSource, Set<String> inSourceNotCsv) { | |
| 166 | if (LOG.isLoggable(Level.WARNING)) { | |
| 167 | for (String k : inCsvNotSource) { | |
| 168 | LOG.warning("Mismatch (in CSV, not found in source): " + k); | |
| 169 | } | |
| 170 | for (String k : inSourceNotCsv) { | |
| 171 | LOG.warning("Mismatch (in source, not present in CSV): " + k); | |
| 172 | } | |
| 173 | } | |
| 174 | } | |
| 175 | ||
| 176 | @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") | |
| 177 | private static int applyFilesLoop(Map<Path, List<MethodKey>> sourceIndex, | |
| 178 | Map<String, ScanRecord> desiredState, List<SourcePatcher> patchers, | |
| 179 | int mismatchCount, PrintWriter log) { | |
| 180 | int modifiedFiles = 0; | |
| 181 | int totalChanges = 0; | |
| 182 | boolean hadErrors = false; | |
| 183 | ||
| 184 | for (Map.Entry<Path, List<MethodKey>> entry : sourceIndex.entrySet()) { | |
| 185 | Path path = entry.getKey(); | |
| 186 |
2
1. applyFilesLoop : removed conditional - replaced equality check with false → SURVIVED 2. applyFilesLoop : removed conditional - replaced equality check with true → KILLED |
if (!hasRelevantMethod(entry.getValue(), desiredState)) { |
| 187 | continue; | |
| 188 | } | |
| 189 | ||
| 190 | SourcePatcher patcher = patchers.stream() | |
| 191 |
2
1. lambda$applyFilesLoop$0 : replaced boolean return with true for org/egothor/methodatlas/ApplyTagsFromCsvEngine::lambda$applyFilesLoop$0 → SURVIVED 2. lambda$applyFilesLoop$0 : replaced boolean return with false for org/egothor/methodatlas/ApplyTagsFromCsvEngine::lambda$applyFilesLoop$0 → KILLED |
.filter(p -> p.supports(path)) |
| 192 | .findFirst().orElse(null); | |
| 193 |
2
1. applyFilesLoop : removed conditional - replaced equality check with false → SURVIVED 2. applyFilesLoop : removed conditional - replaced equality check with true → KILLED |
if (patcher == null) { |
| 194 | continue; | |
| 195 | } | |
| 196 | ||
| 197 | // Build per-method desired state maps for this file | |
| 198 | Map<String, List<String>> tagsToApply = new LinkedHashMap<>(); | |
| 199 | Map<String, String> displayNames = new LinkedHashMap<>(); | |
| 200 | ||
| 201 | for (MethodKey mk : entry.getValue()) { | |
| 202 | String k = key(mk.fqcn(), mk.method()); | |
| 203 | ScanRecord desired = desiredState.get(k); | |
| 204 |
2
1. applyFilesLoop : removed conditional - replaced equality check with false → SURVIVED 2. applyFilesLoop : removed conditional - replaced equality check with true → KILLED |
if (desired == null) { |
| 205 | continue; | |
| 206 | } | |
| 207 |
2
1. applyFilesLoop : removed conditional - replaced equality check with true → SURVIVED 2. applyFilesLoop : removed conditional - replaced equality check with false → KILLED |
if (desired.tags() != null) { |
| 208 | tagsToApply.put(mk.method(), desired.tags()); | |
| 209 | } | |
| 210 | // null means the column was absent from the CSV (old format) — leave @DisplayName untouched. | |
| 211 | // "" means the column was present but empty — remove @DisplayName. | |
| 212 |
2
1. applyFilesLoop : removed conditional - replaced equality check with true → SURVIVED 2. applyFilesLoop : removed conditional - replaced equality check with false → KILLED |
if (desired.displayName() != null) { |
| 213 | displayNames.put(mk.method(), desired.displayName()); | |
| 214 | } | |
| 215 | } | |
| 216 | ||
| 217 | try { | |
| 218 | int changes = patcher.patch(path, tagsToApply, displayNames, log); | |
| 219 |
3
1. applyFilesLoop : removed conditional - replaced comparison check with false → SURVIVED 2. applyFilesLoop : changed conditional boundary → KILLED 3. applyFilesLoop : removed conditional - replaced comparison check with true → KILLED |
if (changes > 0) { |
| 220 |
1
1. applyFilesLoop : Changed increment from 1 to -1 → SURVIVED |
modifiedFiles++; |
| 221 |
1
1. applyFilesLoop : Replaced integer addition with subtraction → SURVIVED |
totalChanges += changes; |
| 222 | } | |
| 223 | } catch (IOException e) { | |
| 224 | if (LOG.isLoggable(Level.WARNING)) { | |
| 225 | LOG.log(Level.WARNING, "Cannot process: " + path, e); | |
| 226 | } | |
| 227 | hadErrors = true; | |
| 228 | } | |
| 229 | } | |
| 230 | ||
| 231 |
1
1. applyFilesLoop : removed call to java/io/PrintWriter::println → KILLED |
log.println("Apply-tags-from-csv complete: " + totalChanges + " change(s) in " |
| 232 | + modifiedFiles + " file(s); " + mismatchCount + " mismatch(es) skipped."); | |
| 233 |
2
1. applyFilesLoop : removed conditional - replaced equality check with false → SURVIVED 2. applyFilesLoop : removed conditional - replaced equality check with true → KILLED |
return hadErrors ? 1 : 0; |
| 234 | } | |
| 235 | ||
| 236 | private static boolean hasRelevantMethod(List<MethodKey> methods, Map<String, ScanRecord> desiredState) { | |
| 237 | for (MethodKey mk : methods) { | |
| 238 |
2
1. hasRelevantMethod : removed conditional - replaced equality check with true → SURVIVED 2. hasRelevantMethod : removed conditional - replaced equality check with false → KILLED |
if (desiredState.containsKey(key(mk.fqcn(), mk.method()))) { |
| 239 |
1
1. hasRelevantMethod : replaced boolean return with false for org/egothor/methodatlas/ApplyTagsFromCsvEngine::hasRelevantMethod → KILLED |
return true; |
| 240 | } | |
| 241 | } | |
| 242 |
1
1. hasRelevantMethod : replaced boolean return with true for org/egothor/methodatlas/ApplyTagsFromCsvEngine::hasRelevantMethod → NO_COVERAGE |
return false; |
| 243 | } | |
| 244 | ||
| 245 | /** | |
| 246 | * Scans source roots and builds an index mapping each source file to the | |
| 247 | * list of (FQCN, method) pairs for all test methods it contains. | |
| 248 | * | |
| 249 | * <p> | |
| 250 | * Each supported source file is passed to | |
| 251 | * {@link SourcePatcher#discoverMethodsByClass(Path)} to obtain the actual | |
| 252 | * test-method inventory. This gives the correct FQCN–method pairs | |
| 253 | * regardless of whether the source tree uses the standard | |
| 254 | * {@code package/to/ClassName.java} directory structure. | |
| 255 | * </p> | |
| 256 | * | |
| 257 | * @param roots source root directories | |
| 258 | * @param patchers list of configured patchers used to identify supported files | |
| 259 | * @return map from source file path to list of {@link MethodKey} entries; | |
| 260 | * never {@code null} | |
| 261 | * @throws IOException if a file tree cannot be traversed | |
| 262 | */ | |
| 263 | @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") | |
| 264 | private static Map<Path, List<MethodKey>> buildSourceIndex( | |
| 265 | List<Path> roots, | |
| 266 | List<SourcePatcher> patchers) throws IOException { | |
| 267 | ||
| 268 | Map<Path, List<MethodKey>> index = new HashMap<>(); | |
| 269 | ||
| 270 | for (Path root : roots) { | |
| 271 |
2
1. buildSourceIndex : removed conditional - replaced equality check with false → SURVIVED 2. buildSourceIndex : removed conditional - replaced equality check with true → KILLED |
if (!Files.isDirectory(root)) { |
| 272 | continue; | |
| 273 | } | |
| 274 | try (Stream<Path> stream = Files.walk(root)) { | |
| 275 | for (Path path : (Iterable<Path>) stream | |
| 276 |
2
1. lambda$buildSourceIndex$1 : replaced boolean return with true for org/egothor/methodatlas/ApplyTagsFromCsvEngine::lambda$buildSourceIndex$1 → SURVIVED 2. lambda$buildSourceIndex$1 : replaced boolean return with false for org/egothor/methodatlas/ApplyTagsFromCsvEngine::lambda$buildSourceIndex$1 → KILLED |
.filter(Files::isRegularFile)::iterator) { |
| 277 | ||
| 278 | SourcePatcher patcher = patchers.stream() | |
| 279 |
2
1. lambda$buildSourceIndex$2 : replaced boolean return with true for org/egothor/methodatlas/ApplyTagsFromCsvEngine::lambda$buildSourceIndex$2 → SURVIVED 2. lambda$buildSourceIndex$2 : replaced boolean return with false for org/egothor/methodatlas/ApplyTagsFromCsvEngine::lambda$buildSourceIndex$2 → KILLED |
.filter(p -> p.supports(path)) |
| 280 | .findFirst().orElse(null); | |
| 281 |
2
1. buildSourceIndex : removed conditional - replaced equality check with true → KILLED 2. buildSourceIndex : removed conditional - replaced equality check with false → KILLED |
if (patcher == null) { |
| 282 | continue; | |
| 283 | } | |
| 284 | ||
| 285 | // Ask the patcher to discover test methods in this file | |
| 286 | Map<String, List<String>> byClass = patcher.discoverMethodsByClass(path); | |
| 287 | List<MethodKey> keys = new ArrayList<>(); | |
| 288 | for (Map.Entry<String, List<String>> classEntry : byClass.entrySet()) { | |
| 289 | String fqcn = classEntry.getKey(); | |
| 290 | for (String methodName : classEntry.getValue()) { | |
| 291 | keys.add(new MethodKey(fqcn, methodName)); | |
| 292 | } | |
| 293 | } | |
| 294 |
2
1. buildSourceIndex : removed conditional - replaced equality check with true → SURVIVED 2. buildSourceIndex : removed conditional - replaced equality check with false → KILLED |
if (!keys.isEmpty()) { |
| 295 | index.put(path, keys); | |
| 296 | } | |
| 297 | } | |
| 298 | } | |
| 299 | } | |
| 300 | ||
| 301 |
1
1. buildSourceIndex : replaced return value with Collections.emptyMap for org/egothor/methodatlas/ApplyTagsFromCsvEngine::buildSourceIndex → KILLED |
return index; |
| 302 | } | |
| 303 | ||
| 304 | private static String key(String fqcn, String method) { | |
| 305 |
1
1. key : replaced return value with "" for org/egothor/methodatlas/ApplyTagsFromCsvEngine::key → KILLED |
return fqcn + "::" + method; |
| 306 | } | |
| 307 | ||
| 308 | /** Lightweight tuple holding a fully qualified class name and a method name. */ | |
| 309 | private record MethodKey(String fqcn, String method) { | |
| 310 | } | |
| 311 | } | |
Mutations | ||
| 108 |
1.1 2.2 |
|
| 109 |
1.1 |
|
| 113 |
1.1 |
|
| 138 |
1.1 |
|
| 141 |
1.1 2.2 3.3 4.4 5.5 6.6 |
|
| 142 |
1.1 |
|
| 146 |
1.1 |
|
| 149 |
1.1 |
|
| 155 |
1.1 |
|
| 158 |
1.1 |
|
| 160 |
1.1 |
|
| 162 |
1.1 |
|
| 186 |
1.1 2.2 |
|
| 191 |
1.1 2.2 |
|
| 193 |
1.1 2.2 |
|
| 204 |
1.1 2.2 |
|
| 207 |
1.1 2.2 |
|
| 212 |
1.1 2.2 |
|
| 219 |
1.1 2.2 3.3 |
|
| 220 |
1.1 |
|
| 221 |
1.1 |
|
| 231 |
1.1 |
|
| 233 |
1.1 2.2 |
|
| 238 |
1.1 2.2 |
|
| 239 |
1.1 |
|
| 242 |
1.1 |
|
| 271 |
1.1 2.2 |
|
| 276 |
1.1 2.2 |
|
| 279 |
1.1 2.2 |
|
| 281 |
1.1 2.2 |
|
| 294 |
1.1 2.2 |
|
| 301 |
1.1 |
|
| 305 |
1.1 |