| 1 | package org.egothor.methodatlas; | |
| 2 | ||
| 3 | import java.io.FilterOutputStream; | |
| 4 | import java.io.IOException; | |
| 5 | import java.io.OutputStream; | |
| 6 | import java.io.OutputStreamWriter; | |
| 7 | import java.io.PrintWriter; | |
| 8 | import java.nio.charset.StandardCharsets; | |
| 9 | import java.nio.file.Path; | |
| 10 | ||
| 11 | import org.egothor.methodatlas.ai.ManualConsumeEngine; | |
| 12 | import org.egothor.methodatlas.ai.AiSuggestionEngine; | |
| 13 | import org.egothor.methodatlas.api.TestDiscoveryConfig; | |
| 14 | import org.egothor.methodatlas.command.ApplyTagsCommand; | |
| 15 | import org.egothor.methodatlas.command.ApplyTagsFromCsvCommand; | |
| 16 | import org.egothor.methodatlas.command.CommandSupport; | |
| 17 | import org.egothor.methodatlas.command.DiffCommand; | |
| 18 | import org.egothor.methodatlas.command.GitHubAnnotationsCommand; | |
| 19 | import org.egothor.methodatlas.command.ManualPrepareCommand; | |
| 20 | import org.egothor.methodatlas.command.SarifCommand; | |
| 21 | import org.egothor.methodatlas.command.ScanCommand; | |
| 22 | ||
| 23 | /** | |
| 24 | * Command-line application for scanning Java test sources, extracting JUnit | |
| 25 | * test metadata, and optionally enriching the emitted results with AI-generated | |
| 26 | * security tagging suggestions. | |
| 27 | * | |
| 28 | * <p> | |
| 29 | * The application traverses one or more directory roots, parses matching source | |
| 30 | * files using JavaParser, identifies supported JUnit Jupiter test methods, and | |
| 31 | * emits one output record per discovered test method. File selection matches | |
| 32 | * source files whose names end with the configured suffix (default: | |
| 33 | * {@code Test.java}). | |
| 34 | * </p> | |
| 35 | * | |
| 36 | * <h2>Source-Derived Metadata</h2> | |
| 37 | * | |
| 38 | * <p> | |
| 39 | * For each discovered test method, the application reports: | |
| 40 | * </p> | |
| 41 | * <ul> | |
| 42 | * <li>fully qualified class name</li> | |
| 43 | * <li>method name</li> | |
| 44 | * <li>inclusive line count of the method declaration</li> | |
| 45 | * <li>JUnit {@code @Tag} values declared on the method</li> | |
| 46 | * </ul> | |
| 47 | * | |
| 48 | * <h2>AI Enrichment</h2> | |
| 49 | * | |
| 50 | * <p> | |
| 51 | * When AI support is enabled, the application submits each discovered test | |
| 52 | * class to an {@link org.egothor.methodatlas.ai.AiSuggestionEngine} and merges | |
| 53 | * the returned method-level suggestions into the emitted output. | |
| 54 | * </p> | |
| 55 | * | |
| 56 | * <h2>Manual AI Workflow</h2> | |
| 57 | * | |
| 58 | * <p> | |
| 59 | * Operators who cannot access an AI API directly can use the two-phase manual | |
| 60 | * workflow: | |
| 61 | * </p> | |
| 62 | * <ol> | |
| 63 | * <li><b>Prepare phase</b> ({@code -manual-prepare}): the application scans | |
| 64 | * test sources and writes one work file per class to the specified directory. | |
| 65 | * Each work file contains operator instructions and the full AI prompt (with | |
| 66 | * class source embedded). No CSV output is produced in this phase.</li> | |
| 67 | * <li><b>Consume phase</b> ({@code -manual-consume}): the application reads | |
| 68 | * operator-saved AI response files ({@code <stem>.response.txt}) from the | |
| 69 | * response directory and produces the final enriched CSV. Classes whose | |
| 70 | * response file is absent receive empty AI columns.</li> | |
| 71 | * </ol> | |
| 72 | * | |
| 73 | * <h2>Supported Command-Line Options</h2> | |
| 74 | * | |
| 75 | * <ul> | |
| 76 | * <li>{@code -config <path>} — loads default values from a YAML configuration | |
| 77 | * file; command-line flags override YAML values</li> | |
| 78 | * <li>{@code -plain} — emits plain text output instead of CSV</li> | |
| 79 | * <li>{@code -sarif} — emits SARIF 2.1.0 JSON output</li> | |
| 80 | * <li>{@code -ai} — enables AI-based enrichment</li> | |
| 81 | * <li>{@code -ai-provider <provider>} — selects the AI provider</li> | |
| 82 | * <li>{@code -ai-model <model>} — selects the provider-specific model</li> | |
| 83 | * <li>{@code -ai-base-url <url>} — overrides the provider base URL</li> | |
| 84 | * <li>{@code -ai-api-key <key>} — supplies the AI API key directly</li> | |
| 85 | * <li>{@code -ai-api-key-env <name>} — resolves the AI API key from an | |
| 86 | * environment variable</li> | |
| 87 | * <li>{@code -ai-taxonomy <path>} — loads taxonomy text from an external | |
| 88 | * file</li> | |
| 89 | * <li>{@code -ai-taxonomy-mode <mode>} — selects the built-in taxonomy | |
| 90 | * variant</li> | |
| 91 | * <li>{@code -ai-max-class-chars <count>} — limits class source size submitted | |
| 92 | * to AI</li> | |
| 93 | * <li>{@code -ai-timeout-sec <seconds>} — sets the AI request timeout</li> | |
| 94 | * <li>{@code -ai-max-retries <count>} — sets the retry limit for AI | |
| 95 | * operations</li> | |
| 96 | * <li>{@code -ai-confidence} — requests a confidence score for each AI | |
| 97 | * security classification; adds an {@code ai_confidence} column to the | |
| 98 | * output</li> | |
| 99 | * <li>{@code -ai-cache <path>} — loads a previous scan CSV produced with | |
| 100 | * {@code -content-hash -ai} as an AI result cache; classes whose | |
| 101 | * {@code content_hash} matches a cache entry are classified without an API | |
| 102 | * call; changed and new classes are classified normally</li> | |
| 103 | * <li>{@code -file-suffix <suffix>} — matches source files by name suffix | |
| 104 | * (default: {@code Test.java}); may be repeated to match multiple patterns, | |
| 105 | * e.g. {@code -file-suffix Test.java -file-suffix IT.java}; the first | |
| 106 | * occurrence replaces the default</li> | |
| 107 | * <li>{@code -test-annotation <name>} — recognises methods annotated with | |
| 108 | * {@code name} as test methods; may be repeated; the first occurrence replaces | |
| 109 | * the JVM provider's default set (JUnit 5 {@code Test}, {@code ParameterizedTest}, | |
| 110 | * {@code RepeatedTest}, {@code TestFactory}, {@code TestTemplate})</li> | |
| 111 | * <li>{@code -emit-metadata} — emits {@code # key: value} comment lines | |
| 112 | * before the header row describing the tool version, scan timestamp, and | |
| 113 | * taxonomy configuration</li> | |
| 114 | * <li>{@code -apply-tags} — instead of emitting a report, writes | |
| 115 | * AI-generated {@code @DisplayName} and {@code @Tag} annotations back to | |
| 116 | * the scanned source files; requires AI enrichment to be enabled</li> | |
| 117 | * <li>{@code -content-hash} — includes a SHA-256 fingerprint of each class | |
| 118 | * source as a {@code content_hash} column in CSV/plain output and as a SARIF | |
| 119 | * property; useful for detecting which classes changed between scans</li> | |
| 120 | * <li>{@code -security-only} — suppresses non-security methods from the output; | |
| 121 | * only methods whose AI classification (or override) has | |
| 122 | * {@code securityRelevant=true} are emitted; requires AI enrichment or a | |
| 123 | * classification override file to have any effect</li> | |
| 124 | * <li>{@code -manual-prepare <workdir> <responsedir>} — runs the manual AI | |
| 125 | * prepare phase, writing work files to {@code workdir} and empty response stubs | |
| 126 | * to {@code responsedir}; the two paths may be identical</li> | |
| 127 | * <li>{@code -manual-consume <workdir> <responsedir>} — runs the manual AI | |
| 128 | * consume phase, reading response files from {@code responsedir} and emitting | |
| 129 | * the final enriched CSV</li> | |
| 130 | * <li>{@code -diff <before.csv> <after.csv>} — compares two MethodAtlas scan | |
| 131 | * outputs and emits a delta report showing added, removed, and modified test | |
| 132 | * methods; all other flags are ignored when {@code -diff} is present</li> | |
| 133 | * <li>{@code -apply-tags-from-csv <path>} — instead of emitting a report, | |
| 134 | * applies the annotation decisions recorded in the reviewed CSV back to the | |
| 135 | * source files; the CSV is treated as a complete desired-state specification: | |
| 136 | * every test method's {@code @Tag} set and {@code @DisplayName} are driven | |
| 137 | * entirely by the corresponding CSV row</li> | |
| 138 | * <li>{@code -mismatch-limit <n>} — when used with {@code -apply-tags-from-csv}, | |
| 139 | * aborts without modifying any source file if the number of mismatches between | |
| 140 | * the CSV and the current source tree reaches or exceeds {@code n}; {@code -1} | |
| 141 | * (the default) logs mismatches as warnings and proceeds</li> | |
| 142 | * <li>{@code -emit-source-root} — adds a {@code source_root} column to CSV | |
| 143 | * output and a {@code SRCROOT=} token to plain-text output, identifying which | |
| 144 | * scan root each record originated from; essential in multi-root or monorepo | |
| 145 | * projects where the same fully qualified class name can appear under different | |
| 146 | * source trees (e.g. {@code module-a/src/test/java/} and | |
| 147 | * {@code module-b/src/test/java/}); has no effect on SARIF or GitHub Annotations | |
| 148 | * output</li> | |
| 149 | * </ul> | |
| 150 | * | |
| 151 | * <p> | |
| 152 | * Any remaining non-option arguments are interpreted as root paths to scan. If | |
| 153 | * no scan path is supplied, the current working directory is scanned. | |
| 154 | * </p> | |
| 155 | * | |
| 156 | * <h2>Exit Codes</h2> | |
| 157 | * | |
| 158 | * <ul> | |
| 159 | * <li>{@code 0} — all files processed successfully</li> | |
| 160 | * <li>{@code 1} — one or more files could not be parsed or processed</li> | |
| 161 | * </ul> | |
| 162 | * | |
| 163 | * @see org.egothor.methodatlas.ai.AiSuggestionEngine | |
| 164 | * @see org.egothor.methodatlas.api.SourcePatcher | |
| 165 | * @see org.egothor.methodatlas.emit.OutputEmitter | |
| 166 | * @see org.egothor.methodatlas.emit.SarifEmitter | |
| 167 | * @see org.egothor.methodatlas.command.Command | |
| 168 | * @see #main(String[]) | |
| 169 | */ | |
| 170 | public final class MethodAtlasApp { | |
| 171 | ||
| 172 | private static final String FLAG_DIFF = "-diff"; | |
| 173 | ||
| 174 | /** | |
| 175 | * Prevents instantiation of this utility class. | |
| 176 | */ | |
| 177 | private MethodAtlasApp() { | |
| 178 | } | |
| 179 | ||
| 180 | /** | |
| 181 | * Program entry point. | |
| 182 | * | |
| 183 | * <p> | |
| 184 | * Delegates all work to {@link #run(String[], PrintWriter)}. Exits with a | |
| 185 | * non-zero status code if any source file could not be processed. | |
| 186 | * </p> | |
| 187 | * | |
| 188 | * @param args command-line arguments | |
| 189 | * @throws IOException if traversal of a configured file tree fails | |
| 190 | * @throws IllegalArgumentException if an option is unknown, if a required | |
| 191 | * option value is missing, or if an option | |
| 192 | * value cannot be parsed | |
| 193 | * @throws IllegalStateException if AI support is enabled but the AI engine | |
| 194 | * cannot be created successfully | |
| 195 | */ | |
| 196 | public static void main(String[] args) throws IOException { | |
| 197 | // Wrap System.out in a guarded stream whose close() only flushes. | |
| 198 | // This lets try-with-resources manage the PrintWriter (satisfying | |
| 199 | // SpotBugs CloseResource and PMD UseTryWithResources) without | |
| 200 | // permanently closing System.out (satisfying Error Prone's | |
| 201 | // ClosingStandardOutputStreams check). | |
| 202 | OutputStream guarded = new FilterOutputStream(System.out) { | |
| 203 | @Override | |
| 204 | public void close() throws IOException { | |
| 205 |
1
1. close : removed call to org/egothor/methodatlas/MethodAtlasApp$1::flush → NO_COVERAGE |
flush(); // flush but do NOT close System.out |
| 206 | } | |
| 207 | }; | |
| 208 | try (PrintWriter out = new PrintWriter(new OutputStreamWriter(guarded, StandardCharsets.UTF_8), true)) { | |
| 209 | int exitCode = run(args, out); | |
| 210 |
2
1. main : removed conditional - replaced equality check with false → NO_COVERAGE 2. main : removed conditional - replaced equality check with true → NO_COVERAGE |
if (exitCode != 0) { |
| 211 |
1
1. main : removed call to java/lang/System::exit → NO_COVERAGE |
System.exit(exitCode); |
| 212 | } | |
| 213 | } | |
| 214 | } | |
| 215 | ||
| 216 | /** | |
| 217 | * Executes a full application run by routing to the appropriate | |
| 218 | * {@link org.egothor.methodatlas.command.Command} implementation. | |
| 219 | * | |
| 220 | * <p> | |
| 221 | * This method is the primary entry point for programmatic and test use. It | |
| 222 | * parses arguments, identifies the requested operating mode, constructs the | |
| 223 | * matching command object, and delegates execution to it. | |
| 224 | * </p> | |
| 225 | * | |
| 226 | * @param args command-line arguments | |
| 227 | * @param out writer that receives all emitted output | |
| 228 | * @return {@code 0} if all files were processed successfully, {@code 1} if | |
| 229 | * any file produced a parse or processing error | |
| 230 | * @throws IOException if traversal of a configured file tree fails | |
| 231 | * @throws IllegalArgumentException if an option is unknown, if a required | |
| 232 | * option value is missing, or if an option | |
| 233 | * value cannot be parsed | |
| 234 | * @throws IllegalStateException if AI support is enabled but the AI engine | |
| 235 | * cannot be created successfully | |
| 236 | */ | |
| 237 | @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") // DiffCommand is created inside the loop but returned immediately | |
| 238 | /* default */ static int run(String[] args, PrintWriter out) throws IOException { | |
| 239 | // -diff is handled before full argument parsing; all other flags are ignored. | |
| 240 |
3
1. run : removed conditional - replaced comparison check with false → KILLED 2. run : removed conditional - replaced comparison check with true → KILLED 3. run : changed conditional boundary → KILLED |
for (int i = 0; i < args.length; i++) { |
| 241 |
2
1. run : removed conditional - replaced equality check with false → KILLED 2. run : removed conditional - replaced equality check with true → KILLED |
if (FLAG_DIFF.equals(args[i])) { |
| 242 |
4
1. run : Replaced integer addition with subtraction → SURVIVED 2. run : removed conditional - replaced comparison check with false → SURVIVED 3. run : changed conditional boundary → SURVIVED 4. run : removed conditional - replaced comparison check with true → KILLED |
if (i + 2 >= args.length) { |
| 243 | throw new IllegalArgumentException( | |
| 244 | "-diff requires two arguments: -diff <before.csv> <after.csv>"); | |
| 245 | } | |
| 246 |
3
1. run : replaced int return with 0 for org/egothor/methodatlas/MethodAtlasApp::run → SURVIVED 2. run : Replaced integer addition with subtraction → KILLED 3. run : Replaced integer addition with subtraction → KILLED |
return new DiffCommand(Path.of(args[i + 1]), Path.of(args[i + 2])).execute(out); |
| 247 | } | |
| 248 | } | |
| 249 | ||
| 250 | CliConfig cliConfig = CliArgs.parse(args); | |
| 251 | ClassificationOverride override = CommandSupport.loadClassificationOverride(cliConfig.overrideFile()); | |
| 252 | AiResultCache aiCache = CommandSupport.buildAiCache(cliConfig.aiCacheFile()); | |
| 253 | ||
| 254 | TestDiscoveryConfig discoveryConfig = | |
| 255 | new TestDiscoveryConfig(cliConfig.fileSuffixes(), cliConfig.testMarkers(), cliConfig.properties()); | |
| 256 | ||
| 257 | // Manual prepare phase: write AI prompt work files; no CSV output. | |
| 258 |
2
1. run : removed conditional - replaced equality check with true → KILLED 2. run : removed conditional - replaced equality check with false → KILLED |
if (cliConfig.manualMode() instanceof ManualMode.Prepare prepare) { |
| 259 |
1
1. run : replaced int return with 0 for org/egothor/methodatlas/MethodAtlasApp::run → KILLED |
return new ManualPrepareCommand(prepare, cliConfig, discoveryConfig).execute(out); |
| 260 | } | |
| 261 | ||
| 262 | // Determine AI engine: manual consume reads from files; normal mode calls APIs. | |
| 263 | AiSuggestionEngine aiEngine; | |
| 264 |
2
1. run : removed conditional - replaced equality check with true → KILLED 2. run : removed conditional - replaced equality check with false → KILLED |
if (cliConfig.manualMode() instanceof ManualMode.Consume consume) { |
| 265 | aiEngine = new ManualConsumeEngine(consume.responseDir()); | |
| 266 | } else { | |
| 267 | aiEngine = CommandSupport.buildAiEngine(cliConfig.aiOptions()); | |
| 268 | } | |
| 269 | ||
| 270 | // Apply-tags-from-csv mode: apply reviewed CSV decisions to source files. | |
| 271 |
2
1. run : removed conditional - replaced equality check with false → KILLED 2. run : removed conditional - replaced equality check with true → KILLED |
if (cliConfig.applyTagsFromCsvFile() != null) { |
| 272 |
1
1. run : replaced int return with 0 for org/egothor/methodatlas/MethodAtlasApp::run → KILLED |
return new ApplyTagsFromCsvCommand(cliConfig, discoveryConfig).execute(out); |
| 273 | } | |
| 274 | ||
| 275 | // Apply-tags mode: annotate source files; no report emitted. | |
| 276 |
2
1. run : removed conditional - replaced equality check with false → KILLED 2. run : removed conditional - replaced equality check with true → KILLED |
if (cliConfig.applyTags()) { |
| 277 |
1
1. run : replaced int return with 0 for org/egothor/methodatlas/MethodAtlasApp::run → SURVIVED |
return new ApplyTagsCommand(cliConfig, discoveryConfig, aiEngine, override, aiCache).execute(out); |
| 278 | } | |
| 279 | ||
| 280 | // SARIF mode: buffer all records; write JSON once after the scan completes. | |
| 281 |
2
1. run : removed conditional - replaced equality check with true → KILLED 2. run : removed conditional - replaced equality check with false → KILLED |
if (cliConfig.outputMode() == OutputMode.SARIF) { |
| 282 |
1
1. run : replaced int return with 0 for org/egothor/methodatlas/MethodAtlasApp::run → SURVIVED |
return new SarifCommand(cliConfig, discoveryConfig, aiEngine, override, aiCache).execute(out); |
| 283 | } | |
| 284 | ||
| 285 | // GitHub Annotations mode: emit ::notice/::warning workflow commands. | |
| 286 |
2
1. run : removed conditional - replaced equality check with false → KILLED 2. run : removed conditional - replaced equality check with true → KILLED |
if (cliConfig.outputMode() == OutputMode.GITHUB_ANNOTATIONS) { |
| 287 |
1
1. run : replaced int return with 0 for org/egothor/methodatlas/MethodAtlasApp::run → SURVIVED |
return new GitHubAnnotationsCommand(cliConfig, discoveryConfig, aiEngine, override, aiCache).execute(out); |
| 288 | } | |
| 289 | ||
| 290 | // CSV / PLAIN mode: emit incrementally (default). | |
| 291 |
1
1. run : replaced int return with 0 for org/egothor/methodatlas/MethodAtlasApp::run → KILLED |
return new ScanCommand(cliConfig, discoveryConfig, aiEngine, override, aiCache).execute(out); |
| 292 | } | |
| 293 | } | |
Mutations | ||
| 205 |
1.1 |
|
| 210 |
1.1 2.2 |
|
| 211 |
1.1 |
|
| 240 |
1.1 2.2 3.3 |
|
| 241 |
1.1 2.2 |
|
| 242 |
1.1 2.2 3.3 4.4 |
|
| 246 |
1.1 2.2 3.3 |
|
| 258 |
1.1 2.2 |
|
| 259 |
1.1 |
|
| 264 |
1.1 2.2 |
|
| 271 |
1.1 2.2 |
|
| 272 |
1.1 |
|
| 276 |
1.1 2.2 |
|
| 277 |
1.1 |
|
| 281 |
1.1 2.2 |
|
| 282 |
1.1 |
|
| 286 |
1.1 2.2 |
|
| 287 |
1.1 |
|
| 291 |
1.1 |