MethodAtlasApp.java
package org.egothor.methodatlas;
import java.io.FilterOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Stream;
import org.egothor.methodatlas.ai.AiClassSuggestion;
import org.egothor.methodatlas.ai.AiOptions;
import org.egothor.methodatlas.ai.AiSuggestionEngine;
import org.egothor.methodatlas.ai.AiSuggestionEngineImpl;
import org.egothor.methodatlas.ai.AiSuggestionException;
import org.egothor.methodatlas.ai.ManualConsumeEngine;
import org.egothor.methodatlas.ai.ManualPrepareEngine;
import org.egothor.methodatlas.ai.PromptBuilder;
import org.egothor.methodatlas.ai.SuggestionLookup;
import com.github.javaparser.JavaParser;
import com.github.javaparser.ParseResult;
import com.github.javaparser.ParserConfiguration;
import com.github.javaparser.ParserConfiguration.LanguageLevel;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.nodeTypes.NodeWithName;
import com.github.javaparser.printer.lexicalpreservation.LexicalPreservingPrinter;
/**
* Command-line application for scanning Java test sources, extracting JUnit
* test metadata, and optionally enriching the emitted results with AI-generated
* security tagging suggestions.
*
* <p>
* The application traverses one or more directory roots, parses matching source
* files using JavaParser, identifies supported JUnit Jupiter test methods, and
* emits one output record per discovered test method. File selection matches
* source files whose names end with the configured suffix (default:
* {@code Test.java}).
* </p>
*
* <h2>Source-Derived Metadata</h2>
*
* <p>
* For each discovered test method, the application reports:
* </p>
* <ul>
* <li>fully qualified class name</li>
* <li>method name</li>
* <li>inclusive line count of the method declaration</li>
* <li>JUnit {@code @Tag} values declared on the method</li>
* </ul>
*
* <h2>AI Enrichment</h2>
*
* <p>
* When AI support is enabled, the application submits each discovered test
* class to an {@link org.egothor.methodatlas.ai.AiSuggestionEngine} and merges
* the returned method-level suggestions into the emitted output.
* </p>
*
* <h2>Manual AI Workflow</h2>
*
* <p>
* Operators who cannot access an AI API directly can use the two-phase manual
* workflow:
* </p>
* <ol>
* <li><b>Prepare phase</b> ({@code -manual-prepare}): the application scans
* test sources and writes one work file per class to the specified directory.
* Each work file contains operator instructions and the full AI prompt (with
* class source embedded). No CSV output is produced in this phase.</li>
* <li><b>Consume phase</b> ({@code -manual-consume}): the application reads
* operator-saved AI response files ({@code <stem>.response.txt}) from the
* response directory and produces the final enriched CSV. Classes whose
* response file is absent receive empty AI columns.</li>
* </ol>
*
* <h2>Supported Command-Line Options</h2>
*
* <ul>
* <li>{@code -config <path>} — loads default values from a YAML configuration
* file; command-line flags override YAML values</li>
* <li>{@code -plain} — emits plain text output instead of CSV</li>
* <li>{@code -sarif} — emits SARIF 2.1.0 JSON output</li>
* <li>{@code -ai} — enables AI-based enrichment</li>
* <li>{@code -ai-provider <provider>} — selects the AI provider</li>
* <li>{@code -ai-model <model>} — selects the provider-specific model</li>
* <li>{@code -ai-base-url <url>} — overrides the provider base URL</li>
* <li>{@code -ai-api-key <key>} — supplies the AI API key directly</li>
* <li>{@code -ai-api-key-env <name>} — resolves the AI API key from an
* environment variable</li>
* <li>{@code -ai-taxonomy <path>} — loads taxonomy text from an external
* file</li>
* <li>{@code -ai-taxonomy-mode <mode>} — selects the built-in taxonomy
* variant</li>
* <li>{@code -ai-max-class-chars <count>} — limits class source size submitted
* to AI</li>
* <li>{@code -ai-timeout-sec <seconds>} — sets the AI request timeout</li>
* <li>{@code -ai-max-retries <count>} — sets the retry limit for AI
* operations</li>
* <li>{@code -ai-confidence} — requests a confidence score for each AI
* security classification; adds an {@code ai_confidence} column to the
* output</li>
* <li>{@code -file-suffix <suffix>} — matches source files by name suffix
* (default: {@code Test.java}); may be repeated to match multiple patterns,
* e.g. {@code -file-suffix Test.java -file-suffix IT.java}; the first
* occurrence replaces the default</li>
* <li>{@code -test-annotation <name>} — recognises methods annotated with
* {@code name} as test methods; may be repeated; the first occurrence replaces
* the default set ({@link AnnotationInspector#DEFAULT_TEST_ANNOTATIONS})</li>
* <li>{@code -emit-metadata} — emits {@code # key: value} comment lines
* before the header row describing the tool version, scan timestamp, and
* taxonomy configuration</li>
* <li>{@code -apply-tags} — instead of emitting a report, writes
* AI-generated {@code @DisplayName} and {@code @Tag} annotations back to
* the scanned source files; requires AI enrichment to be enabled</li>
* <li>{@code -content-hash} — includes a SHA-256 fingerprint of each class
* source as a {@code content_hash} column in CSV/plain output and as a SARIF
* property; useful for detecting which classes changed between scans</li>
* <li>{@code -manual-prepare <workdir> <responsedir>} — runs the manual AI
* prepare phase, writing work files to {@code workdir} and empty response stubs
* to {@code responsedir}; the two paths may be identical</li>
* <li>{@code -manual-consume <workdir> <responsedir>} — runs the manual AI
* consume phase, reading response files from {@code responsedir} and emitting
* the final enriched CSV</li>
* </ul>
*
* <p>
* Any remaining non-option arguments are interpreted as root paths to scan. If
* no scan path is supplied, the current working directory is scanned.
* </p>
*
* <h2>Exit Codes</h2>
*
* <ul>
* <li>{@code 0} — all files processed successfully</li>
* <li>{@code 1} — one or more files could not be parsed or processed</li>
* </ul>
*
* @see org.egothor.methodatlas.ai.AiSuggestionEngine
* @see AnnotationInspector
* @see OutputEmitter
* @see SarifEmitter
* @see #main(String[])
*/
@SuppressWarnings("PMD.CyclomaticComplexity")
public final class MethodAtlasApp {
private static final Logger LOG = Logger.getLogger(MethodAtlasApp.class.getName());
/**
* Prevents instantiation of this utility class.
*/
private MethodAtlasApp() {
}
/**
* Program entry point.
*
* <p>
* Delegates all work to {@link #run(String[], PrintWriter)}. Exits with a
* non-zero status code if any source file could not be processed.
* </p>
*
* @param args command-line arguments
* @throws IOException if traversal of a configured file tree fails
* @throws IllegalArgumentException if an option is unknown, if a required
* option value is missing, or if an option
* value cannot be parsed
* @throws IllegalStateException if AI support is enabled but the AI engine
* cannot be created successfully
*/
public static void main(String[] args) throws IOException {
// Wrap System.out in a guarded stream whose close() only flushes.
// This lets try-with-resources manage the PrintWriter (satisfying
// SpotBugs CloseResource and PMD UseTryWithResources) without
// permanently closing System.out (satisfying Error Prone's
// ClosingStandardOutputStreams check).
OutputStream guarded = new FilterOutputStream(System.out) {
@Override
public void close() throws IOException {
flush(); // flush but do NOT close System.out
}
};
try (PrintWriter out = new PrintWriter(new OutputStreamWriter(guarded, StandardCharsets.UTF_8), true)) {
int exitCode = run(args, out);
if (exitCode != 0) {
System.exit(exitCode);
}
}
}
/**
* Executes a full application run, emitting all output to the supplied writer.
*
* <p>
* This method is the primary entry point for programmatic and test use. It
* parses arguments, initialises the parser and AI engine, emits headers, and
* scans the requested paths.
* </p>
*
* <p>
* When the manual prepare phase is active ({@code -manual-prepare}) this method
* writes work files and reports progress to {@code out} instead of emitting CSV.
* When the manual consume phase is active ({@code -manual-consume}) this method
* uses {@link ManualConsumeEngine} to read operator-saved responses and emits
* the standard enriched CSV.
* </p>
*
* @param args command-line arguments
* @param out writer that receives all emitted output
* @return {@code 0} if all files were processed successfully, {@code 1} if
* any file produced a parse or processing error
* @throws IOException if traversal of a configured file tree fails
* @throws IllegalArgumentException if an option is unknown, if a required
* option value is missing, or if an option
* value cannot be parsed
* @throws IllegalStateException if AI support is enabled but the AI engine
* cannot be created successfully
*/
/* default */ static int run(String[] args, PrintWriter out) throws IOException {
ParserConfiguration parserConfiguration = new ParserConfiguration();
parserConfiguration.setLanguageLevel(LanguageLevel.JAVA_21);
JavaParser parser = new JavaParser(parserConfiguration);
CliConfig cliConfig = CliArgs.parse(args);
// Manual prepare phase: write AI prompt work files; no CSV output.
if (cliConfig.manualMode() instanceof ManualMode.Prepare prepare) {
return runManualPrepare(prepare, cliConfig, parser, out);
}
// Determine AI engine: manual consume reads from files; normal mode calls APIs.
AiSuggestionEngine aiEngine;
if (cliConfig.manualMode() instanceof ManualMode.Consume consume) {
aiEngine = new ManualConsumeEngine(consume.responseDir());
} else {
aiEngine = buildAiEngine(cliConfig.aiOptions());
}
boolean aiEnabled = aiEngine != null;
boolean confidenceEnabled = aiEnabled && cliConfig.aiOptions().confidence();
boolean contentHashEnabled = cliConfig.contentHash();
List<Path> roots = cliConfig.paths().isEmpty() ? List.of(Paths.get(".")) : cliConfig.paths();
// Apply-tags mode: annotate source files; no report emitted.
if (cliConfig.applyTags()) {
return runApplyTags(cliConfig, aiEngine, parser, roots, out);
}
// SARIF mode: buffer all records; write JSON once after the scan completes.
if (cliConfig.outputMode() == OutputMode.SARIF) {
return runSarif(cliConfig, aiEngine, aiEnabled, confidenceEnabled, parser, roots, out);
}
// CSV / PLAIN mode: emit incrementally.
OutputEmitter emitter = new OutputEmitter(out, aiEnabled, confidenceEnabled, contentHashEnabled);
if (cliConfig.emitMetadata()) {
String version = MethodAtlasApp.class.getPackage().getImplementationVersion();
String taxonomyInfo = resolveTaxonomyInfo(cliConfig.aiOptions(), aiEnabled);
emitter.emitMetadata(version != null ? version : "dev", Instant.now().toString(), taxonomyInfo);
}
emitter.emitCsvHeader(cliConfig.outputMode());
final OutputMode mode = cliConfig.outputMode();
TestMethodSink sink = (fqcn, method, beginLine, loc, contentHash, tags, suggestion) ->
emitter.emit(mode, fqcn, method, loc, contentHash, tags, suggestion);
return scan(roots, cliConfig, aiEngine, parser, sink);
}
/**
* Applies AI-generated annotations to test method source files.
*
* <p>
* Scans every configured source root, resolves AI suggestions for each
* discovered test class, and uses {@link TagApplier} to insert
* {@code @DisplayName} and {@code @Tag} annotations. Each modified file is
* written back to disk using the lexical-preserving printer so that
* unrelated formatting is left intact. A summary line is written to
* {@code log} on completion.
* </p>
*
* @param cliConfig full parsed CLI configuration
* @param aiEngine AI engine providing suggestions; may be {@code null}
* @param parser configured JavaParser instance
* @param roots source roots to scan
* @param log writer for progress and summary output
* @return {@code 0} if all files were processed successfully, {@code 1}
* if any file produced a parse or processing error
* @throws IOException if traversing a file tree fails
*/
private static int runApplyTags(CliConfig cliConfig, AiSuggestionEngine aiEngine,
JavaParser parser, List<Path> roots, PrintWriter log) throws IOException {
boolean hadErrors = false;
int modifiedFiles = 0;
int totalAnnotations = 0;
for (Path root : roots) {
try (Stream<Path> stream = Files.walk(root)) {
List<Path> files = stream
.filter(path -> cliConfig.fileSuffixes().stream()
.anyMatch(s -> path.toString().endsWith(s)))
.toList();
for (Path path : files) {
try {
int added = applyTagsToFile(root, path, cliConfig, aiEngine, parser, log);
if (added > 0) {
modifiedFiles++;
totalAnnotations += added;
}
} catch (IOException e) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Cannot process: " + path, e);
}
hadErrors = true;
}
}
}
}
log.println("Apply-tags complete: " + totalAnnotations + " annotation(s) added to "
+ modifiedFiles + " file(s)");
return hadErrors ? 1 : 0;
}
/**
* Parses a single source file, applies AI-suggested security annotations,
* and writes the file back when at least one annotation was inserted.
*
* <p>
* {@link LexicalPreservingPrinter} is used so that only the inserted
* annotations affect the output; all other formatting is preserved.
* </p>
*
* @param root scan root used to compute the path-based file stem
* @param path source file to process
* @param cliConfig full parsed CLI configuration
* @param aiEngine AI engine providing suggestions; may be {@code null}
* @param parser configured JavaParser instance
* @param log writer for progress output
* @return number of annotations added to the file
* @throws IOException if the file cannot be read or written
*/
private static int applyTagsToFile(Path root, Path path, CliConfig cliConfig,
AiSuggestionEngine aiEngine, JavaParser parser, PrintWriter log) throws IOException {
ParseResult<CompilationUnit> parseResult = parser.parse(path);
if (!parseResult.isSuccessful() || parseResult.getResult().isEmpty()) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Failed to parse {0}: {1}",
new Object[] { path, parseResult.getProblems() });
}
return 0;
}
CompilationUnit cu = parseResult.getResult().orElseThrow();
LexicalPreservingPrinter.setup(cu);
String packageName = cu.getPackageDeclaration()
.map(NodeWithName::getNameAsString).orElse("");
int displayNamesAdded = 0;
int tagsAdded = 0;
for (ClassOrInterfaceDeclaration clazz : cu.findAll(ClassOrInterfaceDeclaration.class)) {
String fqcn = buildFqcn(packageName, clazz.getNameAsString());
String fileStem = buildFileStem(root, path, fqcn);
List<MethodDeclaration> testMethods = findJUnitTestMethods(clazz, cliConfig.testAnnotations());
SuggestionLookup suggestionLookup = resolveSuggestionLookup(fileStem, clazz, fqcn, testMethods,
cliConfig.aiOptions(), aiEngine);
TagApplier.ClassResult result = TagApplier.applyToClass(clazz, suggestionLookup,
cliConfig.testAnnotations());
displayNamesAdded += result.displayNamesAdded();
tagsAdded += result.tagsAdded();
}
int totalAdded = displayNamesAdded + tagsAdded;
if (totalAdded > 0) {
if (displayNamesAdded > 0) {
cu.addImport(TagApplier.IMPORT_DISPLAY_NAME);
}
if (tagsAdded > 0) {
cu.addImport(TagApplier.IMPORT_TAG);
}
Files.writeString(path, LexicalPreservingPrinter.print(cu), StandardCharsets.UTF_8);
log.println("Modified: " + path + " (+" + totalAdded + " annotation(s))");
}
return totalAdded;
}
/**
* Runs the SARIF output path: scans all roots, then serializes the buffered
* records as a single SARIF document.
*
* @param cliConfig full parsed CLI configuration
* @param aiEngine AI engine providing suggestions; may be {@code null}
* @param aiEnabled whether an AI engine is active
* @param confidenceEnabled whether the {@code aiConfidence} property should be
* included in SARIF properties
* @param parser configured JavaParser instance
* @param roots source roots to scan
* @param out writer that receives the serialized SARIF document
* @return {@code 0} if all files were processed successfully, {@code 1} if any
* file produced a parse or processing error
* @throws IOException if traversing a file tree fails
*/
private static int runSarif(CliConfig cliConfig, AiSuggestionEngine aiEngine,
boolean aiEnabled, boolean confidenceEnabled,
JavaParser parser, List<Path> roots, PrintWriter out) throws IOException {
SarifEmitter sarifEmitter = new SarifEmitter(aiEnabled, confidenceEnabled);
int result = scan(roots, cliConfig, aiEngine, parser, sarifEmitter);
sarifEmitter.flush(out);
return result;
}
/**
* Scans all roots and forwards each discovered test method to {@code sink}.
*
* @param roots source roots to scan
* @param cliConfig full parsed CLI configuration
* @param aiEngine AI engine providing suggestions; may be {@code null}
* @param parser configured JavaParser instance
* @param sink receiver of discovered test method records
* @return {@code 0} if all files were processed successfully, {@code 1} if any
* file produced a parse or processing error
* @throws IOException if traversing a file tree fails
*/
private static int scan(List<Path> roots, CliConfig cliConfig, AiSuggestionEngine aiEngine,
JavaParser parser, TestMethodSink sink) throws IOException {
boolean hadErrors = false;
for (Path root : roots) {
if (scanRoot(root, cliConfig.aiOptions(), aiEngine, parser, sink,
cliConfig.fileSuffixes(), cliConfig.testAnnotations(), cliConfig.contentHash())) {
hadErrors = true;
}
}
return hadErrors ? 1 : 0;
}
/**
* Executes the manual AI prepare phase.
*
* <p>
* Scans the configured source roots, discovers test classes and their JUnit
* test methods, and writes one work file per class to the prepare work
* directory. Progress lines are written to {@code log}. No CSV output is
* produced.
* </p>
*
* @param prepare manual prepare mode configuration
* @param cliConfig full parsed CLI configuration (used for paths, suffix,
* and taxonomy options)
* @param parser configured JavaParser instance
* @param log writer used for progress reporting
* @return {@code 0} if all files were processed successfully, {@code 1} if any
* file produced a parse or processing error
* @throws IOException if traversing a file tree fails
*/
private static int runManualPrepare(ManualMode.Prepare prepare, CliConfig cliConfig, JavaParser parser,
PrintWriter log) throws IOException {
ManualPrepareEngine engine;
try {
engine = new ManualPrepareEngine(prepare.workDir(), prepare.responseDir(), cliConfig.aiOptions());
} catch (AiSuggestionException e) {
throw new IllegalStateException("Failed to initialize manual prepare engine", e);
}
List<Path> roots = cliConfig.paths().isEmpty() ? List.of(Paths.get(".")) : cliConfig.paths();
boolean hadErrors = false;
int prepared = 0;
for (Path root : roots) {
try (Stream<Path> stream = Files.walk(root)) {
List<Path> files = stream
.filter(path -> cliConfig.fileSuffixes().stream()
.anyMatch(s -> path.toString().endsWith(s)))
.toList();
for (Path path : files) {
int count = processFileForPrepare(root, path, engine, parser, log, cliConfig.testAnnotations());
if (count < 0) {
hadErrors = true;
} else {
prepared += count;
}
}
}
}
log.println("Manual prepare complete. Wrote " + prepared + " work file(s) to " + prepare.workDir()
+ " (response stubs in " + prepare.responseDir() + ")");
return hadErrors ? 1 : 0;
}
/**
* Parses a single Java source file and writes work files for each discovered
* test class.
*
* @param root scan root used to compute the path-based file stem
* @param path source file to parse
* @param engine prepare engine used to write work files
* @param parser configured JavaParser instance
* @param log writer used for progress reporting
* @param testAnnotations set of annotation simple names that identify test
* methods
* @return number of work files written, or {@code -1} if the file could not
* be parsed
*/
private static int processFileForPrepare(Path root, Path path, ManualPrepareEngine engine, JavaParser parser,
PrintWriter log, Set<String> testAnnotations) {
try {
ParseResult<CompilationUnit> parseResult = parser.parse(path);
if (!parseResult.isSuccessful() || parseResult.getResult().isEmpty()) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Failed to parse {0}: {1}",
new Object[] { path, parseResult.getProblems() });
}
return -1;
}
CompilationUnit compilationUnit = parseResult.getResult().orElseThrow();
String packageName = compilationUnit.getPackageDeclaration()
.map(NodeWithName::getNameAsString).orElse("");
int count = 0;
for (ClassOrInterfaceDeclaration clazz : compilationUnit
.findAll(ClassOrInterfaceDeclaration.class)) {
String fqcn = buildFqcn(packageName, clazz.getNameAsString());
List<MethodDeclaration> testMethods = findJUnitTestMethods(clazz, testAnnotations);
if (testMethods.isEmpty()) {
continue;
}
String fileStem = buildFileStem(root, path, fqcn);
List<PromptBuilder.TargetMethod> targetMethods = toTargetMethods(testMethods);
try {
Path workFile = engine.prepare(fileStem, fqcn, clazz.toString(), targetMethods);
log.println("Prepared: " + workFile);
count++;
} catch (AiSuggestionException e) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Failed to prepare work file for " + fqcn, e);
}
}
}
return count;
} catch (IOException e) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Cannot read file: " + path, e);
}
return -1;
}
}
/**
* Recursively scans a directory tree for Java test source files.
*
* @param root root directory to scan
* @param aiOptions AI configuration for the current run
* @param aiEngine AI engine, or {@code null} when AI is disabled
* @param parser configured JavaParser instance
* @param sink receiver of discovered test method records
* @param fileSuffixes one or more filename suffixes used to select source
* files; a file is included if its name ends with any of
* the listed suffixes
* @param testAnnotations set of annotation simple names that identify test
* methods
* @return {@code true} if any file produced a processing error
* @throws IOException if traversing the file tree fails
*/
private static boolean scanRoot(Path root, AiOptions aiOptions, AiSuggestionEngine aiEngine,
JavaParser parser, TestMethodSink sink, List<String> fileSuffixes,
Set<String> testAnnotations, boolean contentHashEnabled) throws IOException {
if (LOG.isLoggable(Level.INFO)) {
LOG.log(Level.INFO, "Scanning {0} for files matching {1}", new Object[] { root, fileSuffixes });
}
boolean hadErrors = false;
try (Stream<Path> stream = Files.walk(root)) {
List<Path> files = stream
.filter(path -> fileSuffixes.stream().anyMatch(s -> path.toString().endsWith(s)))
.toList();
for (Path path : files) {
if (!processFile(root, path, aiOptions, aiEngine, parser, sink, testAnnotations,
contentHashEnabled)) {
hadErrors = true;
}
}
}
return hadErrors;
}
/**
* Parses a single Java source file, discovers JUnit test methods, and forwards
* each to the supplied sink.
*
* @param root scan root used to compute the path-based file stem
* @param path source file to parse
* @param aiOptions AI configuration for the current run
* @param aiEngine AI engine, or {@code null} when AI is disabled
* @param parser configured JavaParser instance
* @param sink receiver of discovered test method records
* @param testAnnotations set of annotation simple names that identify test
* methods
* @param contentHashEnabled whether to compute a SHA-256 fingerprint of each
* class source and include it in the records
* @return {@code true} if the file was processed successfully
*/
private static boolean processFile(Path root, Path path, AiOptions aiOptions,
AiSuggestionEngine aiEngine, JavaParser parser, TestMethodSink sink,
Set<String> testAnnotations, boolean contentHashEnabled) {
try {
ParseResult<CompilationUnit> parseResult = parser.parse(path);
if (!parseResult.isSuccessful() || parseResult.getResult().isEmpty()) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Failed to parse {0}: {1}",
new Object[] { path, parseResult.getProblems() });
}
return false;
}
CompilationUnit compilationUnit = parseResult.getResult().orElseThrow();
String packageName = compilationUnit.getPackageDeclaration()
.map(NodeWithName::getNameAsString).orElse("");
compilationUnit.findAll(ClassOrInterfaceDeclaration.class).forEach(clazz -> {
String fqcn = buildFqcn(packageName, clazz.getNameAsString());
String fileStem = buildFileStem(root, path, fqcn);
String contentHash = contentHashEnabled ? computeContentHash(clazz) : null;
List<MethodDeclaration> testMethods = findJUnitTestMethods(clazz, testAnnotations);
SuggestionLookup suggestionLookup = resolveSuggestionLookup(fileStem, clazz, fqcn, testMethods,
aiOptions, aiEngine);
for (MethodDeclaration method : testMethods) {
int beginLine = method.getRange().map(range -> range.begin.line).orElse(0);
int loc = AnnotationInspector.countLOC(method);
List<String> tags = AnnotationInspector.getTagValues(method);
sink.record(fqcn, method.getNameAsString(), beginLine, loc, contentHash, tags,
suggestionLookup.find(method.getNameAsString()).orElse(null));
}
});
return true;
} catch (IOException e) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Cannot read file: " + path, e);
}
return false;
}
}
/**
* Builds a fully qualified class name from a package name and a simple class
* name.
*
* @param packageName package name; may be empty for the default package
* @param className simple class name
* @return fully qualified class name
*/
private static String buildFqcn(String packageName, String className) {
return packageName.isEmpty() ? className : packageName + "." + className;
}
/**
* Computes the dot-separated file stem used to name work and response files
* in the manual AI workflow.
*
* <p>
* The stem is derived from the source file's path relative to the scan root,
* with path separators replaced by {@code .} and the {@code .java} extension
* removed. This makes each stem unique within a scan root, and when scanning
* from a project root that contains multiple modules the module directory name
* is naturally included in the stem (e.g.
* {@code module-a.src.test.java.com.acme.FooTest}).
* </p>
*
* <p>
* When a file contains inner classes whose simple name differs from the file
* name, the FQCN's simple name is appended so that each class in the file
* receives a distinct stem (e.g. {@code com.acme.FooTest.BarTest}).
* </p>
*
* @param root scan root directory
* @param file source file being processed
* @param fqcn fully qualified class name of the class in {@code file}
* @return dot-separated file stem; never {@code null}
*/
/* default */ static String buildFileStem(Path root, Path file, String fqcn) {
Path rel = root.toAbsolutePath().normalize()
.relativize(file.toAbsolutePath().normalize());
String pathStr = rel.toString().replace('\\', '/').replace('/', '.');
if (pathStr.endsWith(".java")) {
pathStr = pathStr.substring(0, pathStr.length() - 5);
}
// For inner classes the file name encodes the outer class but the FQCN ends
// with the inner class name; append it to keep stems distinct per class.
String pathLastPart = pathStr.contains(".")
? pathStr.substring(pathStr.lastIndexOf('.') + 1) : pathStr;
String fqcnLastPart = fqcn.contains(".")
? fqcn.substring(fqcn.lastIndexOf('.') + 1) : fqcn;
if (!pathLastPart.equals(fqcnLastPart)) {
return pathStr + "." + fqcnLastPart;
}
return pathStr;
}
/**
* Computes a SHA-256 content fingerprint of a class declaration.
*
* <p>
* The hash is derived from the JavaParser pretty-printed form of the class
* declaration, which normalizes whitespace so that insignificant formatting
* changes do not alter the fingerprint. The result is a 64-character
* lowercase hexadecimal string.
* </p>
*
* @param clazz parsed class declaration to fingerprint
* @return 64-character lowercase hex SHA-256 digest
* @throws IllegalStateException if SHA-256 is unavailable (never in practice;
* SHA-256 is mandated by the Java SE spec)
*/
private static String computeContentHash(ClassOrInterfaceDeclaration clazz) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest(clazz.toString().getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(bytes);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
/**
* Returns all JUnit test methods declared within the specified class.
*
* @param clazz parsed class declaration whose methods should be inspected
* @return list of JUnit test method declarations; possibly empty but never
* {@code null}
* @param testAnnotations set of annotation simple names to match
* @see AnnotationInspector#isJUnitTest(MethodDeclaration, Set)
*/
private static List<MethodDeclaration> findJUnitTestMethods(ClassOrInterfaceDeclaration clazz,
Set<String> testAnnotations) {
return clazz.findAll(MethodDeclaration.class).stream()
.filter(m -> AnnotationInspector.isJUnitTest(m, testAnnotations)).toList();
}
/**
* Resolves method-level AI suggestions for a parsed class.
*
* <p>
* Returns an empty lookup when no AI engine is available, the method list is
* empty, or (for regular provider-based AI) the class source exceeds the
* configured maximum size. The {@code maxClassChars} limit is only enforced
* when the automated provider is enabled ({@link AiOptions#enabled()}); it is
* not applied in the manual consume phase.
* </p>
*
* @param fileStem dot-separated path stem identifying the source file;
* forwarded to {@link AiSuggestionEngine#suggestForClass}
* @param clazz parsed class declaration to analyze
* @param fqcn fully qualified class name of {@code clazz}
* @param testMethods discovered JUnit test methods
* @param aiOptions AI configuration for the current run
* @param aiEngine AI engine used to produce suggestions; {@code null} when
* AI is disabled
* @return lookup of AI suggestions keyed by method name; never {@code null}
*/
private static SuggestionLookup resolveSuggestionLookup(String fileStem, ClassOrInterfaceDeclaration clazz,
String fqcn, List<MethodDeclaration> testMethods, AiOptions aiOptions, AiSuggestionEngine aiEngine) {
if (aiEngine == null || testMethods.isEmpty()) {
return SuggestionLookup.from(null);
}
String classSource = clazz.toString();
if (aiOptions.enabled() && classSource.length() > aiOptions.maxClassChars()) {
if (LOG.isLoggable(Level.INFO)) {
LOG.log(Level.INFO, "Skipping AI for {0}: class source too large ({1} chars)",
new Object[] { fqcn, classSource.length() });
}
return SuggestionLookup.from(null);
}
List<PromptBuilder.TargetMethod> targetMethods = toTargetMethods(testMethods);
try {
AiClassSuggestion aiClassSuggestion = aiEngine.suggestForClass(fileStem, fqcn, classSource, targetMethods);
return SuggestionLookup.from(aiClassSuggestion);
} catch (AiSuggestionException e) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "AI suggestion failed for class " + fqcn, e);
}
return SuggestionLookup.from(null);
}
}
/**
* Converts parsed JUnit test method declarations into prompt target descriptors.
*
* @param testMethods list of parsed JUnit test method declarations
* @return list of prompt target descriptors; possibly empty but never
* {@code null}
* @see PromptBuilder.TargetMethod
*/
private static List<PromptBuilder.TargetMethod> toTargetMethods(List<MethodDeclaration> testMethods) {
return testMethods.stream()
.map(method -> new PromptBuilder.TargetMethod(method.getNameAsString(),
method.getRange().map(range -> range.begin.line).orElse(null),
method.getRange().map(range -> range.end.line).orElse(null)))
.toList();
}
/**
* Produces a human-readable string identifying which taxonomy configuration
* is in effect, for use in scan metadata output.
*
* @param aiOptions AI configuration for the current run
* @param aiActive whether an AI engine is active for this run
* @return taxonomy descriptor string; never {@code null}
*/
private static String resolveTaxonomyInfo(AiOptions aiOptions, boolean aiActive) {
if (!aiActive) {
return "n/a (AI disabled)";
}
if (aiOptions.taxonomyFile() != null) {
return "file:" + aiOptions.taxonomyFile().toAbsolutePath();
}
return "built-in/" + aiOptions.taxonomyMode().name().toLowerCase(Locale.ROOT);
}
/**
* Creates the AI suggestion engine for the current run.
*
* <p>
* Returns {@code null} when AI support is disabled. Initialization failures
* are wrapped in an {@link IllegalStateException}.
* </p>
*
* @param aiOptions AI configuration for the current run
* @return initialized AI suggestion engine, or {@code null} when AI is
* disabled
* @throws IllegalStateException if engine initialization fails
*/
private static AiSuggestionEngine buildAiEngine(AiOptions aiOptions) {
if (!aiOptions.enabled()) {
return null;
}
try {
return new AiSuggestionEngineImpl(aiOptions);
} catch (AiSuggestionException e) {
throw new IllegalStateException("Failed to initialize AI engine", e);
}
}
}