feat: add AI-based security suggestion engine and CLI integration

Introduce new package org.egothor.methodatlas.ai providing AI-assisted
classification of JUnit tests for security relevance.

Key changes:
- add AI suggestion engine, provider abstraction, and provider clients
  (OpenAI-compatible, Ollama, Anthropic)
- implement strict JSON prompt/response contract and taxonomy handling
- integrate AI enrichment into MethodAtlas CLI output (CSV and plain
  modes)
- add configuration via AiOptions and CLI flags
- add comprehensive JUnit + Mockito test coverage for AI components
  and CLI integration scenarios
- add realistic test fixtures for security-related test classes
- update Gradle configuration for Mockito agent support on JDK 21+
- provide complete Javadoc for the AI module

The AI layer is optional and degrades gracefully when providers
are unavailable or responses fail.
This commit is contained in:
2026-03-08 23:44:55 +01:00
parent d3a8270d8a
commit bbb6adb7e5
36 changed files with 6037 additions and 283 deletions

396
MethodAtlasApp.java Normal file
View File

@@ -0,0 +1,396 @@
package org.egothor.methodatlas;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Stream;
import com.github.javaparser.ParserConfiguration;
import com.github.javaparser.ParserConfiguration.LanguageLevel;
import com.github.javaparser.StaticJavaParser;
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.expr.AnnotationExpr;
import com.github.javaparser.ast.expr.ArrayInitializerExpr;
import com.github.javaparser.ast.expr.Expression;
import com.github.javaparser.ast.expr.MemberValuePair;
import org.egothor.methodatlas.ai.AiClassSuggestion;
import org.egothor.methodatlas.ai.AiMethodSuggestion;
import org.egothor.methodatlas.ai.AiOptions;
import org.egothor.methodatlas.ai.AiProvider;
import org.egothor.methodatlas.ai.AiSuggestionEngine;
import org.egothor.methodatlas.ai.AiSuggestionEngineImpl;
import org.egothor.methodatlas.ai.AiSuggestionException;
import org.egothor.methodatlas.ai.SuggestionLookup;
public class MethodAtlasApp {
private static final Logger LOG = Logger.getLogger(MethodAtlasApp.class.getName());
private enum OutputMode {
CSV,
PLAIN
}
private record CliConfig(
OutputMode outputMode,
AiOptions aiOptions,
List<Path> paths
) {
}
public static void main(String[] args) throws IOException {
ParserConfiguration parserConfiguration = new ParserConfiguration();
parserConfiguration.setLanguageLevel(LanguageLevel.JAVA_21);
StaticJavaParser.setConfiguration(parserConfiguration);
CliConfig cliConfig = parseArgs(args);
AiSuggestionEngine aiEngine = buildAiEngine(cliConfig.aiOptions());
if (cliConfig.outputMode() == OutputMode.CSV) {
if (cliConfig.aiOptions().enabled()) {
System.out.println("fqcn,method,loc,tags,ai_security_relevant,ai_display_name,ai_tags,ai_reason");
} else {
System.out.println("fqcn,method,loc,tags");
}
}
if (cliConfig.paths().isEmpty()) {
scanRoot(Paths.get("."), cliConfig.outputMode(), cliConfig.aiOptions(), aiEngine);
return;
}
for (Path path : cliConfig.paths()) {
scanRoot(path, cliConfig.outputMode(), cliConfig.aiOptions(), aiEngine);
}
}
private static void scanRoot(
Path root,
OutputMode mode,
AiOptions aiOptions,
AiSuggestionEngine aiEngine
) throws IOException {
LOG.log(Level.INFO, "Scanning {0} for JUnit files", root);
try (Stream<Path> stream = Files.walk(root)) {
stream.filter(path -> path.toString().endsWith("Test.java"))
.forEach(path -> processFile(path, mode, aiOptions, aiEngine));
}
}
private static void processFile(
Path path,
OutputMode mode,
AiOptions aiOptions,
AiSuggestionEngine aiEngine
) {
try {
CompilationUnit compilationUnit = StaticJavaParser.parse(path);
String packageName = compilationUnit.getPackageDeclaration()
.map(packageDeclaration -> packageDeclaration.getNameAsString())
.orElse("");
compilationUnit.findAll(ClassOrInterfaceDeclaration.class).forEach(clazz -> {
String className = clazz.getNameAsString();
String fqcn = packageName.isEmpty() ? className : packageName + "." + className;
SuggestionLookup suggestionLookup = resolveSuggestionLookup(clazz, fqcn, aiOptions, aiEngine);
clazz.findAll(MethodDeclaration.class).forEach(method -> {
if (!isJUnitTest(method)) {
return;
}
int loc = countLOC(method);
List<String> tags = getTagValues(method);
AiMethodSuggestion suggestion = suggestionLookup.find(method.getNameAsString()).orElse(null);
emit(
mode,
aiOptions.enabled(),
fqcn,
method.getNameAsString(),
loc,
tags,
suggestion
);
});
});
} catch (Exception e) {
LOG.log(Level.WARNING, "Failed to parse: {0}", path);
e.printStackTrace();
}
}
private static SuggestionLookup resolveSuggestionLookup(
ClassOrInterfaceDeclaration clazz,
String fqcn,
AiOptions aiOptions,
AiSuggestionEngine aiEngine
) {
if (!aiOptions.enabled() || aiEngine == null) {
return SuggestionLookup.from(null);
}
String classSource = clazz.toString();
if (classSource.length() > aiOptions.maxClassChars()) {
LOG.log(
Level.INFO,
"Skipping AI for {0}: class source too large ({1} chars)",
new Object[] { fqcn, classSource.length() }
);
return SuggestionLookup.from(null);
}
try {
AiClassSuggestion aiClassSuggestion = aiEngine.suggestForClass(fqcn, classSource);
return SuggestionLookup.from(aiClassSuggestion);
} catch (AiSuggestionException e) {
LOG.log(Level.WARNING, "AI suggestion failed for class " + fqcn, e);
return SuggestionLookup.from(null);
}
}
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);
}
}
private static void emit(
OutputMode mode,
boolean aiEnabled,
String fqcn,
String method,
int loc,
List<String> tags,
AiMethodSuggestion suggestion
) {
if (mode == OutputMode.PLAIN) {
emitPlain(aiEnabled, fqcn, method, loc, tags, suggestion);
return;
}
emitCsv(aiEnabled, fqcn, method, loc, tags, suggestion);
}
private static void emitPlain(
boolean aiEnabled,
String fqcn,
String method,
int loc,
List<String> tags,
AiMethodSuggestion suggestion
) {
String existingTags = tags.isEmpty() ? "-" : String.join(";", tags);
if (!aiEnabled) {
System.out.println(fqcn + ", " + method + ", LOC=" + loc + ", TAGS=" + existingTags);
return;
}
String aiSecurity = suggestion == null ? "-" : Boolean.toString(suggestion.securityRelevant());
String aiDisplayName = suggestion == null || suggestion.displayName() == null ? "-" : suggestion.displayName();
String aiTags = suggestion == null || suggestion.tags() == null || suggestion.tags().isEmpty()
? "-"
: String.join(";", suggestion.tags());
String aiReason = suggestion == null || suggestion.reason() == null || suggestion.reason().isBlank()
? "-"
: suggestion.reason();
System.out.println(
fqcn + ", " + method
+ ", LOC=" + loc
+ ", TAGS=" + existingTags
+ ", AI_SECURITY=" + aiSecurity
+ ", AI_DISPLAY=" + aiDisplayName
+ ", AI_TAGS=" + aiTags
+ ", AI_REASON=" + aiReason
);
}
private static void emitCsv(
boolean aiEnabled,
String fqcn,
String method,
int loc,
List<String> tags,
AiMethodSuggestion suggestion
) {
String existingTags = tags.isEmpty() ? "" : String.join(";", tags);
if (!aiEnabled) {
System.out.println(
csvEscape(fqcn) + ","
+ csvEscape(method) + ","
+ loc + ","
+ csvEscape(existingTags)
);
return;
}
String aiSecurity = suggestion == null ? "" : Boolean.toString(suggestion.securityRelevant());
String aiDisplayName = suggestion == null || suggestion.displayName() == null ? "" : suggestion.displayName();
String aiTags = suggestion == null || suggestion.tags() == null ? "" : String.join(";", suggestion.tags());
String aiReason = suggestion == null || suggestion.reason() == null ? "" : suggestion.reason();
System.out.println(
csvEscape(fqcn) + ","
+ csvEscape(method) + ","
+ loc + ","
+ csvEscape(existingTags) + ","
+ csvEscape(aiSecurity) + ","
+ csvEscape(aiDisplayName) + ","
+ csvEscape(aiTags) + ","
+ csvEscape(aiReason)
);
}
private static String csvEscape(String value) {
if (value == null) {
return "";
}
boolean mustQuote =
value.indexOf(',') >= 0
|| value.indexOf('"') >= 0
|| value.indexOf('\n') >= 0
|| value.indexOf('\r') >= 0;
if (!mustQuote) {
return value;
}
return "\"" + value.replace("\"", "\"\"") + "\"";
}
private static CliConfig parseArgs(String[] args) {
OutputMode outputMode = OutputMode.CSV;
List<Path> paths = new ArrayList<>();
AiOptions.Builder aiBuilder = AiOptions.builder();
for (int i = 0; i < args.length; i++) {
String arg = args[i];
switch (arg) {
case "-plain" -> outputMode = OutputMode.PLAIN;
case "-ai" -> aiBuilder.enabled(true);
case "-ai-provider" -> aiBuilder.provider(
AiProvider.valueOf(nextArg(args, ++i, arg).toUpperCase())
);
case "-ai-model" -> aiBuilder.modelName(nextArg(args, ++i, arg));
case "-ai-base-url" -> aiBuilder.baseUrl(nextArg(args, ++i, arg));
case "-ai-api-key" -> aiBuilder.apiKey(nextArg(args, ++i, arg));
case "-ai-api-key-env" -> aiBuilder.apiKeyEnv(nextArg(args, ++i, arg));
case "-ai-taxonomy" -> aiBuilder.taxonomyFile(Paths.get(nextArg(args, ++i, arg)));
case "-ai-taxonomy-mode" -> aiBuilder.taxonomyMode(
AiOptions.TaxonomyMode.valueOf(nextArg(args, ++i, arg).toUpperCase())
);
case "-ai-max-class-chars" -> aiBuilder.maxClassChars(
Integer.parseInt(nextArg(args, ++i, arg))
);
case "-ai-timeout-sec" -> aiBuilder.timeout(
Duration.ofSeconds(Long.parseLong(nextArg(args, ++i, arg)))
);
case "-ai-max-retries" -> aiBuilder.maxRetries(
Integer.parseInt(nextArg(args, ++i, arg))
);
default -> {
if (arg.startsWith("-")) {
throw new IllegalArgumentException("Unknown argument: " + arg);
}
paths.add(Paths.get(arg));
}
}
}
return new CliConfig(outputMode, aiBuilder.build(), paths);
}
private static String nextArg(String[] args, int index, String option) {
if (index >= args.length) {
throw new IllegalArgumentException("Missing value for " + option);
}
return args[index];
}
private static boolean isJUnitTest(MethodDeclaration method) {
for (AnnotationExpr annotation : method.getAnnotations()) {
String name = annotation.getNameAsString();
if ("Test".equals(name) || "ParameterizedTest".equals(name) || "RepeatedTest".equals(name)) {
return true;
}
}
return false;
}
private static int countLOC(MethodDeclaration method) {
return method.getRange()
.map(range -> range.end.line - range.begin.line + 1)
.orElse(0);
}
private static List<String> getTagValues(MethodDeclaration method) {
List<String> tagValues = new ArrayList<>();
for (AnnotationExpr annotation : method.getAnnotations()) {
String name = annotation.getNameAsString();
if ("Tag".equals(name)) {
extractTagValue(annotation).ifPresent(tagValues::add);
} else if ("Tags".equals(name) && annotation.isNormalAnnotationExpr()) {
for (MemberValuePair pair : annotation.asNormalAnnotationExpr().getPairs()) {
if ("value".equals(pair.getNameAsString()) && pair.getValue().isArrayInitializerExpr()) {
ArrayInitializerExpr array = pair.getValue().asArrayInitializerExpr();
for (Expression expression : array.getValues()) {
if (expression.isAnnotationExpr()) {
extractTagValue(expression.asAnnotationExpr()).ifPresent(tagValues::add);
}
}
}
}
}
}
return tagValues;
}
private static Optional<String> extractTagValue(AnnotationExpr annotation) {
if (!"Tag".equals(annotation.getNameAsString())) {
return Optional.empty();
}
if (annotation.isSingleMemberAnnotationExpr()) {
Expression memberValue = annotation.asSingleMemberAnnotationExpr().getMemberValue();
if (memberValue.isStringLiteralExpr()) {
return Optional.of(memberValue.asStringLiteralExpr().asString());
}
}
if (annotation.isNormalAnnotationExpr()) {
for (MemberValuePair pair : annotation.asNormalAnnotationExpr().getPairs()) {
if ("value".equals(pair.getNameAsString()) && pair.getValue().isStringLiteralExpr()) {
return Optional.of(pair.getValue().asStringLiteralExpr().asString());
}
}
}
return Optional.empty();
}
}

View File

@@ -8,6 +8,10 @@ plugins {
group = 'org.egothor.methodatlas'
version = gitVersion(prefix:'release@')
configurations {
mockitoAgent
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
@@ -20,10 +24,27 @@ repositories {
dependencies {
implementation 'com.github.javaparser:javaparser-core:3.28.0'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.21.1'
testImplementation(platform("org.junit:junit-bom:5.14.2"))
testImplementation 'org.junit.jupiter:junit-jupiter'
testImplementation 'org.mockito:mockito-core:5.22.0'
testImplementation 'org.mockito:mockito-junit-jupiter:5.22.0'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
mockitoAgent('org.mockito:mockito-core:5.22.0') {
transitive = false
}
}
tasks.withType(Test).configureEach {
useJUnitPlatform()
jvmArgs "-javaagent:${configurations.mockitoAgent.singleFile}"
doFirst {
println "Mockito agent: ${configurations.mockitoAgent.singleFile}"
println "JVM args: ${jvmArgs}"
}
}
application {

View File

@@ -4,11 +4,22 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
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.AiMethodSuggestion;
import org.egothor.methodatlas.ai.AiOptions;
import org.egothor.methodatlas.ai.AiProvider;
import org.egothor.methodatlas.ai.AiSuggestionEngine;
import org.egothor.methodatlas.ai.AiSuggestionEngineImpl;
import org.egothor.methodatlas.ai.AiSuggestionException;
import org.egothor.methodatlas.ai.SuggestionLookup;
import com.github.javaparser.ParserConfiguration;
import com.github.javaparser.ParserConfiguration.LanguageLevel;
@@ -22,170 +33,289 @@ import com.github.javaparser.ast.expr.Expression;
import com.github.javaparser.ast.expr.MemberValuePair;
/**
* Command-line utility that scans Java source trees for JUnit test methods and
* reports per-method statistics.
* 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 tool walks one or more root directories, parses {@code *Test.java} files
* using JavaParser, and emits a record for each method annotated with one of
* the supported JUnit Jupiter test annotations.
* 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. The current file
* selection strategy includes only source files whose names end with
* {@code Test.java}.
* </p>
*
* <h2>Detection</h2>
* <h2>Source-Derived Metadata</h2>
*
* <p>
* For each discovered test method, the application reports source-derived
* metadata including:
* </p>
* <ul>
* <li>Test methods are detected by annotations {@code @Test},
* {@code @ParameterizedTest}, and {@code @RepeatedTest} (simple name
* match).</li>
* <li>{@code @Tag} values are collected from repeated {@code @Tag("...")}
* annotations and from {@code @Tags({ @Tag("..."), ... })} containers.</li>
* <li>Lines of code (LOC) is computed from the AST source range:
* {@code endLine - beginLine + 1}. If the range is unavailable, LOC is
* {@code 0}.</li>
* <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>Output</h2>
* <p>
* By default, the tool prints CSV with a header line:
* {@code fqcn,method,loc,tags}. The {@code tags} field is a semicolon-separated
* list.
* </p>
* <h2>AI Enrichment</h2>
*
* <p>
* If {@code -plain} is provided as the first argument, the tool prints a plain
* text format:
* 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. Depending on
* the configured provider and taxonomy, these suggestions may include:
* </p>
* <pre>
* fqcn, method, LOC=&lt;n&gt;, TAGS=&lt;tag1;tag2&gt;
* </pre>
* <ul>
* <li>whether a test method is considered security-relevant</li>
* <li>a suggested security-oriented display name</li>
* <li>taxonomy-based security tags</li>
* <li>an explanatory rationale</li>
* </ul>
*
* <h2>Supported Command-Line Options</h2>
*
* <p>
* If no tags are present, {@code TAGS=-} is printed.
* The application recognizes the following principal command-line options:
* </p>
* <ul>
* <li>{@code -plain} — emits plain text output instead of CSV</li>
* <li>{@code -ai} — enables AI-based enrichment of emitted method records</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>
* </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>Examples</h2> <pre>
* java -jar methodatlas.jar /path/to/repo
* java -jar methodatlas.jar -plain /path/to/repo /another/repo
* </pre>
* <h2>Output Modes</h2>
*
* <p>
* The application supports two output modes:
* </p>
* <ul>
* <li><b>CSV</b> (default)</li>
* <li><b>Plain text</b>, enabled by {@code -plain}</li>
* </ul>
*
* <p>
* In CSV mode, the emitted header is:
* </p>
* <pre>{@code
* fqcn,method,loc,tags
* }</pre>
*
* <p>
* When AI support is enabled, the emitted CSV header becomes:
* </p>
* <pre>{@code
* fqcn,method,loc,tags,ai_security_relevant,ai_display_name,ai_tags,ai_reason
* }</pre>
*
* <h2>Typical Usage</h2>
*
* <pre>{@code
* java -jar methodatlas.jar /path/to/project
* }</pre>
*
* <pre>{@code
* java -jar methodatlas.jar -plain /path/to/project
* }</pre>
*
* <pre>{@code
* java -jar methodatlas.jar -ai -ai-provider ollama -ai-model qwen2.5-coder:7b /path/to/project
* }</pre>
*
* @see com.github.javaparser.StaticJavaParser
* @see org.egothor.methodatlas.ai.AiSuggestionEngine
* @see #main(String[])
*/
public class MethodAtlasApp {
/**
* Logging facility for scan progress and parse failures.
*/
private static final Logger LOG = Logger.getLogger(MethodAtlasApp.class.getName());
/**
* Output formats supported by the application.
*
* <p>
* The selected mode determines both the emitted header and the per-method
* output representation.
* </p>
*/
private enum OutputMode {
/**
* Comma-separated values with a header line and raw numeric LOC.
* Emits output in comma-separated value format.
*
* <p>
* Fields are escaped according to the rules implemented by
* {@link #csvEscape(String)}.
* </p>
*/
CSV,
/**
* Plain text lines including {@code LOC=} and {@code TAGS=} labels.
* Emits output in a human-readable plain text format.
*/
PLAIN
}
/**
* Parsed command-line configuration used to drive a single application run.
*
* @param outputMode selected output mode
* @param aiOptions AI configuration controlling provider selection, taxonomy,
* limits, and timeouts
* @param paths root paths to scan; when empty, the current working
* directory is scanned
*/
private record CliConfig(OutputMode outputMode, AiOptions aiOptions, List<Path> paths) {
}
/**
* Program entry point.
*
* <p>
* Usage:
* This method performs the complete startup sequence of the application:
* </p>
* <pre>
* java -jar methodatlas.jar [ -plain ] &lt;path1&gt; [ &lt;path2&gt; ... ]
* </pre>
*
* <ul>
* <li>If {@code -plain} is provided as the first argument, the tool uses the
* plain-text output mode; otherwise CSV output is used.</li>
* <li>If no paths are provided, the current directory {@code "."} is
* scanned.</li>
* </ul>
* <ol>
* <li>configures JavaParser for Java 21 source syntax</li>
* <li>parses command-line arguments into a structured runtime
* configuration</li>
* <li>initializes the AI suggestion engine when AI support is requested</li>
* <li>emits the CSV header when CSV output mode is selected</li>
* <li>scans the requested root paths, or the current directory when no path is
* supplied</li>
* </ol>
*
* <p>
* The JavaParser language level is configured before parsing to support modern
* Java syntax (for example, {@code record} declarations).
* Command-line arguments control both output rendering and optional AI
* enrichment. Non-option arguments are interpreted as root paths to scan.
* </p>
*
* @param args command-line arguments; see usage above
* @throws IOException if directory traversal fails while scanning input paths
* <p>
* If no scan path is provided, the method scans the current working directory.
* If AI support is enabled and engine initialization fails, the method aborts
* by propagating an {@link IllegalStateException}.
* </p>
*
* @param args command-line arguments controlling output mode, AI configuration,
* and scan roots
* @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 into the required
* type
* @throws IllegalStateException if AI support is enabled but the AI engine
* cannot be created successfully
* @see #parseArgs(String[])
* @see #buildAiEngine(AiOptions)
* @see #scanRoot(Path, OutputMode, AiOptions, AiSuggestionEngine)
*/
public static void main(String[] args) throws IOException {
ParserConfiguration pc = new ParserConfiguration();
pc.setLanguageLevel(LanguageLevel.JAVA_21); // or JAVA_17, etc.
StaticJavaParser.setConfiguration(pc);
ParserConfiguration parserConfiguration = new ParserConfiguration();
parserConfiguration.setLanguageLevel(LanguageLevel.JAVA_21);
StaticJavaParser.setConfiguration(parserConfiguration);
OutputMode mode = OutputMode.CSV;
int firstPathIndex = 0;
CliConfig cliConfig = parseArgs(args);
AiSuggestionEngine aiEngine = buildAiEngine(cliConfig.aiOptions());
if (args.length > 0 && "-plain".equals(args[0])) {
mode = OutputMode.PLAIN;
firstPathIndex = 1;
if (cliConfig.outputMode() == OutputMode.CSV) {
if (cliConfig.aiOptions().enabled()) {
System.out.println("fqcn,method,loc,tags,ai_security_relevant,ai_display_name,ai_tags,ai_reason");
} else {
System.out.println("fqcn,method,loc,tags");
}
}
if (mode == OutputMode.CSV) {
System.out.println("fqcn,method,loc,tags");
}
if (args.length <= firstPathIndex) {
scanRoot(Paths.get("."), mode);
if (cliConfig.paths().isEmpty()) {
scanRoot(Paths.get("."), cliConfig.outputMode(), cliConfig.aiOptions(), aiEngine);
return;
}
for (int i = firstPathIndex; i < args.length; i++) {
scanRoot(Paths.get(args[i]), mode);
for (Path path : cliConfig.paths()) {
scanRoot(path, cliConfig.outputMode(), cliConfig.aiOptions(), aiEngine);
}
}
/**
* Recursively scans the supplied root directory for Java test files and
* processes them.
* Recursively scans a directory tree for Java test source files and processes
* each matching file.
*
* <p>
* The current implementation matches files by suffix {@code "Test.java"}.
* The current implementation selects files whose names end with
* {@code Test.java}.
* </p>
*
* @param root root directory to scan
* @param mode output mode to use for emitted records
* @throws IOException if the file tree walk fails
* @param root root directory to scan
* @param mode output mode used for emitted records
* @param aiOptions AI configuration for the current run
* @param aiEngine AI engine used to enrich results, or {@code null} when AI
* support is disabled
* @throws IOException if traversing the file tree fails
* @see Files#walk(Path, java.nio.file.FileVisitOption...)
*/
private static void scanRoot(Path root, OutputMode mode) throws IOException {
private static void scanRoot(Path root, OutputMode mode, AiOptions aiOptions, AiSuggestionEngine aiEngine)
throws IOException {
LOG.log(Level.INFO, "Scanning {0} for JUnit files", root);
Files.walk(root).filter(p -> p.toString().endsWith("Test.java")).forEach(p -> processFile(p, mode));
try (Stream<Path> stream = Files.walk(root)) {
stream.filter(path -> path.toString().endsWith("Test.java"))
.forEach(path -> processFile(path, mode, aiOptions, aiEngine));
}
}
/**
* Parses a single Java source file and emits records for all detected JUnit
* test methods.
* Parses a single Java source file, discovers JUnit test methods, optionally
* resolves AI suggestions for their enclosing classes, and emits output records
* for each discovered test method.
*
* <p>
* Parse errors are logged. Files that cannot be parsed are skipped.
* Parsing and processing failures are logged and do not abort the overall scan.
* </p>
*
* @param path Java source file to parse
* @param mode output mode to use for emitted records
* @param path source file to parse
* @param mode output mode used for emitted records
* @param aiOptions AI configuration for the current run
* @param aiEngine AI engine used to enrich results, or {@code null} when AI
* support is disabled
*/
private static void processFile(Path path, OutputMode mode) {
private static void processFile(Path path, OutputMode mode, AiOptions aiOptions, AiSuggestionEngine aiEngine) {
try {
CompilationUnit cu = StaticJavaParser.parse(path);
CompilationUnit compilationUnit = StaticJavaParser.parse(path);
String packageName = compilationUnit.getPackageDeclaration()
.map(packageDeclaration -> packageDeclaration.getNameAsString()).orElse("");
String pkg = cu.getPackageDeclaration().map(p -> p.getNameAsString()).orElse("");
cu.findAll(ClassOrInterfaceDeclaration.class).forEach(clazz -> {
compilationUnit.findAll(ClassOrInterfaceDeclaration.class).forEach(clazz -> {
String className = clazz.getNameAsString();
String fqcn = pkg.isEmpty() ? className : pkg + "." + className;
String fqcn = packageName.isEmpty() ? className : packageName + "." + className;
SuggestionLookup suggestionLookup = resolveSuggestionLookup(clazz, fqcn, aiOptions, aiEngine);
clazz.findAll(MethodDeclaration.class).forEach(method -> {
if (isJUnitTest(method)) {
int loc = countLOC(method);
List<String> tags = getTagValues(method);
emit(mode, fqcn, method.getNameAsString(), loc, tags);
if (!isJUnitTest(method)) {
return;
}
int loc = countLOC(method);
List<String> tags = getTagValues(method);
AiMethodSuggestion suggestion = suggestionLookup.find(method.getNameAsString()).orElse(null);
emit(mode, aiOptions.enabled(), fqcn, method.getNameAsString(), loc, tags, suggestion);
});
});
} catch (Exception e) {
LOG.log(Level.WARNING, "Failed to parse: {0}", path);
e.printStackTrace();
@@ -193,251 +323,413 @@ public class MethodAtlasApp {
}
/**
* Emits a single output record representing one test method.
* Resolves method-level AI suggestions for a parsed class.
*
* <p>
* For CSV output, values are printed as {@code fqcn,method,loc,tags} with CSV
* escaping applied to text fields. The {@code tags} field is a
* semicolon-separated list, or an empty field if no tags exist.
* If AI support is disabled, no engine is available, or the serialized class
* source exceeds the configured maximum size, the method returns an empty
* lookup. Failures produced by the AI engine are logged and also result in an
* empty lookup.
* </p>
*
* <p>
* For plain output, labels {@code LOC=} and {@code TAGS=} are included. If no
* tags exist, {@code TAGS=-} is printed.
* </p>
*
* @param mode output mode to use
* @param fqcn fully-qualified class name
* @param method method name
* @param loc lines of code for the method declaration (inclusive range)
* @param tags list of tag values; may be empty
* @param clazz parsed class declaration to analyze
* @param fqcn fully qualified class name of {@code clazz}
* @param aiOptions AI configuration for the current run
* @param aiEngine AI engine used to produce suggestions
* @return lookup of AI suggestions keyed by method name; never {@code null}
*/
private static void emit(OutputMode mode, String fqcn, String method, int loc, List<String> tags) {
if (mode == OutputMode.PLAIN) {
String tagText = tags.isEmpty() ? "-" : String.join(";", tags);
System.out.println(fqcn + ", " + method + ", LOC=" + loc + ", TAGS=" + tagText);
return;
private static SuggestionLookup resolveSuggestionLookup(ClassOrInterfaceDeclaration clazz, String fqcn,
AiOptions aiOptions, AiSuggestionEngine aiEngine) {
if (!aiOptions.enabled() || aiEngine == null) {
return SuggestionLookup.from(null);
}
String tagText = tags.isEmpty() ? "" : String.join(";", tags);
System.out.println(csvEscape(fqcn) + "," + csvEscape(method) + "," + loc + "," + csvEscape(tagText));
String classSource = clazz.toString();
if (classSource.length() > aiOptions.maxClassChars()) {
LOG.log(Level.INFO, "Skipping AI for {0}: class source too large ({1} chars)",
new Object[] { fqcn, classSource.length() });
return SuggestionLookup.from(null);
}
try {
AiClassSuggestion aiClassSuggestion = aiEngine.suggestForClass(fqcn, classSource);
return SuggestionLookup.from(aiClassSuggestion);
} catch (AiSuggestionException e) {
LOG.log(Level.WARNING, "AI suggestion failed for class " + fqcn + ": " + e.getMessage());
return SuggestionLookup.from(null);
}
}
/**
* Escapes a value for safe inclusion in a CSV field.
* Creates the AI suggestion engine for the current run.
*
* <p>
* If the value contains a comma, quote, or line break, the value is quoted and
* internal quotes are doubled, per common CSV conventions.
* If AI support is disabled, the method returns {@code null}. Initialization
* failures are wrapped in an {@link IllegalStateException} because they prevent
* execution of the requested AI-enabled mode.
* </p>
*
* @param value input value; may be {@code null}
* @return escaped CSV field value (never {@code null})
* @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);
}
}
/**
* Dispatches emission of a single method record to the configured output
* renderer.
*
* @param mode selected output mode
* @param aiEnabled whether AI enrichment is enabled for the current run
* @param fqcn fully qualified class name containing the method
* @param method test method name
* @param loc inclusive line count of the method declaration
* @param tags source-level JUnit tags extracted from the method
* @param suggestion AI suggestion associated with the method, or {@code null}
* if none is available
*/
private static void emit(OutputMode mode, boolean aiEnabled, String fqcn, String method, int loc, List<String> tags,
AiMethodSuggestion suggestion) {
if (mode == OutputMode.PLAIN) {
emitPlain(aiEnabled, fqcn, method, loc, tags, suggestion);
return;
}
emitCsv(aiEnabled, fqcn, method, loc, tags, suggestion);
}
/**
* Emits a single method record in plain text format.
*
* <p>
* When AI support is disabled, only source-derived metadata is emitted. When AI
* support is enabled, the method appends AI-derived fields to the same line.
* Missing AI values are rendered as {@code -}.
* </p>
*
* @param aiEnabled whether AI enrichment is enabled for the current run
* @param fqcn fully qualified class name containing the method
* @param method test method name
* @param loc inclusive line count of the method declaration
* @param tags source-level JUnit tags extracted from the method
* @param suggestion AI suggestion associated with the method, or {@code null}
* if none is available
*/
private static void emitPlain(boolean aiEnabled, String fqcn, String method, int loc, List<String> tags,
AiMethodSuggestion suggestion) {
String existingTags = tags.isEmpty() ? "-" : String.join(";", tags);
if (!aiEnabled) {
System.out.println(fqcn + ", " + method + ", LOC=" + loc + ", TAGS=" + existingTags);
return;
}
String aiSecurity = suggestion == null ? "-" : Boolean.toString(suggestion.securityRelevant());
String aiDisplayName = suggestion == null || suggestion.displayName() == null ? "-" : suggestion.displayName();
String aiTags = suggestion == null || suggestion.tags() == null || suggestion.tags().isEmpty() ? "-"
: String.join(";", suggestion.tags());
String aiReason = suggestion == null || suggestion.reason() == null || suggestion.reason().isBlank() ? "-"
: suggestion.reason();
System.out.println(fqcn + ", " + method + ", LOC=" + loc + ", TAGS=" + existingTags + ", AI_SECURITY="
+ aiSecurity + ", AI_DISPLAY=" + aiDisplayName + ", AI_TAGS=" + aiTags + ", AI_REASON=" + aiReason);
}
/**
* Emits a single method record in CSV format.
*
* <p>
* All textual fields are escaped using {@link #csvEscape(String)}. When AI
* support is disabled, only the base columns are emitted. When AI support is
* enabled, the AI-related columns are appended in the header order established
* by {@link #main(String[])}.
* </p>
*
* @param aiEnabled whether AI enrichment is enabled for the current run
* @param fqcn fully qualified class name containing the method
* @param method test method name
* @param loc inclusive line count of the method declaration
* @param tags source-level JUnit tags extracted from the method
* @param suggestion AI suggestion associated with the method, or {@code null}
* if none is available
*/
private static void emitCsv(boolean aiEnabled, String fqcn, String method, int loc, List<String> tags,
AiMethodSuggestion suggestion) {
String existingTags = tags.isEmpty() ? "" : String.join(";", tags);
if (!aiEnabled) {
System.out.println(csvEscape(fqcn) + "," + csvEscape(method) + "," + loc + "," + csvEscape(existingTags));
return;
}
String aiSecurity = suggestion == null ? "" : Boolean.toString(suggestion.securityRelevant());
String aiDisplayName = suggestion == null || suggestion.displayName() == null ? "" : suggestion.displayName();
String aiTags = suggestion == null || suggestion.tags() == null ? "" : String.join(";", suggestion.tags());
String aiReason = suggestion == null || suggestion.reason() == null ? "" : suggestion.reason();
System.out.println(csvEscape(fqcn) + "," + csvEscape(method) + "," + loc + "," + csvEscape(existingTags) + ","
+ csvEscape(aiSecurity) + "," + csvEscape(aiDisplayName) + "," + csvEscape(aiTags) + ","
+ csvEscape(aiReason));
}
/**
* Escapes a value for CSV output.
*
* <p>
* If the value contains a comma, double quote, carriage return, or line feed,
* it is wrapped in double quotes and embedded quotes are doubled. A
* {@code null} input is converted to an empty field.
* </p>
*
* @param value value to escape; may be {@code null}
* @return CSV-safe representation of {@code value}
*/
private static String csvEscape(String value) {
if (value == null) {
return "";
}
boolean mustQuote = value.indexOf(',') >= 0 || value.indexOf('"') >= 0 || value.indexOf('\n') >= 0
|| value.indexOf('\r') >= 0;
if (!mustQuote) {
return value;
}
return "\"" + value.replace("\"", "\"\"") + "\"";
}
/**
* Determines whether the given method declaration represents a JUnit Jupiter
* Parses command-line arguments into a structured configuration object.
*
* <p>
* The method recognizes output mode switches, AI-related options, and one or
* more scan paths. Unrecognized options beginning with {@code -} cause an
* {@link IllegalArgumentException}.
* </p>
*
* @param args raw command-line arguments
* @return parsed command-line configuration
* @throws IllegalArgumentException if an option value is missing, malformed, or
* unsupported
*/
private static CliConfig parseArgs(String[] args) {
OutputMode outputMode = OutputMode.CSV;
List<Path> paths = new ArrayList<>();
AiOptions.Builder aiBuilder = AiOptions.builder();
for (int i = 0; i < args.length; i++) {
String arg = args[i];
switch (arg) {
case "-plain" -> outputMode = OutputMode.PLAIN;
case "-ai" -> aiBuilder.enabled(true);
case "-ai-provider" -> aiBuilder.provider(AiProvider.valueOf(nextArg(args, ++i, arg).toUpperCase()));
case "-ai-model" -> aiBuilder.modelName(nextArg(args, ++i, arg));
case "-ai-base-url" -> aiBuilder.baseUrl(nextArg(args, ++i, arg));
case "-ai-api-key" -> aiBuilder.apiKey(nextArg(args, ++i, arg));
case "-ai-api-key-env" -> aiBuilder.apiKeyEnv(nextArg(args, ++i, arg));
case "-ai-taxonomy" -> aiBuilder.taxonomyFile(Paths.get(nextArg(args, ++i, arg)));
case "-ai-taxonomy-mode" ->
aiBuilder.taxonomyMode(AiOptions.TaxonomyMode.valueOf(nextArg(args, ++i, arg).toUpperCase()));
case "-ai-max-class-chars" -> aiBuilder.maxClassChars(Integer.parseInt(nextArg(args, ++i, arg)));
case "-ai-timeout-sec" ->
aiBuilder.timeout(Duration.ofSeconds(Long.parseLong(nextArg(args, ++i, arg))));
case "-ai-max-retries" -> aiBuilder.maxRetries(Integer.parseInt(nextArg(args, ++i, arg)));
default -> {
if (arg.startsWith("-")) {
throw new IllegalArgumentException("Unknown argument: " + arg);
}
paths.add(Paths.get(arg));
}
}
}
return new CliConfig(outputMode, aiBuilder.build(), paths);
}
/**
* Returns the argument value following an option token.
*
* @param args full command-line argument array
* @param index index of the expected option value
* @param option option whose value is being retrieved
* @return argument value at {@code index}
* @throws IllegalArgumentException if {@code index} is outside the bounds of
* {@code args}
*/
private static String nextArg(String[] args, int index, String option) {
if (index >= args.length) {
throw new IllegalArgumentException("Missing value for " + option);
}
return args[index];
}
/**
* Determines whether a method declaration represents a supported JUnit Jupiter
* test method.
*
* <p>
* The method is considered a test if it is annotated with one of the supported
* test annotations by simple name: {@code Test}, {@code ParameterizedTest}, or
* {@code RepeatedTest}.
* The current implementation recognizes methods annotated with {@code @Test},
* {@code @ParameterizedTest}, or {@code @RepeatedTest} by simple annotation
* name.
* </p>
*
* @param method method declaration to inspect
* @return {@code true} if the method is considered a test method; {@code false}
* @return {@code true} if the method is treated as a test method; {@code false}
* otherwise
*/
private static boolean isJUnitTest(MethodDeclaration method) {
for (AnnotationExpr ann : method.getAnnotations()) {
String name = ann.getNameAsString();
for (AnnotationExpr annotation : method.getAnnotations()) {
String name = annotation.getNameAsString();
if ("Test".equals(name) || "ParameterizedTest".equals(name) || "RepeatedTest".equals(name)) {
return true;
}
}
return false;
}
/**
* Collects all {@code @Tag} values declared directly on a method.
* Computes the inclusive line count of a method declaration from its source
* range.
*
* <p>
* Supported forms:
* If the parser did not retain source position information for the method, the
* method returns {@code 0}.
* </p>
* <ul>
* <li>Repeated {@code @Tag("...")} annotations</li>
* <li>{@code @Tags({ @Tag("..."), @Tag("...") })} container annotation</li>
* </ul>
*
* @param method method declaration to inspect
* @return list of tag values in encounter order; never {@code null}
* @param method method declaration whose size should be measured
* @return inclusive line count, or {@code 0} if no range information is
* available
*/
private static int countLOC(MethodDeclaration method) {
return method.getRange().map(range -> range.end.line - range.begin.line + 1).orElse(0);
}
/**
* Extracts all JUnit tag values declared on a method.
*
* <p>
* The method supports both direct {@code @Tag} annotations and the
* container-style {@code @Tags} annotation. Tags are returned in declaration
* order.
* </p>
*
* @param method method declaration whose annotations should be inspected
* @return list of extracted tag values; possibly empty but never {@code null}
*/
private static List<String> getTagValues(MethodDeclaration method) {
List<String> tags = new ArrayList<>();
List<String> tagValues = new ArrayList<>();
for (AnnotationExpr ann : method.getAnnotations()) {
String name = ann.getNameAsString();
if (isTagAnnotationName(name)) {
extractTagValue(ann).ifPresent(tags::add);
} else if (isTagsContainerAnnotationName(name)) {
tags.addAll(extractTagValuesFromContainer(ann));
for (AnnotationExpr annotation : method.getAnnotations()) {
String name = annotation.getNameAsString();
if ("Tag".equals(name)) {
extractTagValue(annotation).ifPresent(tagValues::add);
} else if ("Tags".equals(name)) {
extractTagsContainerValues(annotation, tagValues);
}
}
return tags;
return tagValues;
}
/**
* Checks whether an annotation name represents {@code @Tag}.
*
* @param name annotation name (simple or qualified)
* @return {@code true} if it matches {@code Tag}; {@code false} otherwise
*/
private static boolean isTagAnnotationName(String name) {
return "Tag".equals(name) || name.endsWith(".Tag");
}
/**
* Checks whether an annotation name represents {@code @Tags} (the container for
* {@code @Tag}).
*
* @param name annotation name (simple or qualified)
* @return {@code true} if it matches {@code Tags}; {@code false} otherwise
*/
private static boolean isTagsContainerAnnotationName(String name) {
return "Tags".equals(name) || name.endsWith(".Tags");
}
/**
* Extracts the tag value from a {@code @Tag} annotation expression.
* Extracts tag values from a JUnit {@code @Tags} container annotation.
*
* <p>
* Both single-member and normal annotation syntaxes are supported:
* Both the single-member form {@code @Tags({@Tag("a"), @Tag("b")})} and the
* normal form {@code @Tags(value = {...})} are supported.
* </p>
* <ul>
* <li>{@code @Tag("fast")}</li>
* <li>{@code @Tag(value = "fast")}</li>
* </ul>
*
* @param ann annotation expression representing {@code @Tag}
* @return extracted tag value, or empty if it cannot be determined
* @param annotation annotation expected to represent {@code @Tags}
* @param tagValues destination list to which extracted tag values are appended
*/
private static Optional<String> extractTagValue(AnnotationExpr ann) {
if (ann.isSingleMemberAnnotationExpr()) {
return Optional.of(expressionToTagText(ann.asSingleMemberAnnotationExpr().getMemberValue()));
private static void extractTagsContainerValues(AnnotationExpr annotation, List<String> tagValues) {
if (annotation.isSingleMemberAnnotationExpr()) {
Expression memberValue = annotation.asSingleMemberAnnotationExpr().getMemberValue();
extractTagsFromContainerValue(memberValue, tagValues);
return;
}
if (ann.isNormalAnnotationExpr()) {
for (MemberValuePair pair : ann.asNormalAnnotationExpr().getPairs()) {
if (annotation.isNormalAnnotationExpr()) {
for (MemberValuePair pair : annotation.asNormalAnnotationExpr().getPairs()) {
if ("value".equals(pair.getNameAsString())) {
return Optional.of(expressionToTagText(pair.getValue()));
extractTagsFromContainerValue(pair.getValue(), tagValues);
}
}
}
}
/**
* Extracts individual {@code @Tag} values from the value expression of a
* {@code @Tags} container annotation.
*
* <p>
* If the supplied expression is not an array initializer, the method does
* nothing.
* </p>
*
* @param value expression holding the container contents
* @param tagValues destination list to which extracted tag values are appended
*/
private static void extractTagsFromContainerValue(Expression value, List<String> tagValues) {
if (!value.isArrayInitializerExpr()) {
return;
}
ArrayInitializerExpr array = value.asArrayInitializerExpr();
for (Expression expression : array.getValues()) {
if (expression.isAnnotationExpr()) {
extractTagValue(expression.asAnnotationExpr()).ifPresent(tagValues::add);
}
}
}
/**
* Extracts the value from a single JUnit {@code @Tag} annotation.
*
* <p>
* Both the single-member form {@code @Tag("x")} and the normal form
* {@code @Tag(value = "x")} are supported.
* </p>
*
* @param annotation annotation expected to represent {@code @Tag}
* @return extracted tag value, or {@link Optional#empty()} if the annotation is
* not a supported {@code @Tag} form
*/
private static Optional<String> extractTagValue(AnnotationExpr annotation) {
if (!"Tag".equals(annotation.getNameAsString())) {
return Optional.empty();
}
if (annotation.isSingleMemberAnnotationExpr()) {
Expression memberValue = annotation.asSingleMemberAnnotationExpr().getMemberValue();
if (memberValue.isStringLiteralExpr()) {
return Optional.of(memberValue.asStringLiteralExpr().asString());
}
return Optional.empty();
}
if (annotation.isNormalAnnotationExpr()) {
for (MemberValuePair pair : annotation.asNormalAnnotationExpr().getPairs()) {
if ("value".equals(pair.getNameAsString()) && pair.getValue().isStringLiteralExpr()) {
return Optional.of(pair.getValue().asStringLiteralExpr().asString());
}
}
}
return Optional.empty();
}
/**
* Extracts all contained {@code @Tag} values from a {@code @Tags} container
* annotation.
*
* <p>
* Handles array initializers such as:
* </p>
* <pre>
* &#64;Tags({ @Tag("a"), @Tag("b") })
* </pre>
*
* @param ann annotation expression representing {@code @Tags}
* @return list of extracted tag values; never {@code null}
*/
private static List<String> extractTagValuesFromContainer(AnnotationExpr ann) {
List<String> tags = new ArrayList<>();
Optional<Expression> maybeValue = Optional.empty();
if (ann.isSingleMemberAnnotationExpr()) {
maybeValue = Optional.of(ann.asSingleMemberAnnotationExpr().getMemberValue());
} else if (ann.isNormalAnnotationExpr()) {
for (MemberValuePair pair : ann.asNormalAnnotationExpr().getPairs()) {
if ("value".equals(pair.getNameAsString())) {
maybeValue = Optional.of(pair.getValue());
break;
}
}
}
if (maybeValue.isEmpty()) {
return tags;
}
Expression value = maybeValue.get();
if (value.isArrayInitializerExpr()) {
ArrayInitializerExpr array = value.asArrayInitializerExpr();
for (Expression element : array.getValues()) {
if (element.isAnnotationExpr()) {
AnnotationExpr inner = element.asAnnotationExpr();
if (isTagAnnotationName(inner.getNameAsString())) {
extractTagValue(inner).ifPresent(tags::add);
}
}
}
} else if (value.isAnnotationExpr()) {
AnnotationExpr inner = value.asAnnotationExpr();
if (isTagAnnotationName(inner.getNameAsString())) {
extractTagValue(inner).ifPresent(tags::add);
}
}
return tags;
}
/**
* Converts an annotation value expression to tag text.
*
* <p>
* String literals are returned as their unescaped string value. Other
* expressions are returned using {@link Expression#toString()}.
* </p>
*
* @param expr expression to convert; may be {@code null}
* @return converted tag text (never {@code null})
*/
private static String expressionToTagText(Expression expr) {
if (expr == null) {
return "";
}
if (expr.isStringLiteralExpr()) {
return expr.asStringLiteralExpr().asString();
}
return expr.toString();
}
/**
* Computes the lines of code (LOC) for a method declaration using its source
* range.
*
* @param method method declaration
* @return inclusive LOC computed from the source range; {@code 0} if range is
* not available
*/
private static int countLOC(MethodDeclaration method) {
if (method.getRange().isPresent()) {
return method.getRange().get().end.line - method.getRange().get().begin.line + 1;
}
return 0;
}
}

View File

@@ -0,0 +1,47 @@
package org.egothor.methodatlas.ai;
import java.util.List;
/**
* Immutable AI-generated classification result for a single parsed test class.
*
* <p>
* This record represents the structured output returned by an AI suggestion
* engine after analyzing the source of one JUnit test class. It contains both
* optional class-level security classification data and method-level
* suggestions for individual test methods declared within the class.
* </p>
*
* <p>
* Class-level fields describe whether the class as a whole appears to be
* security-relevant and, if so, which aggregate tags or rationale apply.
* Method-level results are provided separately through {@link #methods()} and
* are typically used by the calling code as the primary source for per-method
* enrichment of emitted scan results.
* </p>
*
* <p>
* Instances of this record are commonly deserialized from provider-specific AI
* responses after normalization into the application's internal result model.
* </p>
*
* @param className simple or fully qualified class name reported by
* the AI; may be {@code null} if omitted by the
* provider response
* @param classSecurityRelevant whether the class as a whole is considered
* security-relevant; may be {@code null} when the
* AI does not provide a class-level decision
* @param classTags class-level security tags suggested by the AI;
* may be empty or {@code null} depending on
* response normalization
* @param classReason explanatory rationale for the class-level
* classification; may be {@code null}
* @param methods method-level suggestions produced for individual
* test methods; may be empty or {@code null}
* depending on response normalization
* @see AiMethodSuggestion
* @see org.egothor.methodatlas.ai.AiSuggestionEngine
*/
public record AiClassSuggestion(String className, Boolean classSecurityRelevant, List<String> classTags,
String classReason, List<AiMethodSuggestion> methods) {
}

View File

@@ -0,0 +1,60 @@
package org.egothor.methodatlas.ai;
import java.util.List;
/**
* Immutable AI-generated security classification result for a single test
* method.
*
* <p>
* This record represents the normalized method-level output returned by an
* {@link org.egothor.methodatlas.ai.AiSuggestionEngine} after analyzing the
* source of a JUnit test class. Each instance describes the AI's interpretation
* of the security relevance and taxonomy classification of one test method.
* </p>
*
* <p>
* The classification data contained in this record is typically produced by an
* external AI provider and normalized by the application's AI integration layer
* before being returned to the scanning logic. The resulting values are then
* merged with source-derived metadata during output generation.
* </p>
*
* <p>
* The fields of this record correspond to the security analysis dimensions used
* by the {@code MethodAtlasApp} enrichment pipeline:
* </p>
*
* <ul>
* <li>whether the test method validates a security property</li>
* <li>a suggested {@code @DisplayName} describing the security intent</li>
* <li>taxonomy-based security tags associated with the test</li>
* <li>a short explanatory rationale describing the classification</li>
* </ul>
*
* <p>
* Instances of this record are typically stored in a
* {@link org.egothor.methodatlas.ai.SuggestionLookup} and retrieved using the
* method name as the lookup key when emitting enriched scan results.
* </p>
*
* @param methodName name of the analyzed test method as reported by the
* AI
* @param securityRelevant {@code true} if the AI classified the test as
* validating a security property
* @param displayName suggested {@code @DisplayName} value describing the
* security intent of the test; may be {@code null}
* @param tags taxonomy-based security tags suggested for the test
* method; may be empty or {@code null} depending on
* provider response
* @param reason explanatory rationale describing why the method was
* classified as security-relevant or why specific tags
* were assigned; may be {@code null}
*
* @see org.egothor.methodatlas.MethodAtlasApp
* @see org.egothor.methodatlas.ai.AiSuggestionEngine
* @see org.egothor.methodatlas.ai.SuggestionLookup
*/
public record AiMethodSuggestion(String methodName, boolean securityRelevant, String displayName, List<String> tags,
String reason) {
}

View File

@@ -0,0 +1,338 @@
package org.egothor.methodatlas.ai;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Objects;
/**
* Immutable configuration describing how AI-based enrichment should be
* performed during a {@link org.egothor.methodatlas.MethodAtlasApp} execution.
*
* <p>
* This record aggregates all runtime parameters required by the AI integration
* layer, including provider selection, model identification, authentication
* configuration, taxonomy selection, request limits, and retry behavior.
* </p>
*
* <p>
* Instances of this record are typically constructed using the associated
* {@link Builder} and passed to the AI subsystem when initializing an
* {@link AiSuggestionEngine}. The configuration is immutable once constructed
* and therefore safe to share between concurrent components.
* </p>
*
* <h2>Configuration Responsibilities</h2>
*
* <ul>
* <li>AI provider selection and endpoint configuration</li>
* <li>model name resolution</li>
* <li>API key discovery</li>
* <li>taxonomy configuration for security classification</li>
* <li>input size limits for class source submission</li>
* <li>network timeout configuration</li>
* <li>retry policy for transient AI failures</li>
* </ul>
*
* <p>
* Default values are supplied by the {@link Builder} when parameters are not
* explicitly provided.
* </p>
*
* @param enabled whether AI enrichment is enabled
* @param provider AI provider used to perform analysis
* @param modelName provider-specific model identifier
* @param baseUrl base API endpoint used by the selected provider
* @param apiKey API key used for authentication, if provided directly
* @param apiKeyEnv environment variable name containing the API key
* @param taxonomyFile optional path to an external taxonomy definition
* @param taxonomyMode built-in taxonomy mode to use when no file is provided
* @param maxClassChars maximum number of characters allowed for class source
* submitted to the AI provider
* @param timeout request timeout applied to AI calls
* @param maxRetries number of retry attempts for failed AI operations
*
* @see AiSuggestionEngine
* @see Builder
*/
public record AiOptions(boolean enabled, AiProvider provider, String modelName, String baseUrl, String apiKey,
String apiKeyEnv, Path taxonomyFile, TaxonomyMode taxonomyMode, int maxClassChars, Duration timeout,
int maxRetries) {
/**
* Built-in taxonomy modes used for security classification.
*
* <p>
* These modes determine which internal taxonomy definition is supplied to the
* AI provider when an external taxonomy file is not configured.
* </p>
*
* <ul>
* <li>{@link #DEFAULT} general-purpose taxonomy suitable for human
* readability</li>
* <li>{@link #OPTIMIZED} compact taxonomy optimized for AI classification
* accuracy</li>
* </ul>
*/
public enum TaxonomyMode {
/**
* Standard taxonomy definition emphasizing clarity and documentation.
*/
DEFAULT,
/**
* Reduced taxonomy optimized for improved AI classification reliability.
*/
OPTIMIZED
}
/**
* Canonical constructor performing validation of configuration parameters.
*
* <p>
* The constructor enforces basic invariants required for correct operation of
* the AI integration layer. Invalid values result in an
* {@link IllegalArgumentException}.
* </p>
*
* @throws NullPointerException if required parameters such as
* {@code provider}, {@code modelName},
* {@code timeout}, or {@code taxonomyMode} are
* {@code null}
* @throws IllegalArgumentException if configuration values violate required
* constraints
*/
public AiOptions {
Objects.requireNonNull(provider, "provider");
Objects.requireNonNull(modelName, "modelName");
Objects.requireNonNull(timeout, "timeout");
Objects.requireNonNull(taxonomyMode, "taxonomyMode");
if (baseUrl == null || baseUrl.isBlank()) {
throw new IllegalArgumentException("baseUrl must not be blank");
}
if (maxClassChars <= 0) {
throw new IllegalArgumentException("maxClassChars must be > 0");
}
if (maxRetries < 0) {
throw new IllegalArgumentException("maxRetries must be >= 0");
}
}
/**
* Creates a new {@link Builder} used to construct {@link AiOptions} instances.
*
* <p>
* The builder supplies sensible defaults for most configuration values and
* allows incremental customization before producing the final immutable
* configuration record.
* </p>
*
* @return new builder instance
*/
public static Builder builder() {
return new Builder();
}
/**
* Resolves the effective API key used for authenticating AI provider requests.
*
* <p>
* The resolution strategy is:
* </p>
*
* <ol>
* <li>If {@link #apiKey()} is defined and non-empty, it is returned.</li>
* <li>If {@link #apiKeyEnv()} is defined, the corresponding environment
* variable is resolved using {@link System#getenv(String)}.</li>
* <li>If neither source yields a value, {@code null} is returned.</li>
* </ol>
*
* @return resolved API key or {@code null} if none is available
*/
public String resolvedApiKey() {
if (apiKey != null && !apiKey.isBlank()) {
return apiKey;
}
if (apiKeyEnv != null && !apiKeyEnv.isBlank()) {
String value = System.getenv(apiKeyEnv);
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
/**
* Mutable builder used to construct validated {@link AiOptions} instances.
*
* <p>
* The builder follows the conventional staged construction pattern, allowing
* optional parameters to be supplied before producing the final immutable
* configuration record via {@link #build()}.
* </p>
*
* <p>
* Reasonable defaults are provided for most parameters so that only
* provider-specific details typically need to be configured explicitly.
* </p>
*/
public static final class Builder {
private boolean enabled;
private AiProvider provider = AiProvider.AUTO;
private String modelName = "qwen2.5-coder:7b";
private String baseUrl;
private String apiKey;
private String apiKeyEnv;
private Path taxonomyFile;
private TaxonomyMode taxonomyMode = TaxonomyMode.DEFAULT;
private int maxClassChars = 40_000;
private Duration timeout = Duration.ofSeconds(90);
private int maxRetries = 1;
/**
* Enables or disables AI enrichment.
*
* @param enabled {@code true} to enable AI integration
* @return this builder
*/
public Builder enabled(boolean enabled) {
this.enabled = enabled;
return this;
}
/**
* Selects the AI provider.
*
* @param provider provider implementation to use
* @return this builder
*/
public Builder provider(AiProvider provider) {
this.provider = provider;
return this;
}
/**
* Specifies the provider-specific model identifier.
*
* @param modelName name of the model to use
* @return this builder
*/
public Builder modelName(String modelName) {
this.modelName = modelName;
return this;
}
/**
* Sets the base API endpoint used by the provider.
*
* @param baseUrl base URL of the provider API
* @return this builder
*/
public Builder baseUrl(String baseUrl) {
this.baseUrl = baseUrl;
return this;
}
/**
* Sets the API key used for authentication.
*
* @param apiKey API key value
* @return this builder
*/
public Builder apiKey(String apiKey) {
this.apiKey = apiKey;
return this;
}
/**
* Specifies the environment variable that stores the API key.
*
* @param apiKeyEnv environment variable name
* @return this builder
*/
public Builder apiKeyEnv(String apiKeyEnv) {
this.apiKeyEnv = apiKeyEnv;
return this;
}
/**
* Specifies an external taxonomy definition file.
*
* @param taxonomyFile path to taxonomy definition
* @return this builder
*/
public Builder taxonomyFile(Path taxonomyFile) {
this.taxonomyFile = taxonomyFile;
return this;
}
/**
* Selects the built-in taxonomy mode.
*
* @param taxonomyMode taxonomy variant
* @return this builder
*/
public Builder taxonomyMode(TaxonomyMode taxonomyMode) {
this.taxonomyMode = taxonomyMode;
return this;
}
/**
* Sets the maximum size of class source submitted to the AI provider.
*
* @param maxClassChars maximum allowed character count
* @return this builder
*/
public Builder maxClassChars(int maxClassChars) {
this.maxClassChars = maxClassChars;
return this;
}
/**
* Sets the timeout applied to AI requests.
*
* @param timeout request timeout
* @return this builder
*/
public Builder timeout(Duration timeout) {
this.timeout = timeout;
return this;
}
/**
* Sets the retry limit for AI requests.
*
* @param maxRetries retry count
* @return this builder
*/
public Builder maxRetries(int maxRetries) {
this.maxRetries = maxRetries;
return this;
}
/**
* Builds the final immutable {@link AiOptions} configuration.
*
* <p>
* If no base URL is explicitly supplied, a provider-specific default endpoint
* is selected automatically.
* </p>
*
* @return validated AI configuration
*/
public AiOptions build() {
AiProvider effectiveProvider = provider == null ? AiProvider.AUTO : provider;
String effectiveBaseUrl = baseUrl;
if (effectiveBaseUrl == null || effectiveBaseUrl.isBlank()) {
effectiveBaseUrl = switch (effectiveProvider) {
case AUTO, OLLAMA -> "http://localhost:11434";
case OPENAI -> "https://api.openai.com";
case OPENROUTER -> "https://openrouter.ai/api";
case ANTHROPIC -> "https://api.anthropic.com";
};
}
return new AiOptions(enabled, effectiveProvider, modelName, effectiveBaseUrl, apiKey, apiKeyEnv,
taxonomyFile, taxonomyMode, maxClassChars, timeout, maxRetries);
}
}
}

View File

@@ -0,0 +1,88 @@
package org.egothor.methodatlas.ai;
/**
* Enumeration of supported AI provider implementations used by the
* {@link org.egothor.methodatlas.ai.AiSuggestionEngine}.
*
* <p>
* Each constant represents a distinct AI platform capable of performing
* security classification of test sources. The provider selected through
* {@link AiOptions} determines which concrete client implementation is used for
* communicating with the external AI service.
* </p>
*
* <p>
* Provider integrations typically differ in authentication model, request
* format, endpoint structure, and supported model identifiers. The AI
* integration layer normalizes these differences so that the rest of the
* application can interact with a consistent abstraction.
* </p>
*
* <h2>Provider Selection</h2>
*
* <p>
* The selected provider influences:
* </p>
* <ul>
* <li>the HTTP endpoint used for inference requests</li>
* <li>authentication behavior</li>
* <li>the model identifier format</li>
* <li>response normalization logic</li>
* </ul>
*
* <p>
* When {@link #AUTO} is selected, the system attempts to determine the most
* suitable provider automatically based on the configured endpoint or local
* runtime environment.
* </p>
*
* @see AiOptions
* @see AiSuggestionEngine
*/
public enum AiProvider {
/**
* Automatically selects the most appropriate AI provider based on configuration
* and runtime availability.
*
* <p>
* This mode allows the application to operate with minimal configuration,
* preferring locally available providers when possible.
* </p>
*/
AUTO,
/**
* Uses a locally running <a href="https://ollama.ai/">Ollama</a> instance as
* the AI inference backend.
*
* <p>
* This provider typically communicates with an HTTP endpoint hosted on the
* local machine and allows the use of locally installed large language models
* without external API calls.
* </p>
*/
OLLAMA,
/**
* Uses the OpenAI API for AI inference.
*
* <p>
* Requests are sent to the OpenAI platform using API key authentication and
* provider-specific model identifiers such as {@code gpt-4} or {@code gpt-4o}.
* </p>
*/
OPENAI,
/**
* Uses the <a href="https://openrouter.ai/">OpenRouter</a> aggregation service
* to access multiple AI models through a unified API.
*
* <p>
* OpenRouter acts as a routing layer that forwards requests to different
* underlying model providers while maintaining a consistent API surface.
* </p>
*/
OPENROUTER,
/**
* Uses the <a href="https://www.anthropic.com/">Anthropic</a> API for AI
* inference, typically through models in the Claude family.
*/
ANTHROPIC
}

View File

@@ -0,0 +1,93 @@
package org.egothor.methodatlas.ai;
/**
* Provider-specific client abstraction used to communicate with external AI
* inference services.
*
* <p>
* Implementations of this interface encapsulate the protocol and request
* formatting required to interact with a particular AI provider such as OpenAI,
* Ollama, Anthropic, or OpenRouter. The interface isolates the rest of the
* application from provider-specific details including authentication, endpoint
* layout, and response normalization.
* </p>
*
* <p>
* Instances are typically created by the AI integration layer during
* initialization of the {@link AiSuggestionEngine}. Each client is responsible
* for transforming a class-level analysis request into the providers native
* API format and mapping the response back into the internal
* {@link AiClassSuggestion} representation used by the application.
* </p>
*
* <h2>Provider Responsibilities</h2>
*
* <ul>
* <li>constructing provider-specific HTTP requests</li>
* <li>handling authentication and API keys</li>
* <li>sending inference requests</li>
* <li>parsing and validating AI responses</li>
* <li>normalizing results into {@link AiClassSuggestion}</li>
* </ul>
*
* <p>
* Implementations are expected to be stateless and thread-safe unless
* explicitly documented otherwise.
* </p>
*
* @see AiSuggestionEngine
* @see AiClassSuggestion
* @see AiProvider
*/
public interface AiProviderClient {
/**
* Determines whether the provider is reachable and usable in the current
* runtime environment.
*
* <p>
* Implementations typically perform a lightweight availability check such as
* probing the provider's base endpoint or verifying that required configuration
* (for example, API keys or local services) is present.
* </p>
*
* <p>
* This method is primarily used when {@link AiProvider#AUTO} selection is
* enabled so the system can choose the first available provider.
* </p>
*
* @return {@code true} if the provider appears available and ready to accept
* inference requests; {@code false} otherwise
*/
boolean isAvailable();
/**
* Requests AI-based security classification for a parsed test class.
*
* <p>
* The implementation submits the provided class source code together with the
* taxonomy specification to the underlying AI provider. The provider analyzes
* the class and produces structured classification results for the class itself
* and for each test method contained within the class.
* </p>
*
* <p>
* The response is normalized into an {@link AiClassSuggestion} instance
* containing both class-level metadata and a list of {@link AiMethodSuggestion}
* objects describing individual test methods.
* </p>
*
* @param fqcn fully qualified name of the analyzed class
* @param classSource complete source code of the class being analyzed
* @param taxonomyText security taxonomy definition guiding the AI
* classification
* @return normalized AI classification result
*
* @throws AiSuggestionException if the request fails due to provider errors,
* malformed responses, or communication failures
*
* @see AiClassSuggestion
* @see AiMethodSuggestion
*/
AiClassSuggestion suggestForClass(String fqcn, String classSource, String taxonomyText)
throws AiSuggestionException;
}

View File

@@ -0,0 +1,149 @@
package org.egothor.methodatlas.ai;
/**
* Factory responsible for creating provider-specific AI client implementations.
*
* <p>
* This class centralizes the logic for selecting and constructing concrete
* {@link AiProviderClient} implementations based on the configuration provided
* through {@link AiOptions}. It abstracts provider instantiation from the rest
* of the application so that higher-level components interact only with the
* {@link AiProviderClient} interface.
* </p>
*
* <h2>Provider Resolution</h2>
*
* <p>
* When an explicit provider is configured in {@link AiOptions#provider()}, the
* factory constructs the corresponding client implementation. When
* {@link AiProvider#AUTO} is selected, the factory attempts to determine a
* suitable provider automatically using the following strategy:
* </p>
*
* <ol>
* <li>Attempt to use a locally running {@link OllamaClient}.</li>
* <li>If Ollama is not reachable and an API key is configured, fall back to an
* OpenAI-compatible provider.</li>
* <li>If no provider can be resolved, an {@link AiSuggestionException} is
* thrown.</li>
* </ol>
*
* <p>
* The factory ensures that returned clients are usable by verifying provider
* availability when required.
* </p>
*
* <p>
* This class is intentionally non-instantiable and exposes only static factory
* methods.
* </p>
*
* @see AiProviderClient
* @see AiProvider
* @see AiOptions
*/
public final class AiProviderFactory {
/**
* Prevents instantiation of this utility class.
*/
private AiProviderFactory() {
}
/**
* Creates a provider-specific {@link AiProviderClient} based on the supplied
* configuration.
*
* <p>
* The selected provider determines which concrete implementation is
* instantiated and how availability checks are performed. When
* {@link AiProvider#AUTO} is configured, the method delegates provider
* selection to {@link #auto(AiOptions)}.
* </p>
*
* @param options AI configuration describing provider, model, endpoint,
* authentication, and runtime limits
* @return initialized provider client ready to perform inference requests
*
* @throws AiSuggestionException if the provider cannot be initialized, required
* authentication is missing, or no suitable
* provider can be resolved
*/
public static AiProviderClient create(AiOptions options) throws AiSuggestionException {
return switch (options.provider()) {
case OLLAMA -> new OllamaClient(options);
case OPENAI -> requireAvailable(new OpenAiCompatibleClient(options), "OpenAI API key missing");
case OPENROUTER -> requireAvailable(new OpenAiCompatibleClient(options), "OpenRouter API key missing");
case ANTHROPIC -> requireAvailable(new AnthropicClient(options), "Anthropic API key missing");
case AUTO -> auto(options);
};
}
/**
* Performs automatic provider discovery when {@link AiProvider#AUTO} is
* selected.
*
* <p>
* The discovery process prioritizes locally available inference services to
* enable operation without external dependencies whenever possible.
* </p>
*
* <p>
* The current discovery strategy is:
* </p>
* <ol>
* <li>Attempt to connect to a local {@link OllamaClient}.</li>
* <li>If Ollama is not available but an API key is configured, create an
* {@link OpenAiCompatibleClient}.</li>
* <li>If neither provider can be used, throw an exception.</li>
* </ol>
*
* @param options AI configuration used to construct candidate providers
* @return resolved provider client
*
* @throws AiSuggestionException if no suitable provider can be discovered
*/
private static AiProviderClient auto(AiOptions options) throws AiSuggestionException {
AiOptions ollamaOptions = AiOptions.builder().enabled(options.enabled()).provider(AiProvider.OLLAMA)
.modelName(options.modelName()).baseUrl(options.baseUrl()).taxonomyFile(options.taxonomyFile())
.maxClassChars(options.maxClassChars()).timeout(options.timeout()).maxRetries(options.maxRetries())
.build();
OllamaClient ollamaClient = new OllamaClient(ollamaOptions);
if (ollamaClient.isAvailable()) {
return ollamaClient;
}
String apiKey = options.resolvedApiKey();
if (apiKey != null && !apiKey.isBlank()) {
return new OpenAiCompatibleClient(options);
}
throw new AiSuggestionException(
"No AI provider available. Ollama is not reachable and no API key is configured.");
}
/**
* Ensures that a provider client is available before returning it.
*
* <p>
* This helper method invokes {@link AiProviderClient#isAvailable()} and throws
* an {@link AiSuggestionException} if the provider cannot be used. It is
* primarily used when constructing clients that require external services or
* authentication to function correctly.
* </p>
*
* @param client provider client to verify
* @param message error message used if the provider is unavailable
* @return the supplied client if it is available
*
* @throws AiSuggestionException if the provider reports that it is not
* available
*/
private static AiProviderClient requireAvailable(AiProviderClient client, String message)
throws AiSuggestionException {
if (!client.isAvailable()) {
throw new AiSuggestionException(message);
}
return client;
}
}

View File

@@ -0,0 +1,66 @@
package org.egothor.methodatlas.ai;
/**
* High-level AI orchestration contract for security classification of parsed
* test classes.
*
* <p>
* This interface defines the provider-agnostic entry point used by
* {@link org.egothor.methodatlas.MethodAtlasApp} to request AI-generated
* security tagging suggestions for a single parsed JUnit test class.
* Implementations coordinate taxonomy selection, provider resolution, request
* submission, response normalization, and conversion into the application's
* internal result model.
* </p>
*
* <h2>Responsibilities</h2>
*
* <ul>
* <li>accepting a fully qualified class name and corresponding class
* source</li>
* <li>submitting the class for AI-based security analysis</li>
* <li>normalizing provider-specific responses into
* {@link AiClassSuggestion}</li>
* <li>surfacing failures through {@link AiSuggestionException}</li>
* </ul>
*
* <p>
* The interface intentionally hides provider-specific protocol details so that
* the rest of the application can depend on a stable abstraction independent of
* the selected AI backend.
* </p>
*
* @see AiClassSuggestion
* @see AiProviderClient
* @see org.egothor.methodatlas.MethodAtlasApp
*/
public interface AiSuggestionEngine {
/**
* Requests AI-generated security classification for a single parsed test class.
*
* <p>
* The supplied source code is analyzed in the context of the configured
* taxonomy and AI provider. The returned result may contain both class-level
* and method-level suggestions, including security relevance, display name
* proposals, taxonomy tags, and optional explanatory rationale.
* </p>
*
* <p>
* The method expects the complete source of the class being analyzed, rather
* than a single method fragment, so that the AI engine can evaluate test intent
* using full class context.
* </p>
*
* @param fqcn fully qualified class name of the parsed test class
* @param classSource complete source code of the class to analyze
* @return normalized AI classification result for the class and its methods
*
* @throws AiSuggestionException if analysis fails due to provider communication
* errors, invalid responses, provider
* unavailability, or normalization failures
*
* @see AiClassSuggestion
* @see AiMethodSuggestion
*/
AiClassSuggestion suggestForClass(String fqcn, String classSource) throws AiSuggestionException;
}

View File

@@ -0,0 +1,126 @@
package org.egothor.methodatlas.ai;
import java.io.IOException;
import java.nio.file.Files;
/**
* Default implementation of {@link AiSuggestionEngine} that coordinates
* provider selection and taxonomy loading for AI-based security classification.
*
* <p>
* This implementation acts as the primary orchestration layer between the
* command-line application and the provider-specific AI client subsystem. It
* resolves the effective {@link AiProviderClient} through
* {@link AiProviderFactory}, loads the taxonomy text used to guide
* classification, and delegates class-level analysis requests to the selected
* provider client.
* </p>
*
* <h2>Responsibilities</h2>
*
* <ul>
* <li>creating the effective provider client from {@link AiOptions}</li>
* <li>loading taxonomy text from a configured file or from the selected
* built-in taxonomy mode</li>
* <li>delegating class analysis requests to the provider client</li>
* <li>presenting a provider-independent {@link AiSuggestionEngine} contract to
* higher-level callers</li>
* </ul>
*
* <p>
* Instances of this class are immutable after construction and are intended to
* be created once per application run.
* </p>
*
* @see AiSuggestionEngine
* @see AiProviderFactory
* @see AiProviderClient
* @see AiOptions.TaxonomyMode
*/
public final class AiSuggestionEngineImpl implements AiSuggestionEngine {
private final AiProviderClient client;
private final String taxonomyText;
/**
* Creates a new AI suggestion engine using the supplied runtime options.
*
* <p>
* During construction, the implementation resolves the effective provider
* client and loads the taxonomy text that will be supplied to the AI provider
* for subsequent classification requests. The taxonomy is taken from an
* external file when configured; otherwise, the built-in taxonomy selected by
* {@link AiOptions#taxonomyMode()} is used.
* </p>
*
* @param options AI runtime configuration controlling provider selection,
* taxonomy loading, and request behavior
*
* @throws AiSuggestionException if provider initialization fails or if the
* configured taxonomy cannot be loaded
*/
public AiSuggestionEngineImpl(AiOptions options) throws AiSuggestionException {
this.client = AiProviderFactory.create(options);
this.taxonomyText = loadTaxonomy(options);
}
/**
* Requests AI-generated security classification for a single parsed test class.
*
* <p>
* The method delegates directly to the configured {@link AiProviderClient},
* supplying the fully qualified class name, the complete class source, and the
* taxonomy text loaded at engine initialization time.
* </p>
*
* @param fqcn fully qualified class name of the analyzed test class
* @param classSource complete source code of the class to analyze
* @return normalized AI classification result for the class and its methods
*
* @throws AiSuggestionException if the provider fails to analyze the class or
* returns an invalid response
*
* @see AiClassSuggestion
* @see AiProviderClient#suggestForClass(String, String, String)
*/
@Override
public AiClassSuggestion suggestForClass(String fqcn, String classSource) throws AiSuggestionException {
return client.suggestForClass(fqcn, classSource, taxonomyText);
}
/**
* Loads the taxonomy text used to guide AI classification.
*
* <p>
* Resolution order:
* </p>
* <ol>
* <li>If an external taxonomy file is configured, its contents are used.</li>
* <li>Otherwise, the built-in taxonomy selected by
* {@link AiOptions#taxonomyMode()} is used.</li>
* </ol>
*
* @param options AI runtime configuration
* @return taxonomy text to be supplied to the AI provider
*
* @throws AiSuggestionException if an external taxonomy file is configured but
* cannot be read successfully
*
* @see DefaultSecurityTaxonomy#text()
* @see OptimizedSecurityTaxonomy#text()
*/
private static String loadTaxonomy(AiOptions options) throws AiSuggestionException {
if (options.taxonomyFile() != null) {
try {
return Files.readString(options.taxonomyFile());
} catch (IOException e) {
throw new AiSuggestionException("Failed to read taxonomy file: " + options.taxonomyFile(), e);
}
}
return switch (options.taxonomyMode()) {
case DEFAULT -> DefaultSecurityTaxonomy.text();
case OPTIMIZED -> OptimizedSecurityTaxonomy.text();
};
}
}

View File

@@ -0,0 +1,47 @@
package org.egothor.methodatlas.ai;
/**
* Checked exception indicating failure during AI-based suggestion generation or
* related AI subsystem operations.
*
* <p>
* This exception is used throughout the AI integration layer to report provider
* initialization failures, taxonomy loading errors, connectivity problems,
* malformed provider responses, and other conditions that prevent successful
* generation of AI-based classification results.
* </p>
*
* <p>
* The exception is declared as a checked exception because such failures are
* part of the normal operational contract of the AI subsystem and callers are
* expected to either handle them explicitly or convert them into higher-level
* application failures when AI support is mandatory.
* </p>
*
* @see AiSuggestionEngine
* @see AiProviderClient
* @see AiSuggestionEngineImpl
*/
public final class AiSuggestionException extends Exception {
private static final long serialVersionUID = 6365662915183382629L;
/**
* Creates a new exception with the specified detail message.
*
* @param message detail message describing the failure
*/
public AiSuggestionException(String message) {
super(message);
}
/**
* Creates a new exception with the specified detail message and cause.
*
* @param message detail message describing the failure
* @param cause underlying cause of the failure
*/
public AiSuggestionException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,259 @@
package org.egothor.methodatlas.ai;
import java.net.URI;
import java.net.http.HttpRequest;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* {@link AiProviderClient} implementation for the Anthropic API.
*
* <p>
* This client submits classification requests to the Anthropic
* <a href="https://docs.anthropic.com/">Claude API</a> and converts the
* returned response into the internal {@link AiClassSuggestion} model used by
* the MethodAtlas AI subsystem.
* </p>
*
* <h2>Operational Responsibilities</h2>
*
* <ul>
* <li>constructing Anthropic message API requests</li>
* <li>injecting the taxonomy-driven classification prompt</li>
* <li>performing authenticated HTTP calls to the Anthropic service</li>
* <li>extracting the JSON result embedded in the model response</li>
* <li>normalizing the result into {@link AiClassSuggestion}</li>
* </ul>
*
* <p>
* The client uses the {@code /v1/messages} endpoint and relies on the Claude
* message format, where a system prompt defines classification rules and the
* user message contains the class source together with the taxonomy
* specification.
* </p>
*
* <p>
* Instances of this class are typically created by
* {@link AiProviderFactory#create(AiOptions)}.
* </p>
*
* <p>
* This implementation is stateless apart from immutable configuration and is
* therefore safe for reuse across multiple requests.
* </p>
*
* @see AiProviderClient
* @see AiSuggestionEngine
* @see AiProviderFactory
*/
public final class AnthropicClient implements AiProviderClient {
/**
* System prompt used to instruct the model to return strictly formatted JSON
* responses suitable for automated parsing.
*
* <p>
* The prompt enforces deterministic output behavior and prevents the model from
* returning explanations, markdown formatting, or conversational responses that
* would break the JSON extraction pipeline.
* </p>
*/
private static final String SYSTEM_PROMPT = """
You are a precise software security classification engine.
You classify JUnit 5 tests and return strict JSON only.
Never include markdown fences, explanations, or extra text.
""";
private final AiOptions options;
private final HttpSupport httpSupport;
/**
* Creates a new Anthropic client using the supplied runtime configuration.
*
* <p>
* The configuration defines the model identifier, API endpoint, request
* timeout, and authentication settings used when communicating with the
* Anthropic service.
* </p>
*
* @param options AI runtime configuration
*/
public AnthropicClient(AiOptions options) {
this.options = options;
this.httpSupport = new HttpSupport(options.timeout());
}
/**
* Determines whether the Anthropic provider can be used in the current runtime
* environment.
*
* <p>
* The provider is considered available when a non-empty API key can be resolved
* from {@link AiOptions#resolvedApiKey()}.
* </p>
*
* @return {@code true} if a usable API key is configured
*/
@Override
public boolean isAvailable() {
return options.resolvedApiKey() != null && !options.resolvedApiKey().isBlank();
}
/**
* Submits a classification request to the Anthropic API for the specified test
* class.
*
* <p>
* The method constructs a message-based request containing:
* </p>
*
* <ul>
* <li>a system prompt enforcing deterministic JSON output</li>
* <li>a user prompt containing the class source and taxonomy definition</li>
* </ul>
*
* <p>
* The response is parsed to extract the first JSON object returned by the
* model, which is then deserialized into an {@link AiClassSuggestion}.
* </p>
*
* @param fqcn fully qualified class name being analyzed
* @param classSource complete source code of the class
* @param taxonomyText taxonomy definition guiding classification
*
* @return normalized AI classification result
*
* @throws AiSuggestionException if the provider request fails, the response
* cannot be parsed, or the provider returns
* invalid content
*/
@Override
public AiClassSuggestion suggestForClass(String fqcn, String classSource, String taxonomyText)
throws AiSuggestionException {
try {
String prompt = PromptBuilder.build(fqcn, classSource, taxonomyText);
MessageRequest payload = new MessageRequest(options.modelName(), SYSTEM_PROMPT,
List.of(new ContentMessage("user", List.of(new ContentBlock("text", prompt)))), 0.0, 2_000);
String requestBody = httpSupport.objectMapper().writeValueAsString(payload);
URI uri = URI.create(options.baseUrl() + "/v1/messages");
HttpRequest request = httpSupport.jsonPost(uri, requestBody, options.timeout())
.header("x-api-key", options.resolvedApiKey()).header("anthropic-version", "2023-06-01").build();
String responseBody = httpSupport.postJson(request);
MessageResponse response = httpSupport.objectMapper().readValue(responseBody, MessageResponse.class);
if (response.content == null || response.content.isEmpty()) {
throw new AiSuggestionException("No content returned by Anthropic");
}
String text = response.content.stream().filter(block -> "text".equals(block.type)).map(block -> block.text)
.filter(value -> value != null && !value.isBlank()).findFirst()
.orElseThrow(() -> new AiSuggestionException("Anthropic returned no text block"));
String json = JsonText.extractFirstJsonObject(text);
AiClassSuggestion suggestion = httpSupport.objectMapper().readValue(json, AiClassSuggestion.class);
return normalize(suggestion);
} catch (Exception e) {
throw new AiSuggestionException("Anthropic suggestion failed for " + fqcn, e);
}
}
/**
* Normalizes AI results returned by the provider.
*
* <p>
* This method ensures that collection fields are never {@code null} and removes
* malformed method entries that do not contain a valid method name.
* </p>
*
* <p>
* The normalization step protects the rest of the application from
* provider-side inconsistencies and guarantees that the resulting
* {@link AiClassSuggestion} object satisfies the expected invariants.
* </p>
*
* @param input raw suggestion returned by the provider
* @return normalized suggestion instance
*/
private static AiClassSuggestion normalize(AiClassSuggestion input) {
List<AiMethodSuggestion> methods = input.methods() == null ? List.of() : input.methods();
List<String> classTags = input.classTags() == null ? List.of() : input.classTags();
List<AiMethodSuggestion> normalizedMethods = methods.stream()
.filter(method -> method != null && method.methodName() != null && !method.methodName().isBlank())
.map(method -> new AiMethodSuggestion(method.methodName(), method.securityRelevant(),
method.displayName(), method.tags() == null ? List.of() : method.tags(), method.reason()))
.toList();
return new AiClassSuggestion(input.className(), input.classSecurityRelevant(), classTags, input.classReason(),
normalizedMethods);
}
/**
* Request payload sent to the Anthropic message API.
*
* <p>
* This record models the JSON structure expected by the {@code /v1/messages}
* endpoint and is serialized using Jackson before transmission.
* </p>
*
* @param model model identifier
* @param system system prompt controlling model behavior
* @param messages list of message objects forming the conversation
* @param temperature sampling temperature
* @param maxTokens maximum token count for the response
*/
private record MessageRequest(String model, String system, List<ContentMessage> messages, Double temperature,
@JsonProperty("max_tokens") Integer maxTokens) {
}
/**
* Message container used by the Anthropic message API.
*
* @param role role of the message sender (for example {@code user})
* @param content message content blocks
*/
private record ContentMessage(String role, List<ContentBlock> content) {
}
/**
* Individual content block within a message payload.
*
* @param type block type (for example {@code text})
* @param text textual content of the block
*/
private record ContentBlock(String type, String text) {
}
/**
* Partial response model returned by the Anthropic API.
*
* <p>
* Only the fields required by this client are mapped. Additional fields are
* ignored to maintain forward compatibility with API changes.
* </p>
*/
@JsonIgnoreProperties(ignoreUnknown = true)
private static final class MessageResponse {
public List<ResponseBlock> content;
}
/**
* Content block returned within a provider response.
*
* <p>
* The client scans these blocks to locate the first text segment containing the
* JSON classification result.
* </p>
*/
@JsonIgnoreProperties(ignoreUnknown = true)
private static final class ResponseBlock {
public String type;
public String text;
}
}

View File

@@ -0,0 +1,416 @@
package org.egothor.methodatlas.ai;
/**
* Provides the default built-in taxonomy used to guide AI-based security
* classification of JUnit test methods.
*
* <p>
* This class exposes a human-readable taxonomy definition that is supplied to
* the AI suggestion engine when no external taxonomy file is configured and
* {@link org.egothor.methodatlas.ai.AiOptions.TaxonomyMode#DEFAULT} is
* selected. The taxonomy defines the controlled vocabulary, decision rules, and
* naming conventions used when classifying security-relevant tests.
* </p>
*
* <h2>Purpose</h2>
*
* <p>
* The taxonomy is designed to improve classification consistency by providing
* the AI provider with a stable and explicit specification of:
* </p>
* <ul>
* <li>what constitutes a security-relevant test</li>
* <li>which security category tags are allowed</li>
* <li>how tags should be selected</li>
* <li>how security-oriented display names should be formed</li>
* </ul>
*
* <p>
* The default taxonomy favors readability and professional descriptive clarity.
* For a more compact taxonomy tuned specifically for model reliability, see
* {@link OptimizedSecurityTaxonomy}.
* </p>
*
* <p>
* This class is a non-instantiable utility holder.
* </p>
*
* @see OptimizedSecurityTaxonomy
* @see org.egothor.methodatlas.ai.AiSuggestionEngineImpl
* @see org.egothor.methodatlas.ai.AiOptions.TaxonomyMode
*/
public final class DefaultSecurityTaxonomy {
/**
* Prevents instantiation of this utility class.
*/
private DefaultSecurityTaxonomy() {
}
/**
* Returns the default built-in taxonomy text used for AI classification.
*
* <p>
* The returned text is intended to be embedded directly into provider prompts
* and therefore contains both conceptual guidance and operational
* classification rules. It defines:
* </p>
* <ul>
* <li>scope of security-relevant tests</li>
* <li>mandatory and optional tagging rules</li>
* <li>allowed taxonomy categories</li>
* <li>guidance for class-level versus method-level tagging</li>
* <li>display name conventions</li>
* <li>AI-oriented decision instructions</li>
* </ul>
*
* <p>
* The taxonomy includes the following category tags: {@code auth},
* {@code access-control}, {@code crypto}, {@code input-validation},
* {@code injection}, {@code data-protection}, {@code logging},
* {@code error-handling}, and {@code owasp}.
* </p>
*
* <p>
* The returned value is immutable text and may safely be reused across multiple
* AI requests.
* </p>
*
* @return default taxonomy text used to instruct AI classification
*
* @see OptimizedSecurityTaxonomy#text()
* @see org.egothor.methodatlas.ai.AiSuggestionEngineImpl
*/
public static String text() {
return """
SECURITY TEST TAGGING TAXONOMY
==============================
Purpose
-------
This taxonomy defines a controlled vocabulary for labeling security-relevant JUnit tests.
The goal is to enable automated classification of test methods that validate security
properties, controls, mitigations, or invariants.
The taxonomy is intentionally small and stable to avoid uncontrolled tag proliferation.
Classification Scope
--------------------
Applies to:
- JUnit 5 test classes and methods
- primarily unit tests
- integration tests may follow the same model when applicable
A test should be considered *security-relevant* if its failure could plausibly lead to:
- loss of confidentiality
- loss of integrity
- loss of availability
- unauthorized actions
- exposure of sensitive data
- bypass of security controls
Examples of security-relevant verification:
- access control decisions
- authentication or identity validation
- cryptographic correctness or misuse resistance
- input validation or canonicalization
- injection prevention
- safe handling of sensitive data
- correct security event logging
- secure error handling
Non-Security Tests (Out of Scope)
---------------------------------
Do NOT classify tests as security tests when they only verify:
- functional correctness unrelated to security
- performance characteristics
- UI behavior
- formatting or presentation logic
- internal implementation details with no security implications
If a test contains security logic but its intent is purely functional,
prefer NOT classifying it as a security test.
Tagging Model
-------------
Every security-relevant test MUST include:
@Tag("security")
and SHOULD include a descriptive display name:
@DisplayName("SECURITY: <control/property> - <scenario>")
Example:
@Test
@Tag("security")
@Tag("access-control")
@DisplayName("SECURITY: access control - deny non-owner account access")
Category tags provide additional classification.
Allowed Category Tags
---------------------
Only the following category tags may be used.
Use lowercase and hyphenated names exactly as defined.
1. auth
-------
Authentication and identity validation.
Use when the test validates:
- login or credential verification
- authentication workflows
- MFA enforcement
- token validation
- session binding
- subject or identity claims
Typical signals:
- login handlers
- token parsing
- identity providers
- credential verification
2. access-control
-----------------
Authorization and permission enforcement.
Use when the test validates:
- role-based or attribute-based access control
- ACL evaluation
- policy decision logic
- object ownership checks
- deny-by-default behavior
Typical signals:
- permission checks
- policy evaluation
- role validation
- ownership checks
3. crypto
---------
Cryptographic correctness or misuse resistance.
Use when the test validates:
- encryption and decryption
- signature verification
- key handling
- nonce or IV requirements
- secure randomness
- hashing or key derivation
Typical signals:
- cryptographic libraries
- key material
- ciphersuites
- signature APIs
4. input-validation
-------------------
Validation or normalization of untrusted inputs.
Use when the test validates:
- schema validation
- format validation
- canonicalization rules
- path normalization
- rejection of malformed inputs
Typical signals:
- parsing logic
- validation layers
- normalization routines
5. injection
------------
Prevention of injection vulnerabilities.
Use when the test validates protection against:
- SQL injection
- NoSQL injection
- command injection
- template injection
- XPath/LDAP injection
- deserialization attacks
Typical signals:
- query construction
- escaping
- parameterization
- command execution
6. data-protection
------------------
Protection of sensitive or regulated data.
Use when the test validates:
- encryption of stored data
- encryption in transit at unit level
- masking or redaction
- secret handling
- secure storage of credentials
Typical signals:
- PII handling
- encryption enforcement
- secrets management
7. logging
----------
Security event logging and auditability.
Use when the test validates:
- absence of secrets in logs
- presence of required audit events
- correct security event messages
- traceability identifiers
Typical signals:
- log assertions
- audit event emission
8. error-handling
-----------------
Security-safe error behavior.
Use when the test validates:
- absence of information leakage
- sanitized error messages
- safe fallback behavior
- secure default behavior on failure
Typical signals:
- negative path tests
- exception handling checks
9. owasp
--------
Optional mapping tag linking the test to a widely recognized OWASP risk category.
Use when the test explicitly addresses a vulnerability class defined by the
OWASP Top 10 or related OWASP guidance.
Examples include tests targeting:
- injection vulnerabilities
- broken authentication
- broken access control
- sensitive data exposure
- security misconfiguration
- insecure deserialization
- cross-site scripting
Important:
The `owasp` tag should only be used when the test clearly maps to a well-known
OWASP vulnerability category.
When possible, the `owasp` tag should be combined with a more precise category
from this taxonomy (for example `injection` or `access-control`).
Tagging Rules
-------------
Mandatory rules:
- Every security-relevant test MUST include the tag:
security
- Security tests SHOULD include 1 to 3 category tags.
- Category tags MUST be selected only from the allowed taxonomy.
- Do NOT invent new tags.
Class-Level vs Method-Level Tags
--------------------------------
Class-level tags may be used when:
- all tests in the class validate the same security concern.
Method-level tags should be used when:
- only some tests are security-relevant
- tests cover different security categories
Display Name Convention
-----------------------
Security test names should follow this format:
SECURITY: <security property> - <test scenario>
Examples:
SECURITY: access control - deny non-owner account access
SECURITY: crypto - reject reused nonce in AEAD
SECURITY: input validation - reject path traversal sequences
AI Classification Guidance
--------------------------
When classifying tests:
1. Identify the security property validated.
2. Determine whether the test enforces or validates a security control.
3. Assign the umbrella tag `security` when applicable.
4. Select 13 category tags that best describe the security concern.
5. Prefer specific categories over broad ones.
6. Avoid assigning tags when the security intent is unclear.
""";
}
}

View File

@@ -0,0 +1,160 @@
package org.egothor.methodatlas.ai;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* Small HTTP utility component used by AI provider clients for outbound network
* communication and JSON processing support.
*
* <p>
* This class centralizes common HTTP-related functionality required by the AI
* provider integrations, including:
* </p>
* <ul>
* <li>creation of a configured {@link HttpClient}</li>
* <li>provision of a shared Jackson {@link ObjectMapper}</li>
* <li>execution of JSON-oriented HTTP requests</li>
* <li>construction of JSON {@code POST} requests</li>
* </ul>
*
* <p>
* The helper is intentionally lightweight and provider-agnostic. It does not
* implement provider-specific authentication, endpoint selection, or response
* normalization logic; those responsibilities remain in the concrete provider
* clients.
* </p>
*
* <p>
* The internally managed {@link ObjectMapper} is configured to ignore unknown
* JSON properties so that provider response deserialization remains resilient
* to non-breaking API changes.
* </p>
*
* <p>
* Instances of this class are immutable after construction.
* </p>
*
* @see HttpClient
* @see ObjectMapper
* @see AiProviderClient
*/
public final class HttpSupport {
private final HttpClient httpClient;
private final ObjectMapper objectMapper;
/**
* Creates a new HTTP support helper with the specified connection timeout.
*
* <p>
* The supplied timeout is used as the connection timeout of the underlying
* {@link HttpClient}. Request-specific timeouts may still be configured
* independently on individual {@link HttpRequest} instances.
* </p>
*
* <p>
* The constructor also initializes a Jackson {@link ObjectMapper} configured
* with {@link DeserializationFeature#FAIL_ON_UNKNOWN_PROPERTIES} disabled.
* </p>
*
* @param timeout connection timeout used for the underlying HTTP client
*/
public HttpSupport(Duration timeout) {
this.httpClient = HttpClient.newBuilder().connectTimeout(timeout).build();
this.objectMapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}
/**
* Returns the configured HTTP client used by this helper.
*
* @return configured HTTP client instance
*/
public HttpClient httpClient() {
return httpClient;
}
/**
* Returns the configured Jackson object mapper used for JSON serialization and
* deserialization.
*
* @return configured object mapper instance
*/
public ObjectMapper objectMapper() {
return objectMapper;
}
/**
* Executes an HTTP request expected to return a JSON response body and returns
* the response content as text.
*
* <p>
* The method sends the supplied request using the internally configured
* {@link HttpClient}. Responses with HTTP status codes outside the successful
* {@code 2xx} range are treated as failures and cause an {@link IOException} to
* be thrown containing both the status code and response body.
* </p>
*
* <p>
* Despite the method name, the request itself is not required to be a
* {@code POST} request; the method simply executes the provided request and
* validates that the response indicates success.
* </p>
*
* @param request HTTP request to execute
* @return response body as text
*
* @throws IOException if request execution fails or if the HTTP
* response status code is outside the successful
* {@code 2xx} range
* @throws InterruptedException if the calling thread is interrupted while
* waiting for the response
*/
public String postJson(HttpRequest request) throws IOException, InterruptedException {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
int statusCode = response.statusCode();
if (statusCode < 200 || statusCode >= 300) {
throw new IOException("HTTP " + statusCode + ": " + response.body());
}
return response.body();
}
/**
* Creates a JSON-oriented HTTP {@code POST} request builder.
*
* <p>
* The returned builder is preconfigured with:
* </p>
* <ul>
* <li>the supplied target {@link URI}</li>
* <li>the supplied request timeout</li>
* <li>{@code Content-Type: application/json}</li>
* <li>a {@code POST} request body containing the supplied JSON text</li>
* </ul>
*
* <p>
* Callers may further customize the returned builder, for example by adding
* authentication or provider-specific headers, before invoking
* {@link HttpRequest.Builder#build()}.
* </p>
*
* @param uri target URI of the request
* @param body serialized JSON request body
* @param timeout request timeout
* @return preconfigured HTTP request builder for a JSON {@code POST} request
*/
public HttpRequest.Builder jsonPost(URI uri, String body, Duration timeout) {
return HttpRequest.newBuilder(uri).timeout(timeout).header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body));
}
}

View File

@@ -0,0 +1,77 @@
package org.egothor.methodatlas.ai;
/**
* Utility methods for extracting JSON fragments from free-form text produced by
* AI model responses.
*
* <p>
* Some AI providers may return textual responses that contain additional
* commentary or formatting around the actual JSON payload requested by the
* application. This helper provides a minimal extraction mechanism that
* isolates the first JSON object found within such responses so that it can be
* deserialized safely.
* </p>
*
* <p>
* The current implementation performs a simple structural search for the first
* opening brace (<code>{</code>) and the last closing brace (<code>}</code>),
* and returns the substring spanning those positions.
* </p>
*
* <p>
* The method is intentionally tolerant of provider-specific output formats and
* is primarily used as a defensive measure to recover valid JSON payloads from
* otherwise well-formed responses.
* </p>
*
* <p>
* This class is a non-instantiable utility holder.
* </p>
*
* @see AiSuggestionException
* @see AiClassSuggestion
*/
public final class JsonText {
/**
* Prevents instantiation of this utility class.
*/
private JsonText() {
}
/**
* Extracts the first JSON object found within a text response.
*
* <p>
* The method scans the supplied text for the first occurrence of an opening
* brace (<code>{</code>) and the last occurrence of a closing brace
* (<code>}</code>). The substring between these positions (inclusive) is
* returned as the extracted JSON object.
* </p>
*
* <p>
* This approach allows the application to recover structured data even when the
* model returns additional natural-language content or formatting around the
* JSON payload.
* </p>
*
* @param text text returned by the AI model
* @return extracted JSON object as text
*
* @throws AiSuggestionException if the input text is empty or if no valid JSON
* object boundaries can be located
*/
public static String extractFirstJsonObject(String text) throws AiSuggestionException {
if (text == null || text.isBlank()) {
throw new AiSuggestionException("Model returned an empty response");
}
int start = text.indexOf('{');
int end = text.lastIndexOf('}');
if (start < 0 || end < 0 || end < start) {
throw new AiSuggestionException("Model response does not contain a JSON object: " + text);
}
return text.substring(start, end + 1);
}
}

View File

@@ -0,0 +1,277 @@
package org.egothor.methodatlas.ai;
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* {@link AiProviderClient} implementation for a locally running
* <a href="https://ollama.ai/">Ollama</a> inference service.
*
* <p>
* This client submits taxonomy-guided classification prompts to the Ollama HTTP
* API and converts the returned model response into the internal
* {@link AiClassSuggestion} representation used by the MethodAtlas AI
* subsystem.
* </p>
*
* <h2>Operational Responsibilities</h2>
*
* <ul>
* <li>verifying local Ollama availability</li>
* <li>constructing chat-style inference requests</li>
* <li>injecting the system prompt and taxonomy-guided user prompt</li>
* <li>executing HTTP requests against the Ollama API</li>
* <li>extracting and normalizing JSON classification results</li>
* </ul>
*
* <p>
* The client uses the Ollama {@code /api/chat} endpoint for inference and the
* {@code /api/tags} endpoint as a lightweight availability probe.
* </p>
*
* <p>
* This implementation is intended primarily for local, offline, or
* privacy-preserving inference scenarios where source code should not be sent
* to an external provider.
* </p>
*
* @see AiProviderClient
* @see AiProviderFactory
* @see AiSuggestionEngine
*/
public final class OllamaClient implements AiProviderClient {
/**
* System prompt used to enforce deterministic, machine-readable model output.
*
* <p>
* The prompt instructs the model to behave as a strict classification engine
* and to return JSON only, without markdown fences or explanatory prose, so
* that the response can be parsed automatically.
* </p>
*/
private static final String SYSTEM_PROMPT = """
You are a precise software security classification engine.
You classify JUnit 5 tests and return strict JSON only.
Never include markdown fences, explanations, or extra text.
""";
private final AiOptions options;
private final HttpSupport httpSupport;
/**
* Creates a new Ollama client using the supplied runtime configuration.
*
* <p>
* The configuration determines the base URL of the Ollama service, the model
* identifier, and request timeout values used by this client.
* </p>
*
* @param options AI runtime configuration
*/
public OllamaClient(AiOptions options) {
this.options = options;
this.httpSupport = new HttpSupport(options.timeout());
}
/**
* Determines whether the configured Ollama service is reachable.
*
* <p>
* The method performs a lightweight availability probe against the
* {@code /api/tags} endpoint. If the endpoint responds successfully, the
* provider is considered available.
* </p>
*
* <p>
* Any exception raised during the probe is treated as an indication that the
* provider is unavailable.
* </p>
*
* @return {@code true} if the Ollama service is reachable; {@code false}
* otherwise
*/
@Override
public boolean isAvailable() {
try {
URI uri = URI.create(options.baseUrl() + "/api/tags");
HttpRequest request = HttpRequest.newBuilder(uri).GET().timeout(options.timeout()).build();
httpSupport.httpClient().send(request, HttpResponse.BodyHandlers.discarding());
return true;
} catch (Exception e) {
return false;
}
}
/**
* Submits a classification request to the Ollama chat API for the specified
* test class.
*
* <p>
* The request consists of:
* </p>
* <ul>
* <li>a system prompt enforcing strict JSON output</li>
* <li>a user prompt containing the test class source and taxonomy text</li>
* <li>provider options such as deterministic temperature settings</li>
* </ul>
*
* <p>
* The returned response is expected to contain a JSON object in the message
* content field. That JSON text is extracted, deserialized into an
* {@link AiClassSuggestion}, and then normalized before being returned.
* </p>
*
* @param fqcn fully qualified class name being analyzed
* @param classSource complete source code of the class being analyzed
* @param taxonomyText taxonomy definition guiding classification
* @return normalized AI classification result
*
* @throws AiSuggestionException if the request fails, if the provider returns
* invalid content, or if response deserialization
* fails
*/
@Override
public AiClassSuggestion suggestForClass(String fqcn, String classSource, String taxonomyText)
throws AiSuggestionException {
try {
String prompt = PromptBuilder.build(fqcn, classSource, taxonomyText);
ChatRequest payload = new ChatRequest(options.modelName(),
List.of(new Message("system", SYSTEM_PROMPT), new Message("user", prompt)), false,
new Options(0.0));
String requestBody = httpSupport.objectMapper().writeValueAsString(payload);
URI uri = URI.create(options.baseUrl() + "/api/chat");
HttpRequest request = httpSupport.jsonPost(uri, requestBody, options.timeout()).build();
String responseBody = httpSupport.postJson(request);
ChatResponse response = httpSupport.objectMapper().readValue(responseBody, ChatResponse.class);
if (response.message == null || response.message.content == null || response.message.content.isBlank()) {
throw new AiSuggestionException("Ollama returned no message content");
}
String json = JsonText.extractFirstJsonObject(response.message.content);
AiClassSuggestion suggestion = httpSupport.objectMapper().readValue(json, AiClassSuggestion.class);
return normalize(suggestion);
} catch (Exception e) {
throw new AiSuggestionException("Ollama suggestion failed for " + fqcn, e);
}
}
/**
* Normalizes a provider response into the application's internal result
* invariants.
*
* <p>
* The method ensures that collection-valued fields are never {@code null} and
* removes malformed method entries that do not define a usable method name.
* </p>
*
* @param input raw suggestion returned by the provider
* @return normalized suggestion
*/
private static AiClassSuggestion normalize(AiClassSuggestion input) {
List<AiMethodSuggestion> methods = input.methods() == null ? List.of() : input.methods();
List<String> classTags = input.classTags() == null ? List.of() : input.classTags();
List<AiMethodSuggestion> normalizedMethods = methods.stream()
.filter(method -> method != null && method.methodName() != null && !method.methodName().isBlank())
.map(method -> new AiMethodSuggestion(method.methodName(), method.securityRelevant(),
method.displayName(), method.tags() == null ? List.of() : method.tags(), method.reason()))
.toList();
return new AiClassSuggestion(input.className(), input.classSecurityRelevant(), classTags, input.classReason(),
normalizedMethods);
}
/**
* Request payload sent to the Ollama chat API.
*
* <p>
* This record models the JSON structure expected by the {@code /api/chat}
* endpoint.
* </p>
*
* @param model model identifier used for inference
* @param messages ordered chat messages sent to the model
* @param stream whether streaming responses are requested
* @param options provider-specific inference options
*/
private record ChatRequest(String model, List<Message> messages, boolean stream, Options options) {
}
/**
* Chat message sent to the Ollama API.
*
* @param role logical role of the message sender, such as {@code system} or
* {@code user}
* @param content textual message content
*/
private record Message(String role, String content) {
}
/**
* Provider-specific inference options supplied to the Ollama API.
*
* <p>
* Currently only the {@code temperature} sampling parameter is configured.
* Temperature controls the randomness of model output:
* </p>
*
* <ul>
* <li>{@code 0.0} produces deterministic output</li>
* <li>higher values increase variation and creativity</li>
* </ul>
*
* <p>
* The MethodAtlas AI integration explicitly sets {@code temperature} to
* {@code 0.0} in order to obtain stable, repeatable classification results and
* strictly formatted JSON output suitable for automated parsing.
* </p>
*
* <p>
* Allowing stochastic sampling would significantly increase the probability
* that the model produces explanatory text, formatting variations, or malformed
* JSON responses, which would break the downstream deserialization pipeline.
* </p>
*
* @param temperature sampling temperature controlling response randomness
*/
private record Options(@JsonProperty("temperature") Double temperature) {
}
/**
* Partial response model returned by the Ollama chat API.
*
* <p>
* Only the fields required by this client are modeled. Unknown properties are
* ignored to maintain compatibility with future API extensions.
* </p>
*/
@JsonIgnoreProperties(ignoreUnknown = true)
private static final class ChatResponse {
public ResponseMessage message;
}
/**
* Message payload returned within an Ollama chat response.
*
* <p>
* The client reads the {@link #content} field and expects it to contain the
* JSON classification result generated by the model.
* </p>
*/
@JsonIgnoreProperties(ignoreUnknown = true)
private static final class ResponseMessage {
public String content;
}
}

View File

@@ -0,0 +1,257 @@
package org.egothor.methodatlas.ai;
import java.net.URI;
import java.net.http.HttpRequest;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* {@link AiProviderClient} implementation for AI providers that expose an
* OpenAI-compatible chat completion API.
*
* <p>
* This client supports providers that implement the OpenAI-style
* {@code /v1/chat/completions} endpoint. The same implementation is used for:
* </p>
*
* <ul>
* <li>{@link AiProvider#OPENAI}</li>
* <li>{@link AiProvider#OPENROUTER}</li>
* </ul>
*
* <p>
* The client constructs a chat-style prompt consisting of a system message
* defining the classification rules and a user message containing the test
* class source together with the taxonomy definition. The model response is
* expected to contain a JSON object describing the security classification.
* </p>
*
* <h2>Operational Responsibilities</h2>
*
* <ul>
* <li>constructing OpenAI-compatible chat completion requests</li>
* <li>injecting the taxonomy-driven classification prompt</li>
* <li>performing authenticated HTTP requests</li>
* <li>extracting JSON content from the model response</li>
* <li>normalizing the result into {@link AiClassSuggestion}</li>
* </ul>
*
* <p>
* The implementation is provider-neutral for APIs that follow the OpenAI
* protocol, which allows reuse across multiple compatible services such as
* OpenRouter.
* </p>
*
* <p>
* Instances are typically created through
* {@link AiProviderFactory#create(AiOptions)}.
* </p>
*
* @see AiProvider
* @see AiProviderClient
* @see AiProviderFactory
*/
public final class OpenAiCompatibleClient implements AiProviderClient {
/**
* System prompt instructing the model to operate strictly as a classification
* engine and to return machine-readable JSON output.
*
* <p>
* The prompt intentionally forbids explanatory text and markdown formatting to
* ensure that the returned content can be parsed reliably by the application.
* </p>
*/
private static final String SYSTEM_PROMPT = """
You are a precise software security classification engine.
You classify JUnit 5 tests and return strict JSON only.
Never include markdown fences, explanations, or extra text.
""";
private final AiOptions options;
private final HttpSupport httpSupport;
/**
* Creates a new client for an OpenAI-compatible provider.
*
* <p>
* The supplied configuration determines the provider endpoint, model name,
* authentication method, request timeout, and other runtime parameters.
* </p>
*
* @param options AI runtime configuration
*/
public OpenAiCompatibleClient(AiOptions options) {
this.options = options;
this.httpSupport = new HttpSupport(options.timeout());
}
/**
* Determines whether the configured provider can be used in the current runtime
* environment.
*
* <p>
* For OpenAI-compatible providers, availability is determined by the presence
* of a usable API key resolved through {@link AiOptions#resolvedApiKey()}.
* </p>
*
* @return {@code true} if a usable API key is available
*/
@Override
public boolean isAvailable() {
return options.resolvedApiKey() != null && !options.resolvedApiKey().isBlank();
}
/**
* Submits a classification request to an OpenAI-compatible chat completion API.
*
* <p>
* The request payload includes:
* </p>
*
* <ul>
* <li>the configured model identifier</li>
* <li>a system prompt defining classification rules</li>
* <li>a user prompt containing the test class source and taxonomy</li>
* <li>a deterministic temperature setting</li>
* </ul>
*
* <p>
* When the selected provider is {@link AiProvider#OPENROUTER}, additional HTTP
* headers are included to identify the calling application.
* </p>
*
* <p>
* The response is expected to contain a JSON object in the message content
* field. The JSON text is extracted and deserialized into an
* {@link AiClassSuggestion}.
* </p>
*
* @param fqcn fully qualified class name being analyzed
* @param classSource complete source code of the class
* @param taxonomyText taxonomy definition guiding classification
*
* @return normalized classification result
*
* @throws AiSuggestionException if the provider request fails, the model
* response is invalid, or JSON deserialization
* fails
*/
@Override
public AiClassSuggestion suggestForClass(String fqcn, String classSource, String taxonomyText)
throws AiSuggestionException {
try {
String prompt = PromptBuilder.build(fqcn, classSource, taxonomyText);
ChatRequest payload = new ChatRequest(options.modelName(),
List.of(new Message("system", SYSTEM_PROMPT), new Message("user", prompt)), 0.0);
String requestBody = httpSupport.objectMapper().writeValueAsString(payload);
URI uri = URI.create(options.baseUrl() + "/v1/chat/completions");
HttpRequest.Builder requestBuilder = httpSupport.jsonPost(uri, requestBody, options.timeout())
.header("Authorization", "Bearer " + options.resolvedApiKey());
if (options.provider() == AiProvider.OPENROUTER) {
requestBuilder.header("HTTP-Referer", "https://methodatlas.local");
requestBuilder.header("X-Title", "MethodAtlas");
}
String responseBody = httpSupport.postJson(requestBuilder.build());
ChatResponse response = httpSupport.objectMapper().readValue(responseBody, ChatResponse.class);
if (response.choices == null || response.choices.isEmpty()) {
throw new AiSuggestionException("No choices returned by model");
}
String content = response.choices.get(0).message.content;
String json = JsonText.extractFirstJsonObject(content);
AiClassSuggestion suggestion = httpSupport.objectMapper().readValue(json, AiClassSuggestion.class);
return normalize(suggestion);
} catch (Exception e) {
throw new AiSuggestionException("OpenAI-compatible suggestion failed for " + fqcn, e);
}
}
/**
* Normalizes provider results to ensure structural invariants expected by the
* application.
*
* <p>
* The method replaces {@code null} collections with empty lists and removes
* malformed method entries that do not contain a valid method name.
* </p>
*
* @param input raw suggestion returned by the provider
* @return normalized suggestion instance
*/
private static AiClassSuggestion normalize(AiClassSuggestion input) {
List<AiMethodSuggestion> methods = input.methods() == null ? List.of() : input.methods();
List<String> classTags = input.classTags() == null ? List.of() : input.classTags();
List<AiMethodSuggestion> normalizedMethods = methods.stream()
.filter(method -> method != null && method.methodName() != null && !method.methodName().isBlank())
.map(method -> new AiMethodSuggestion(method.methodName(), method.securityRelevant(),
method.displayName(), method.tags() == null ? List.of() : method.tags(), method.reason()))
.toList();
return new AiClassSuggestion(input.className(), input.classSecurityRelevant(), classTags, input.classReason(),
normalizedMethods);
}
/**
* Request payload for an OpenAI-compatible chat completion request.
*
* @param model model identifier used for inference
* @param messages ordered chat messages sent to the model
* @param temperature sampling temperature controlling response variability
*/
private record ChatRequest(String model, List<Message> messages, @JsonProperty("temperature") Double temperature) {
}
/**
* Chat message included in the request payload.
*
* @param role logical role of the message sender, such as {@code system} or
* {@code user}
* @param content textual message content
*/
private record Message(String role, String content) {
}
/**
* Partial response model returned by the chat completion API.
*
* <p>
* Only fields required for extracting the model response are mapped. Unknown
* properties are ignored to preserve compatibility with provider API changes.
* </p>
*/
@JsonIgnoreProperties(ignoreUnknown = true)
private static final class ChatResponse {
public List<Choice> choices;
}
/**
* Individual completion choice returned by the provider.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
private static final class Choice {
public ResponseMessage message;
}
/**
* Message payload returned inside a completion choice.
*
* <p>
* The {@code content} field is expected to contain the JSON classification
* result generated by the model.
* </p>
*/
@JsonIgnoreProperties(ignoreUnknown = true)
private static final class ResponseMessage {
public String content;
}
}

View File

@@ -0,0 +1,233 @@
package org.egothor.methodatlas.ai;
/**
* Provides the optimized built-in taxonomy used to guide AI-based security
* classification when prompt compactness and model reliability are prioritized.
*
* <p>
* This class supplies a condensed taxonomy definition intended for use with
* {@link org.egothor.methodatlas.ai.AiOptions.TaxonomyMode#OPTIMIZED}. In
* contrast to {@link DefaultSecurityTaxonomy}, this variant is structured to
* improve AI classification consistency by reducing prompt verbosity while
* preserving the same controlled category set and classification intent.
* </p>
*
* <h2>Design Goals</h2>
*
* <ul>
* <li>minimize prompt length without changing the supported taxonomy</li>
* <li>increase deterministic model behavior</li>
* <li>reduce ambiguity in category selection</li>
* <li>preserve professional terminology and decision rules</li>
* </ul>
*
* <p>
* The taxonomy text returned by this class is intended to be embedded directly
* into AI prompts and therefore favors concise, machine-oriented instruction
* structure over explanatory prose.
* </p>
*
* <p>
* This class is a non-instantiable utility holder.
* </p>
*
* @see DefaultSecurityTaxonomy
* @see org.egothor.methodatlas.ai.AiSuggestionEngineImpl
* @see org.egothor.methodatlas.ai.AiOptions.TaxonomyMode
*/
public final class OptimizedSecurityTaxonomy {
/**
* Prevents instantiation of this utility class.
*/
private OptimizedSecurityTaxonomy() {
}
/**
* Returns the optimized built-in taxonomy text used for AI classification.
*
* <p>
* The returned taxonomy is a compact instruction set designed for large
* language models performing security classification of JUnit test methods. It
* preserves the same controlled tag set as the default taxonomy while
* presenting the rules in a shorter, more model-oriented structure.
* </p>
*
* <p>
* The taxonomy defines:
* </p>
* <ul>
* <li>the meaning of a security-relevant test</li>
* <li>the mandatory {@code security} umbrella tag</li>
* <li>the allowed category tags</li>
* <li>selection rules for assigning taxonomy tags</li>
* <li>guidance for use of the optional {@code owasp} tag</li>
* <li>the required {@code SECURITY: <property> - <scenario>} display name
* format</li>
* </ul>
*
* <p>
* This optimized variant is suitable when improved model consistency or shorter
* prompt size is more important than human-oriented explanatory wording.
* </p>
*
* @return optimized taxonomy text used to instruct AI classification
*
* @see DefaultSecurityTaxonomy#text()
* @see org.egothor.methodatlas.ai.AiSuggestionEngineImpl
*/
public static String text() {
return """
SECURITY TEST CLASSIFICATION SPECIFICATION
==========================================
Goal
----
Classify JUnit 5 test methods that validate security properties.
The output MUST follow the allowed tag taxonomy and MUST NOT introduce new tags.
Security-Relevant Test Definition
---------------------------------
A test is security-relevant when it verifies any of the following:
• authentication behavior
• authorization decisions
• cryptographic correctness
• validation of untrusted input
• protection against injection attacks
• protection of sensitive data
• security event logging
• secure error handling
If failure of the test could allow:
• unauthorized access
• data exposure
• privilege escalation
• security control bypass
then the test is security-relevant.
Mandatory Tag
-------------
Every security-relevant test MUST contain:
security
Allowed Category Tags
---------------------
Only the following tags are permitted:
auth
access-control
crypto
input-validation
injection
data-protection
logging
error-handling
owasp
Category Semantics
------------------
auth
authentication validation
identity verification
credential checks
token/session validation
access-control
authorization enforcement
permission checks
role evaluation
ownership validation
crypto
encryption/decryption
signature verification
key usage
nonce/IV rules
hashing or key derivation
input-validation
validation of untrusted inputs
canonicalization
malformed input rejection
path normalization
injection
protection against injection attacks
SQL/NoSQL injection
command injection
template injection
deserialization vulnerabilities
data-protection
encryption of sensitive data
secret handling
PII protection
secure storage
logging
security event logging
audit events
absence of secrets in logs
error-handling
safe error messages
no information leakage
safe fallback behavior
OWASP Tag
---------
The `owasp` tag indicates that the test validates protection against a vulnerability
category commonly described in OWASP guidance such as:
• injection
• broken authentication
• broken access control
• security misconfiguration
• sensitive data exposure
• insecure deserialization
• cross-site scripting
The `owasp` tag should only be used when the test clearly targets a known
OWASP vulnerability category.
Prefer combining `owasp` with a more precise taxonomy tag.
Tag Selection Rules
-------------------
1. If a test validates a security property → include `security`.
2. Add 13 additional category tags when applicable.
3. Prefer the most specific tag.
4. Do not assign tags when security relevance is unclear.
5. Never invent new tags.
Display Name Format
-------------------
SECURITY: <security property> - <scenario>
Examples:
SECURITY: access control - deny non-owner account access
SECURITY: crypto - reject reused nonce in AEAD
SECURITY: input validation - reject path traversal sequences
""";
}
}

View File

@@ -0,0 +1,136 @@
package org.egothor.methodatlas.ai;
/**
* Utility responsible for constructing the prompt supplied to AI providers for
* security classification of JUnit test classes.
*
* <p>
* The prompt produced by this class combines several components into a single
* instruction payload:
* </p>
*
* <ul>
* <li>classification instructions for the AI model</li>
* <li>a controlled security taxonomy definition</li>
* <li>strict output formatting rules</li>
* <li>the fully qualified class name</li>
* <li>the complete source code of the analyzed test class</li>
* </ul>
*
* <p>
* The resulting prompt is passed to the configured AI provider and instructs
* the model to produce a deterministic JSON classification result describing
* security relevance and taxonomy tags for individual test methods.
* </p>
*
* <p>
* The prompt enforces a closed taxonomy and strict JSON output rules to ensure
* that the returned content can be parsed reliably by the application.
* </p>
*
* <p>
* This class is a non-instantiable utility holder.
* </p>
*
* @see AiSuggestionEngine
* @see AiProviderClient
* @see DefaultSecurityTaxonomy
* @see OptimizedSecurityTaxonomy
*/
public final class PromptBuilder {
/**
* Prevents instantiation of this utility class.
*/
private PromptBuilder() {
}
/**
* Builds the complete prompt supplied to an AI provider for security
* classification of a JUnit test class.
*
* <p>
* The generated prompt contains:
* </p>
*
* <ul>
* <li>task instructions describing the classification objective</li>
* <li>the security taxonomy definition controlling allowed tags</li>
* <li>strict output rules enforcing JSON-only responses</li>
* <li>a formal JSON schema describing the expected result structure</li>
* <li>the fully qualified class name of the analyzed test class</li>
* <li>the complete class source used as analysis input</li>
* </ul>
*
* <p>
* The taxonomy text supplied to this method is typically obtained from either
* {@link DefaultSecurityTaxonomy#text()} or
* {@link OptimizedSecurityTaxonomy#text()}, depending on the selected
* {@link AiOptions.TaxonomyMode}.
* </p>
*
* <p>
* The returned prompt is intended to be used as the content of a user message
* in chat-based inference APIs.
* </p>
*
* @param fqcn fully qualified class name of the test class being
* analyzed
* @param classSource complete source code of the test class
* @param taxonomyText taxonomy definition guiding classification
* @return formatted prompt supplied to the AI provider
*
* @see AiSuggestionEngine#suggestForClass(String, String)
*/
public static String build(String fqcn, String classSource, String taxonomyText) {
return """
You are analyzing a single JUnit 5 test class and suggesting security tags.
TASK
- Analyze the WHOLE class for context.
- Return per-method suggestions for JUnit test methods only.
- Do not invent methods that do not exist.
- Be conservative.
- If uncertain, classify the method as securityRelevant=false.
- Ignore pure functional / performance / UX tests unless they explicitly validate a security property.
CONTROLLED TAXONOMY
%s
OUTPUT RULES
- Return JSON only.
- No markdown.
- No prose outside JSON.
- Tags must come only from this closed set:
security, auth, access-control, crypto, input-validation, injection, data-protection, logging, error-handling, owasp
- If securityRelevant=true, tags MUST include "security".
- Add 1-3 tags total per method.
- displayName must be null when securityRelevant=false.
- If securityRelevant=true, displayName must match:
SECURITY: <control/property> - <scenario>
JSON SHAPE
{
"className": "string",
"classSecurityRelevant": true,
"classTags": ["security", "crypto"],
"classReason": "string",
"methods": [
{
"methodName": "string",
"securityRelevant": true,
"displayName": "SECURITY: ...",
"tags": ["security", "crypto"],
"reason": "string"
}
]
}
CLASS
FQCN: %s
SOURCE
%s
"""
.formatted(taxonomyText, fqcn, classSource);
}
}

View File

@@ -0,0 +1,109 @@
package org.egothor.methodatlas.ai;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Immutable lookup structure providing efficient access to AI-generated method
* suggestions by method name.
*
* <p>
* This class acts as an adapter between the class-level suggestion model
* returned by the AI subsystem ({@link AiClassSuggestion}) and the per-method
* processing logic used by {@code MethodAtlasApp}. It converts the list of
* {@link AiMethodSuggestion} objects into a name-indexed lookup map so that
* suggestions can be retrieved in constant time during traversal of parsed test
* methods.
* </p>
*
* <h2>Design Characteristics</h2>
*
* <ul>
* <li>immutable after construction</li>
* <li>null-safe for missing or malformed AI responses</li>
* <li>optimized for repeated method-level lookups</li>
* </ul>
*
* <p>
* If the AI response contains duplicate suggestions for the same method, only
* the first occurrence is retained.
* </p>
*
* @see AiClassSuggestion
* @see AiMethodSuggestion
*/
public final class SuggestionLookup {
private final Map<String, AiMethodSuggestion> byMethodName;
/**
* Creates a new immutable lookup instance backed by the supplied map.
*
* <p>
* The internal map is defensively copied to guarantee immutability of the
* lookup structure.
* </p>
*
* @param byMethodName mapping from method names to AI suggestions
*/
private SuggestionLookup(Map<String, AiMethodSuggestion> byMethodName) {
this.byMethodName = Map.copyOf(byMethodName);
}
/**
* Creates a lookup instance from a class-level AI suggestion result.
*
* <p>
* The method extracts all method suggestions contained in the supplied
* {@link AiClassSuggestion} and indexes them by method name. Entries with
* {@code null} suggestions, missing method names, or blank method names are
* ignored.
* </p>
*
* <p>
* If the suggestion contains no method entries, an empty lookup instance is
* returned.
* </p>
*
* @param suggestion AI classification result for a test class
* @return lookup structure providing fast access to method suggestions
*/
public static SuggestionLookup from(AiClassSuggestion suggestion) {
if (suggestion == null || suggestion.methods() == null || suggestion.methods().isEmpty()) {
return new SuggestionLookup(Map.of());
}
Map<String, AiMethodSuggestion> map = new HashMap<>();
for (AiMethodSuggestion methodSuggestion : suggestion.methods()) {
if (methodSuggestion == null) {
continue;
}
if (methodSuggestion.methodName() == null || methodSuggestion.methodName().isBlank()) {
continue;
}
map.putIfAbsent(methodSuggestion.methodName(), methodSuggestion);
}
return new SuggestionLookup(map);
}
/**
* Retrieves the AI suggestion for the specified method name.
*
* <p>
* If no suggestion exists for the method, an empty {@link Optional} is
* returned.
* </p>
*
* @param methodName name of the method being queried
* @return optional containing the suggestion if present
*
* @throws NullPointerException if {@code methodName} is {@code null}
*/
public Optional<AiMethodSuggestion> find(String methodName) {
Objects.requireNonNull(methodName, "methodName");
return Optional.ofNullable(byMethodName.get(methodName));
}
}

View File

@@ -0,0 +1,69 @@
/**
* AI integration layer for MethodAtlas providing automated security
* classification of JUnit test methods.
*
* <p>
* This package contains the infrastructure required to obtain AI-assisted
* suggestions for security tagging of JUnit 5 tests. The subsystem analyzes
* complete test classes, submits classification prompts to an AI provider, and
* converts the returned results into structured suggestions that can be
* consumed by the main application.
* </p>
*
* <h2>Architecture Overview</h2>
*
* <p>
* The AI subsystem follows a layered design:
* </p>
*
* <ul>
* <li><b>Engine layer</b>
* {@link org.egothor.methodatlas.ai.AiSuggestionEngine} orchestrates provider
* communication and taxonomy handling.</li>
*
* <li><b>Provider layer</b> implementations of
* {@link org.egothor.methodatlas.ai.AiProviderClient} integrate with specific
* AI services such as Ollama, OpenAI-compatible APIs, or Anthropic.</li>
*
* <li><b>Prompt construction</b>
* {@link org.egothor.methodatlas.ai.PromptBuilder} builds the prompt that
* instructs the model how to perform security classification.</li>
*
* <li><b>Taxonomy definition</b>
* {@link org.egothor.methodatlas.ai.DefaultSecurityTaxonomy} and
* {@link org.egothor.methodatlas.ai.OptimizedSecurityTaxonomy} define the
* controlled vocabulary used for tagging.</li>
*
* <li><b>Result normalization</b> AI responses are converted into the
* structured domain model ({@link org.egothor.methodatlas.ai.AiClassSuggestion}
* and {@link org.egothor.methodatlas.ai.AiMethodSuggestion}).</li>
* </ul>
*
* <h2>Security Considerations</h2>
*
* <p>
* Source code analyzed by the AI subsystem may contain sensitive information.
* For environments where external transmission of code is undesirable, the
* subsystem supports local inference through
* {@link org.egothor.methodatlas.ai.OllamaClient}.
* </p>
*
* <h2>Deterministic Output</h2>
*
* <p>
* The subsystem is designed to obtain deterministic, machine-readable output
* from AI models. Prompts enforce strict JSON responses and classification
* decisions are constrained by a controlled taxonomy.
* </p>
*
* <h2>Extensibility</h2>
*
* <p>
* Additional AI providers can be integrated by implementing
* {@link org.egothor.methodatlas.ai.AiProviderClient} and registering the
* implementation in {@link org.egothor.methodatlas.ai.AiProviderFactory}.
* </p>
*
* @since 1.0.1
*/
package org.egothor.methodatlas.ai;

View File

@@ -1,18 +1,111 @@
/**
* Provides the {@code MethodAtlasApp} command-line utility for scanning Java
* source trees for JUnit test methods and emitting per-method statistics.
* Provides the core command-line utility for analyzing Java test sources and
* producing structured metadata about JUnit test methods.
*
* <p>
* The primary entry point is {@link org.egothor.methodatlas.MethodAtlasApp}.
* The central component of this package is
* {@link org.egothor.methodatlas.MethodAtlasApp}, a command-line application
* that scans Java source trees, identifies JUnit Jupiter test methods, and
* emits per-method metadata describing the discovered tests.
* </p>
*
* <h2>Overview</h2>
*
* <p>
* The application traverses one or more directory roots, parses Java source
* files using the <a href="https://javaparser.org/">JavaParser</a> library, and
* extracts information about test methods declared in classes whose file names
* follow the conventional {@code *Test.java} pattern.
* </p>
*
* <p>
* Output modes:
* For each detected test method the application reports:
* </p>
*
* <ul>
* <li>CSV (default): {@code fqcn,method,loc,tags}</li>
* <li>Plain text: enabled by {@code -plain} as the first command-line
* argument</li>
* <li>fully-qualified class name (FQCN)</li>
* <li>test method name</li>
* <li>method size measured in lines of code (LOC)</li>
* <li>JUnit {@code @Tag} annotations declared on the method</li>
* </ul>
*
* <p>
* The resulting dataset can be used for test inventory generation, quality
* metrics, governance reporting, or security analysis of test coverage.
* </p>
*
* <h2>AI-Based Security Tagging</h2>
*
* <p>
* When enabled via command-line options, the application can augment the
* extracted test metadata with security classification suggestions produced by
* an AI provider. The AI integration is implemented through the
* {@link org.egothor.methodatlas.ai.AiSuggestionEngine} abstraction located in
* the {@code org.egothor.methodatlas.ai} package.
* </p>
*
* <p>
* In this mode the application sends each discovered test class to the
* configured AI provider and receives suggested security annotations, such as:
* </p>
*
* <ul>
* <li>whether the test validates a security property</li>
* <li>suggested {@code @DisplayName} describing the security intent</li>
* <li>taxonomy-based security tags</li>
* <li>optional explanatory reasoning</li>
* </ul>
*
* <p>
* These suggestions are merged with the source-derived metadata and emitted
* alongside the standard output fields.
* </p>
*
* <h2>Output Formats</h2>
*
* <p>
* The application supports two output modes:
* </p>
*
* <ul>
* <li><b>CSV (default)</b> <pre>{@code fqcn,method,loc,tags}</pre> or, when AI
* enrichment is enabled:
* <pre>{@code fqcn,method,loc,tags,ai_security_relevant,ai_display_name,ai_tags,ai_reason}</pre>
* </li>
* <li><b>Plain text</b>, enabled using the {@code -plain} command-line option
* </li>
* </ul>
*
* <h2>Typical Usage</h2>
*
* <pre>{@code
* java -jar methodatlas.jar /path/to/project
* }
* </pre>
*
* <pre>{@code
* java -jar methodatlas.jar -plain /path/to/project
* }
* </pre>
*
* <p>
* The command scans the specified source directory recursively and emits one
* output record per detected test method.
* </p>
*
* <h2>Implementation Notes</h2>
*
* <ul>
* <li>Parsing is performed using
* {@link com.github.javaparser.StaticJavaParser}.</li>
* <li>Test detection is based on JUnit Jupiter annotations such as
* {@code @Test}, {@code @ParameterizedTest}, and {@code @RepeatedTest}.</li>
* <li>Tag extraction supports both {@code @Tag} annotations and the container
* form {@code @Tags}.</li>
* </ul>
*
* @see org.egothor.methodatlas.MethodAtlasApp
* @see org.egothor.methodatlas.ai.AiSuggestionEngine
* @see com.github.javaparser.StaticJavaParser
*/
package org.egothor.methodatlas;

View File

@@ -0,0 +1,396 @@
package org.egothor.methodatlas;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.egothor.methodatlas.ai.AiClassSuggestion;
import org.egothor.methodatlas.ai.AiMethodSuggestion;
import org.egothor.methodatlas.ai.AiSuggestionEngineImpl;
import org.egothor.methodatlas.ai.AiSuggestionException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.MockedConstruction;
class MethodAtlasAppAiTest {
@Test
void csvMode_aiEnabled_withRealisticFixtures_emitsMergedAiSuggestions(@TempDir Path tempDir) throws Exception {
copyAllFixtures(tempDir);
try (MockedConstruction<AiSuggestionEngineImpl> mocked = mockConstruction(AiSuggestionEngineImpl.class,
(mock, context) -> {
when(mock.suggestForClass(eq("com.acme.tests.SampleOneTest"), anyString()))
.thenReturn(sampleOneSuggestion());
when(mock.suggestForClass(eq("com.acme.other.AnotherTest"), anyString()))
.thenReturn(anotherSuggestion());
when(mock.suggestForClass(eq("com.acme.security.AccessControlServiceTest"), anyString()))
.thenReturn(accessControlSuggestion());
when(mock.suggestForClass(eq("com.acme.storage.PathTraversalValidationTest"), anyString()))
.thenReturn(pathTraversalSuggestion());
when(mock.suggestForClass(eq("com.acme.audit.AuditLoggingTest"), anyString()))
.thenReturn(auditLoggingSuggestion());
})) {
String output = runAppCapturingStdout(new String[] { "-ai", tempDir.toString() });
List<String> lines = nonEmptyLines(output);
assertEquals(18, lines.size(), "Expected header + 17 method rows across 5 fixtures");
assertEquals("fqcn,method,loc,tags,ai_security_relevant,ai_display_name,ai_tags,ai_reason", lines.get(0));
Map<String, List<String>> rows = parseCsvAiRows(lines);
assertAiCsvRow(rows, "com.acme.security.AccessControlServiceTest", "shouldRejectUnauthenticatedRequest",
"security;authn", "true", "SECURITY: authentication - reject unauthenticated request",
"security;auth;access-control",
"The test verifies that anonymous access to a protected operation is rejected.");
assertAiCsvRow(rows, "com.acme.storage.PathTraversalValidationTest",
"shouldRejectRelativePathTraversalSequence", "security;validation", "true",
"SECURITY: input validation - reject path traversal sequence", "security;input-validation;owasp",
"The test rejects a classic parent-directory traversal payload.");
assertAiCsvRow(rows, "com.acme.audit.AuditLoggingTest", "shouldNotLogRawBearerToken", "security;logging",
"true", "SECURITY: logging - redact bearer token", "security;logging",
"The test ensures that sensitive bearer tokens are redacted before logging.");
assertAiCsvRow(rows, "com.acme.audit.AuditLoggingTest", "shouldFormatHumanReadableSupportMessage", "",
"false", "", "", "The test is functional formatting coverage and is not security-specific.");
assertAiCsvRow(rows, "com.acme.tests.SampleOneTest", "alpha", "fast;crypto", "true",
"SECURITY: crypto - validates encrypted happy path", "security;crypto",
"The test exercises a crypto-related security property.");
assertAiCsvRow(rows, "com.acme.tests.SampleOneTest", "beta", "param", "", "", "", "");
assertAiCsvRow(rows, "com.acme.other.AnotherTest", "delta", "", "false", "", "",
"The repeated test is not security-specific.");
assertFalse(rows.containsKey("com.acme.tests.SampleOneTest#ghostMethod"),
"AI-only invented methods must not appear in CLI output");
assertEquals(1, mocked.constructed().size(), "Expected one AI engine instance");
}
}
@Test
void plainMode_aiFailureForOneClass_continuesScanningAndFallsBackForThatClass(@TempDir Path tempDir)
throws Exception {
copyAllFixtures(tempDir);
try (MockedConstruction<AiSuggestionEngineImpl> mocked = mockConstruction(AiSuggestionEngineImpl.class,
(mock, context) -> {
when(mock.suggestForClass(eq("com.acme.tests.SampleOneTest"), anyString()))
.thenReturn(sampleOneSuggestion());
when(mock.suggestForClass(eq("com.acme.other.AnotherTest"), anyString()))
.thenReturn(anotherSuggestion());
when(mock.suggestForClass(eq("com.acme.security.AccessControlServiceTest"), anyString()))
.thenThrow(new AiSuggestionException("Simulated provider failure"));
when(mock.suggestForClass(eq("com.acme.storage.PathTraversalValidationTest"), anyString()))
.thenReturn(pathTraversalSuggestion());
when(mock.suggestForClass(eq("com.acme.audit.AuditLoggingTest"), anyString()))
.thenReturn(auditLoggingSuggestion());
})) {
String output = runAppCapturingStdout(new String[] { "-plain", "-ai", tempDir.toString() });
List<String> lines = nonEmptyLines(output);
assertEquals(17, lines.size(), "Expected one plain output line per discovered test method");
String failedClassLine = findLineContaining(lines,
"com.acme.security.AccessControlServiceTest, shouldRejectUnauthenticatedRequest,");
assertTrue(failedClassLine.contains("AI_SECURITY=-"));
assertTrue(failedClassLine.contains("AI_DISPLAY=-"));
assertTrue(failedClassLine.contains("AI_TAGS=-"));
assertTrue(failedClassLine.contains("AI_REASON=-"));
String unaffectedLine = findLineContaining(lines,
"com.acme.storage.PathTraversalValidationTest, shouldRejectRelativePathTraversalSequence,");
assertTrue(unaffectedLine.contains("AI_SECURITY=true"));
assertTrue(
unaffectedLine.contains("AI_DISPLAY=SECURITY: input validation - reject path traversal sequence"));
assertTrue(unaffectedLine.contains("AI_TAGS=security;input-validation;owasp"));
String nonSecurityLine = findLineContaining(lines,
"com.acme.audit.AuditLoggingTest, shouldFormatHumanReadableSupportMessage,");
assertTrue(nonSecurityLine.contains("AI_SECURITY=false"));
assertTrue(nonSecurityLine.contains("AI_DISPLAY=-"));
assertTrue(nonSecurityLine.contains("AI_TAGS=-"));
assertTrue(nonSecurityLine
.contains("AI_REASON=The test is functional formatting coverage and is not security-specific."));
assertEquals(1, mocked.constructed().size(), "Expected one AI engine instance");
}
}
@Test
void csvMode_oversizedClass_skipsAiLookup_andLeavesAiColumnsEmpty(@TempDir Path tempDir) throws Exception {
writeOversizedFixture(tempDir);
try (MockedConstruction<AiSuggestionEngineImpl> mocked = mockConstruction(AiSuggestionEngineImpl.class)) {
String output = runAppCapturingStdout(
new String[] { "-ai", "-ai-max-class-chars", "10", tempDir.toString() });
List<String> lines = nonEmptyLines(output);
assertEquals(2, lines.size(), "Expected header + 1 emitted method row");
assertEquals("fqcn,method,loc,tags,ai_security_relevant,ai_display_name,ai_tags,ai_reason", lines.get(0));
Map<String, List<String>> rows = parseCsvAiRows(lines);
List<String> row = rows.get("com.acme.big.HugeAiSkipTest#hugeSecurityTest");
assertNotNull(row, "Missing oversized-class row");
assertEquals(8, row.size());
assertEquals("security", row.get(3));
assertEquals("", row.get(4));
assertEquals("", row.get(5));
assertEquals("", row.get(6));
assertEquals("", row.get(7));
assertEquals(1, mocked.constructed().size(), "Expected one AI engine instance");
verify(mocked.constructed().get(0), never()).suggestForClass(anyString(), anyString());
}
}
private static AiClassSuggestion sampleOneSuggestion() {
return new AiClassSuggestion("com.acme.tests.SampleOneTest", true, List.of("security", "crypto"),
"Class contains crypto-related security coverage.",
List.of(new AiMethodSuggestion("alpha", true, "SECURITY: crypto - validates encrypted happy path",
List.of("security", "crypto"), "The test exercises a crypto-related security property."),
new AiMethodSuggestion("ghostMethod", true, "SECURITY: invented - should never appear",
List.of("security"), "This invented method must not be emitted by the CLI.")));
}
private static AiClassSuggestion anotherSuggestion() {
return new AiClassSuggestion("com.acme.other.AnotherTest", false, List.of(), "Class is not security-relevant.",
List.of(new AiMethodSuggestion("delta", false, null, List.of(),
"The repeated test is not security-specific.")));
}
private static AiClassSuggestion accessControlSuggestion() {
return new AiClassSuggestion("com.acme.security.AccessControlServiceTest", true,
List.of("security", "access-control"), "Class verifies authorization and authentication controls.",
List.of(new AiMethodSuggestion("shouldAllowOwnerToReadOwnStatement", true,
"SECURITY: access control - allow owner access", List.of("security", "access-control"),
"The test verifies that the resource owner is granted access."),
new AiMethodSuggestion("shouldAllowAdministratorToReadAnyStatement", true,
"SECURITY: access control - allow administrator access",
List.of("security", "access-control"),
"The test verifies privileged administrative access."),
new AiMethodSuggestion("shouldDenyForeignUserFromReadingAnotherUsersStatement", true,
"SECURITY: access control - deny foreign user access",
List.of("security", "access-control"),
"The test verifies that one user cannot access another user's statement."),
new AiMethodSuggestion("shouldRejectUnauthenticatedRequest", true,
"SECURITY: authentication - reject unauthenticated request",
List.of("security", "auth", "access-control"),
"The test verifies that anonymous access to a protected operation is rejected."),
new AiMethodSuggestion("shouldRenderFriendlyAccountLabel", false, null, List.of(),
"The test is purely presentational and not security-specific.")));
}
private static AiClassSuggestion pathTraversalSuggestion() {
return new AiClassSuggestion("com.acme.storage.PathTraversalValidationTest", true,
List.of("security", "input-validation"), "Class validates filesystem input handling.",
List.of(new AiMethodSuggestion("shouldRejectRelativePathTraversalSequence", true,
"SECURITY: input validation - reject path traversal sequence",
List.of("security", "input-validation", "owasp"),
"The test rejects a classic parent-directory traversal payload."),
new AiMethodSuggestion("shouldRejectNestedTraversalAfterNormalization", true,
"SECURITY: input validation - block normalized root escape",
List.of("security", "input-validation", "owasp"),
"The test verifies that normalized traversal cannot escape the upload root."),
new AiMethodSuggestion("shouldAllowSafePathInsideUploadRoot", true,
"SECURITY: input validation - allow safe normalized path",
List.of("security", "input-validation"),
"The test verifies that a normalized in-root path is accepted."),
new AiMethodSuggestion("shouldBuildDownloadFileName", false, null, List.of(),
"The test only formats a filename and is not security-specific.")));
}
private static AiClassSuggestion auditLoggingSuggestion() {
return new AiClassSuggestion("com.acme.audit.AuditLoggingTest", true, List.of("security", "logging"),
"Class verifies security-relevant logging and audit behavior.",
List.of(new AiMethodSuggestion("shouldWriteAuditEventForPrivilegeChange", true,
"SECURITY: logging - audit privilege change", List.of("security", "logging"),
"The test verifies audit logging of a privileged security action."),
new AiMethodSuggestion("shouldNotLogRawBearerToken", true,
"SECURITY: logging - redact bearer token", List.of("security", "logging"),
"The test ensures that sensitive bearer tokens are redacted before logging."),
new AiMethodSuggestion("shouldNotLogPlaintextPasswordOnAuthenticationFailure", true,
"SECURITY: logging - avoid plaintext password disclosure",
List.of("security", "logging"),
"The test verifies that plaintext passwords are not written to logs."),
new AiMethodSuggestion("shouldFormatHumanReadableSupportMessage", false, null, List.of(),
"The test is functional formatting coverage and is not security-specific.")));
}
private static List<String> parseCsvFields(String line) {
List<String> out = new ArrayList<>();
StringBuilder current = new StringBuilder();
boolean inQuotes = false;
int i = 0;
while (i < line.length()) {
char ch = line.charAt(i);
if (inQuotes) {
if (ch == '\"') {
if (i + 1 < line.length() && line.charAt(i + 1) == '\"') {
current.append('\"');
i += 2;
continue;
}
inQuotes = false;
i++;
continue;
}
current.append(ch);
i++;
continue;
}
if (ch == '\"') {
inQuotes = true;
i++;
continue;
}
if (ch == ',') {
out.add(current.toString());
current.setLength(0);
i++;
continue;
}
current.append(ch);
i++;
}
out.add(current.toString());
return out;
}
private static void copyAllFixtures(Path tempDir) throws IOException {
copyFixtures(tempDir, "SampleOneTest.java", "AnotherTest.java", "AccessControlServiceTest.java",
"PathTraversalValidationTest.java", "AuditLoggingTest.java");
}
private static void copyFixtures(Path tempDir, String... fixtureFileNames) throws IOException {
for (String fixtureFileName : fixtureFileNames) {
copyFixture(tempDir, fixtureFileName);
}
}
private static void copyFixture(Path destDir, String fixtureFileName) throws IOException {
String resourcePath = "/fixtures/" + fixtureFileName + ".txt";
try (InputStream in = MethodAtlasAppAiTest.class.getResourceAsStream(resourcePath)) {
assertNotNull(in, "Missing test resource: " + resourcePath);
Files.copy(in, destDir.resolve(fixtureFileName));
}
}
private static String runAppCapturingStdout(String[] args) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
PrintStream previous = System.out;
try (PrintStream ps = new PrintStream(baos, true, StandardCharsets.UTF_8)) {
System.setOut(ps);
MethodAtlasApp.main(args);
} finally {
System.setOut(previous);
}
return baos.toString(StandardCharsets.UTF_8);
}
private static List<String> nonEmptyLines(String text) {
String[] parts = text.split("\\R");
List<String> lines = new ArrayList<>();
for (String part : parts) {
String trimmed = part.trim();
if (!trimmed.isEmpty()) {
lines.add(trimmed);
}
}
return lines;
}
private static String findLineContaining(List<String> lines, String fragment) {
for (String line : lines) {
if (line.contains(fragment)) {
return line;
}
}
throw new AssertionError("Missing line containing: " + fragment);
}
private static void writeOversizedFixture(Path tempDir) throws IOException {
StringBuilder repeated = new StringBuilder();
for (int i = 0; i < 100; i++) {
repeated.append(" String s").append(i).append(" = \"padding").append(i).append("\";\n");
}
String source = """
package com.acme.big;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
class HugeAiSkipTest {
@Test
@Tag("security")
void hugeSecurityTest() {
""" + repeated + """
}
}
""";
Files.writeString(tempDir.resolve("HugeAiSkipTest.java"), source, StandardCharsets.UTF_8);
}
private static void assertAiCsvRow(Map<String, List<String>> rows, String fqcn, String method,
String expectedTagsText, String expectedAiSecurityRelevant, String expectedAiDisplayName,
String expectedAiTagsText, String expectedAiReason) {
List<String> fields = rows.get(fqcn + "#" + method);
assertNotNull(fields, "Missing row for " + fqcn + "#" + method);
assertEquals(8, fields.size(), "Expected 8 CSV fields for " + fqcn + "#" + method);
assertEquals(expectedTagsText, fields.get(3), "Source tags mismatch for " + fqcn + "#" + method);
assertEquals(expectedAiSecurityRelevant, fields.get(4), "AI security flag mismatch for " + fqcn + "#" + method);
assertEquals(expectedAiDisplayName, fields.get(5), "AI display name mismatch for " + fqcn + "#" + method);
assertEquals(expectedAiTagsText, fields.get(6), "AI tags mismatch for " + fqcn + "#" + method);
assertEquals(expectedAiReason, fields.get(7), "AI reason mismatch for " + fqcn + "#" + method);
}
private static Map<String, List<String>> parseCsvAiRows(List<String> lines) {
Map<String, List<String>> rows = new HashMap<>();
for (int i = 1; i < lines.size(); i++) {
List<String> fields = parseCsvFields(lines.get(i));
assertEquals(8, fields.size(), "Expected 8 CSV fields, got " + fields.size() + " from: " + lines.get(i));
rows.put(fields.get(0) + "#" + fields.get(1), fields);
}
return rows;
}
}

View File

@@ -22,25 +22,38 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/**
* End-to-end tests for {@link MethodAtlasApp} output formats (CSV default,
* -plain).
* Baseline end-to-end regression tests for {@link MethodAtlasApp} output
* formats with AI enrichment disabled.
*
* <p>
* These tests copy predefined Java fixture files from
* src/test/resources/fixtures into a temporary directory and run
* MethodAtlasApp.main(...) against that directory, asserting the detected
* methods, LOC, and extracted @Tag values.
* {@code src/test/resources/fixtures} into a temporary directory and execute
* {@link MethodAtlasApp#main(String[])} against that directory. The assertions
* verify the stable non-AI contract of the application:
* </p>
* <ul>
* <li>detected test methods</li>
* <li>inclusive method LOC values</li>
* <li>extracted JUnit {@code @Tag} values, including nested {@code @Tags}</li>
* <li>CSV and plain-text rendering behavior</li>
* </ul>
*
* <p>
* AI-specific behavior should be tested separately once a dedicated injection
* seam exists for supplying a mocked
* {@code org.egothor.methodatlas.ai.AiSuggestionEngine}.
* </p>
*/
public class MethodAtlasAppTest {
@Test
public void csvMode_detectsMethodsLocAndTags(@TempDir Path tempDir) throws Exception {
copyFixture(tempDir, "SampleOneTest.java");
copyFixture(tempDir, "AnotherTest.java");
copyStandardFixtures(tempDir);
String output = runAppCapturingStdout(new String[] { tempDir.toString() });
List<String> lines = nonEmptyLines(output);
assertTrue(lines.size() >= 3, "Expected header + at least 2 records, got: " + lines.size());
assertEquals(18, lines.size(), "Expected header + 17 records");
assertEquals("fqcn,method,loc,tags", lines.get(0));
@@ -58,13 +71,12 @@ public class MethodAtlasAppTest {
@Test
public void plainMode_detectsMethodsLocAndTags(@TempDir Path tempDir) throws Exception {
copyFixture(tempDir, "SampleOneTest.java");
copyFixture(tempDir, "AnotherTest.java");
copyStandardFixtures(tempDir);
String output = runAppCapturingStdout(new String[] { "-plain", tempDir.toString() });
List<String> lines = nonEmptyLines(output);
assertTrue(lines.size() >= 4, "Expected at least 4 method lines, got: " + lines.size());
assertEquals(17, lines.size(), "Expected 17 method lines");
Map<String, PlainRow> rows = new HashMap<>();
for (String line : lines) {
@@ -78,6 +90,14 @@ public class MethodAtlasAppTest {
assertPlainRow(rows, "com.acme.other.AnotherTest", "delta", 3, "-");
}
private static void copyStandardFixtures(Path tempDir) throws IOException {
copyFixture(tempDir, "SampleOneTest.java");
copyFixture(tempDir, "AnotherTest.java");
copyFixture(tempDir, "AccessControlServiceTest.java");
copyFixture(tempDir, "PathTraversalValidationTest.java");
copyFixture(tempDir, "AuditLoggingTest.java");
}
private static void assertCsvRow(Map<String, CsvRow> rows, String fqcn, String method, int expectedLoc,
List<String> expectedTags) {
@@ -239,4 +259,4 @@ public class MethodAtlasAppTest {
private int loc;
private String tagsText;
}
}
}

View File

@@ -0,0 +1,187 @@
package org.egothor.methodatlas.ai;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.nio.file.Path;
import java.time.Duration;
import org.junit.jupiter.api.Test;
class AiOptionsTest {
@Test
void builder_defaults_areStableAndValid() {
AiOptions options = AiOptions.builder().build();
assertEquals(false, options.enabled());
assertEquals(AiProvider.AUTO, options.provider());
assertEquals("qwen2.5-coder:7b", options.modelName());
assertEquals("http://localhost:11434", options.baseUrl());
assertNull(options.apiKey());
assertNull(options.apiKeyEnv());
assertNull(options.taxonomyFile());
assertEquals(AiOptions.TaxonomyMode.DEFAULT, options.taxonomyMode());
assertEquals(40_000, options.maxClassChars());
assertEquals(Duration.ofSeconds(90), options.timeout());
assertEquals(1, options.maxRetries());
}
@Test
void builder_usesOllamaDefaultBaseUrl() {
AiOptions options = AiOptions.builder().provider(AiProvider.OLLAMA).build();
assertEquals("http://localhost:11434", options.baseUrl());
}
@Test
void builder_usesAutoDefaultBaseUrl() {
AiOptions options = AiOptions.builder().provider(AiProvider.AUTO).build();
assertEquals("http://localhost:11434", options.baseUrl());
}
@Test
void builder_usesOpenAiDefaultBaseUrl() {
AiOptions options = AiOptions.builder().provider(AiProvider.OPENAI).build();
assertEquals("https://api.openai.com", options.baseUrl());
}
@Test
void builder_usesOpenRouterDefaultBaseUrl() {
AiOptions options = AiOptions.builder().provider(AiProvider.OPENROUTER).build();
assertEquals("https://openrouter.ai/api", options.baseUrl());
}
@Test
void builder_usesAnthropicDefaultBaseUrl() {
AiOptions options = AiOptions.builder().provider(AiProvider.ANTHROPIC).build();
assertEquals("https://api.anthropic.com", options.baseUrl());
}
@Test
void builder_preservesExplicitBaseUrl() {
AiOptions options = AiOptions.builder().provider(AiProvider.OPENAI)
.baseUrl("https://internal-gateway.example.test/openai").build();
assertEquals("https://internal-gateway.example.test/openai", options.baseUrl());
}
@Test
void builder_treatsNullProviderAsAuto() {
AiOptions options = AiOptions.builder().provider(null).build();
assertEquals(AiProvider.AUTO, options.provider());
assertEquals("http://localhost:11434", options.baseUrl());
}
@Test
void resolvedApiKey_prefersDirectApiKey() {
AiOptions options = AiOptions.builder().apiKey("sk-direct-value").apiKeyEnv("SHOULD_NOT_BE_USED").build();
assertEquals("sk-direct-value", options.resolvedApiKey());
}
@Test
void resolvedApiKey_returnsNullWhenDirectKeyIsBlankAndEnvIsMissing() {
AiOptions options = AiOptions.builder().apiKey(" ").apiKeyEnv("METHODATLAS_TEST_ENV_NOT_PRESENT").build();
assertNull(options.resolvedApiKey());
}
@Test
void resolvedApiKey_returnsNullWhenNeitherDirectNorEnvAreConfigured() {
AiOptions options = AiOptions.builder().build();
assertNull(options.resolvedApiKey());
}
@Test
void canonicalConstructor_rejectsNullProvider() {
NullPointerException ex = assertThrows(NullPointerException.class,
() -> new AiOptions(true, null, "gpt-4o-mini", "https://api.openai.com", null, null, null,
AiOptions.TaxonomyMode.DEFAULT, 40_000, Duration.ofSeconds(30), 1));
assertEquals("provider", ex.getMessage());
}
@Test
void canonicalConstructor_rejectsNullModelName() {
NullPointerException ex = assertThrows(NullPointerException.class,
() -> new AiOptions(true, AiProvider.OPENAI, null, "https://api.openai.com", null, null, null,
AiOptions.TaxonomyMode.DEFAULT, 40_000, Duration.ofSeconds(30), 1));
assertEquals("modelName", ex.getMessage());
}
@Test
void canonicalConstructor_rejectsNullTimeout() {
NullPointerException ex = assertThrows(NullPointerException.class,
() -> new AiOptions(true, AiProvider.OPENAI, "gpt-4o-mini", "https://api.openai.com", null, null, null,
AiOptions.TaxonomyMode.DEFAULT, 40_000, null, 1));
assertEquals("timeout", ex.getMessage());
}
@Test
void canonicalConstructor_rejectsNullTaxonomyMode() {
NullPointerException ex = assertThrows(NullPointerException.class, () -> new AiOptions(true, AiProvider.OPENAI,
"gpt-4o-mini", "https://api.openai.com", null, null, null, null, 40_000, Duration.ofSeconds(30), 1));
assertEquals("taxonomyMode", ex.getMessage());
}
@Test
void canonicalConstructor_rejectsBlankBaseUrl() {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> new AiOptions(true, AiProvider.OPENAI, "gpt-4o-mini", " ", null, null, null,
AiOptions.TaxonomyMode.DEFAULT, 40_000, Duration.ofSeconds(30), 1));
assertEquals("baseUrl must not be blank", ex.getMessage());
}
@Test
void canonicalConstructor_rejectsNonPositiveMaxClassChars() {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> new AiOptions(true, AiProvider.OPENAI, "gpt-4o-mini", "https://api.openai.com", null, null, null,
AiOptions.TaxonomyMode.DEFAULT, 0, Duration.ofSeconds(30), 1));
assertEquals("maxClassChars must be > 0", ex.getMessage());
}
@Test
void canonicalConstructor_rejectsNegativeMaxRetries() {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> new AiOptions(true, AiProvider.OPENAI, "gpt-4o-mini", "https://api.openai.com", null, null, null,
AiOptions.TaxonomyMode.DEFAULT, 40_000, Duration.ofSeconds(30), -1));
assertEquals("maxRetries must be >= 0", ex.getMessage());
}
@Test
void builder_allowsFullCustomization() {
Path taxonomyFile = Path.of("src/test/resources/security-taxonomy.yaml");
Duration timeout = Duration.ofSeconds(15);
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.ANTHROPIC)
.modelName("claude-3-5-sonnet").baseUrl("https://proxy.example.test/anthropic").apiKey("test-api-key")
.apiKeyEnv("IGNORED_ENV").taxonomyFile(taxonomyFile).taxonomyMode(AiOptions.TaxonomyMode.OPTIMIZED)
.maxClassChars(12_345).timeout(timeout).maxRetries(4).build();
assertEquals(true, options.enabled());
assertEquals(AiProvider.ANTHROPIC, options.provider());
assertEquals("claude-3-5-sonnet", options.modelName());
assertEquals("https://proxy.example.test/anthropic", options.baseUrl());
assertEquals("test-api-key", options.apiKey());
assertEquals("IGNORED_ENV", options.apiKeyEnv());
assertEquals(taxonomyFile, options.taxonomyFile());
assertEquals(AiOptions.TaxonomyMode.OPTIMIZED, options.taxonomyMode());
assertEquals(12_345, options.maxClassChars());
assertEquals(timeout, options.timeout());
assertEquals(4, options.maxRetries());
}
}

View File

@@ -0,0 +1,177 @@
package org.egothor.methodatlas.ai;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.mockito.MockedConstruction;
class AiProviderFactoryTest {
@Test
void create_withOllamaProvider_returnsOllamaClientWithoutAvailabilityCheck() throws Exception {
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OLLAMA).build();
try (MockedConstruction<OllamaClient> mocked = mockConstruction(OllamaClient.class)) {
AiProviderClient client = AiProviderFactory.create(options);
assertInstanceOf(OllamaClient.class, client);
assertEquals(1, mocked.constructed().size());
assertSame(mocked.constructed().get(0), client);
}
}
@Test
void create_withOpenAiProvider_returnsOpenAiCompatibleClientWhenAvailable() throws Exception {
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENAI).apiKey("sk-test-value")
.build();
try (MockedConstruction<OpenAiCompatibleClient> mocked = mockConstruction(OpenAiCompatibleClient.class,
(mock, context) -> when(mock.isAvailable()).thenReturn(true))) {
AiProviderClient client = AiProviderFactory.create(options);
assertInstanceOf(OpenAiCompatibleClient.class, client);
assertEquals(1, mocked.constructed().size());
assertSame(mocked.constructed().get(0), client);
}
}
@Test
void create_withOpenAiProvider_throwsWhenUnavailable() {
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENAI).build();
try (MockedConstruction<OpenAiCompatibleClient> mocked = mockConstruction(OpenAiCompatibleClient.class,
(mock, context) -> when(mock.isAvailable()).thenReturn(false))) {
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
() -> AiProviderFactory.create(options));
assertEquals("OpenAI API key missing", ex.getMessage());
assertEquals(1, mocked.constructed().size());
}
}
@Test
void create_withOpenRouterProvider_returnsOpenAiCompatibleClientWhenAvailable() throws Exception {
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENROUTER).apiKey("or-test-key")
.build();
try (MockedConstruction<OpenAiCompatibleClient> mocked = mockConstruction(OpenAiCompatibleClient.class,
(mock, context) -> when(mock.isAvailable()).thenReturn(true))) {
AiProviderClient client = AiProviderFactory.create(options);
assertInstanceOf(OpenAiCompatibleClient.class, client);
assertEquals(1, mocked.constructed().size());
assertSame(mocked.constructed().get(0), client);
}
}
@Test
void create_withOpenRouterProvider_throwsWhenUnavailable() {
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENROUTER).build();
try (MockedConstruction<OpenAiCompatibleClient> mocked = mockConstruction(OpenAiCompatibleClient.class,
(mock, context) -> when(mock.isAvailable()).thenReturn(false))) {
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
() -> AiProviderFactory.create(options));
assertEquals("OpenRouter API key missing", ex.getMessage());
assertEquals(1, mocked.constructed().size());
}
}
@Test
void create_withAnthropicProvider_returnsAnthropicClientWhenAvailable() throws Exception {
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.ANTHROPIC)
.apiKey("anthropic-test-key").build();
try (MockedConstruction<AnthropicClient> mocked = mockConstruction(AnthropicClient.class,
(mock, context) -> when(mock.isAvailable()).thenReturn(true))) {
AiProviderClient client = AiProviderFactory.create(options);
assertInstanceOf(AnthropicClient.class, client);
assertEquals(1, mocked.constructed().size());
assertSame(mocked.constructed().get(0), client);
}
}
@Test
void create_withAnthropicProvider_throwsWhenUnavailable() {
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.ANTHROPIC).build();
try (MockedConstruction<AnthropicClient> mocked = mockConstruction(AnthropicClient.class,
(mock, context) -> when(mock.isAvailable()).thenReturn(false))) {
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
() -> AiProviderFactory.create(options));
assertEquals("Anthropic API key missing", ex.getMessage());
assertEquals(1, mocked.constructed().size());
}
}
@Test
void create_withAutoProvider_returnsOllamaWhenAvailable() throws Exception {
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.AUTO).modelName("qwen2.5-coder:7b")
.baseUrl("http://localhost:11434").build();
try (MockedConstruction<OllamaClient> ollamaMocked = mockConstruction(OllamaClient.class,
(mock, context) -> when(mock.isAvailable()).thenReturn(true));
MockedConstruction<OpenAiCompatibleClient> openAiMocked = mockConstruction(
OpenAiCompatibleClient.class)) {
AiProviderClient client = AiProviderFactory.create(options);
assertInstanceOf(OllamaClient.class, client);
assertEquals(1, ollamaMocked.constructed().size());
assertSame(ollamaMocked.constructed().get(0), client);
assertEquals(0, openAiMocked.constructed().size());
}
}
@Test
void create_withAutoProvider_fallsBackToOpenAiCompatibleWhenOllamaUnavailableAndApiKeyPresent() throws Exception {
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.AUTO).modelName("gpt-4o-mini")
.baseUrl("https://api.openai.com").apiKey("sk-test-value").build();
try (MockedConstruction<OllamaClient> ollamaMocked = mockConstruction(OllamaClient.class,
(mock, context) -> when(mock.isAvailable()).thenReturn(false));
MockedConstruction<OpenAiCompatibleClient> openAiMocked = mockConstruction(
OpenAiCompatibleClient.class)) {
AiProviderClient client = AiProviderFactory.create(options);
assertInstanceOf(OpenAiCompatibleClient.class, client);
assertEquals(1, ollamaMocked.constructed().size());
assertEquals(1, openAiMocked.constructed().size());
assertSame(openAiMocked.constructed().get(0), client);
}
}
@Test
void create_withAutoProvider_throwsWhenOllamaUnavailableAndNoApiKeyConfigured() {
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.AUTO).build();
try (MockedConstruction<OllamaClient> ollamaMocked = mockConstruction(OllamaClient.class,
(mock, context) -> when(mock.isAvailable()).thenReturn(false));
MockedConstruction<OpenAiCompatibleClient> openAiMocked = mockConstruction(
OpenAiCompatibleClient.class)) {
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
() -> AiProviderFactory.create(options));
assertEquals("No AI provider available. Ollama is not reachable and no API key is configured.",
ex.getMessage());
assertEquals(1, ollamaMocked.constructed().size());
assertEquals(0, openAiMocked.constructed().size());
}
}
}

View File

@@ -0,0 +1,165 @@
package org.egothor.methodatlas.ai;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.MockedStatic;
class AiSuggestionEngineImplTest {
@TempDir
Path tempDir;
@Test
void suggestForClass_delegatesToProviderClient_usingDefaultTaxonomy() throws Exception {
AiProviderClient client = mock(AiProviderClient.class);
AiClassSuggestion expected = new AiClassSuggestion("com.acme.security.AccessControlServiceTest", true,
List.of("security", "access-control"), "Class validates access-control behavior.",
List.of(new AiMethodSuggestion("shouldRejectUnauthenticatedRequest", true,
"SECURITY: authentication - reject unauthenticated request", List.of("security", "auth"),
"The test verifies that anonymous access is rejected.")));
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENAI).build();
try (MockedStatic<AiProviderFactory> factory = mockStatic(AiProviderFactory.class)) {
factory.when(() -> AiProviderFactory.create(options)).thenReturn(client);
when(client.suggestForClass(eq("com.acme.security.AccessControlServiceTest"),
eq("class AccessControlServiceTest {}"), eq(DefaultSecurityTaxonomy.text()))).thenReturn(expected);
AiSuggestionEngineImpl engine = new AiSuggestionEngineImpl(options);
AiClassSuggestion actual = engine.suggestForClass("com.acme.security.AccessControlServiceTest",
"class AccessControlServiceTest {}");
assertSame(expected, actual);
factory.verify(() -> AiProviderFactory.create(options));
verify(client).suggestForClass("com.acme.security.AccessControlServiceTest",
"class AccessControlServiceTest {}", DefaultSecurityTaxonomy.text());
verifyNoMoreInteractions(client);
}
}
@Test
void suggestForClass_delegatesToProviderClient_usingOptimizedTaxonomy() throws Exception {
AiProviderClient client = mock(AiProviderClient.class);
AiClassSuggestion expected = new AiClassSuggestion("com.acme.storage.PathTraversalValidationTest", true,
List.of("security", "input-validation"), "Class validates protection against unsafe path input.",
List.of(new AiMethodSuggestion("shouldRejectRelativePathTraversalSequence", true,
"SECURITY: input validation - reject path traversal sequence",
List.of("security", "input-validation", "owasp"),
"The test rejects a classic path traversal payload.")));
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OLLAMA)
.taxonomyMode(AiOptions.TaxonomyMode.OPTIMIZED).build();
try (MockedStatic<AiProviderFactory> factory = mockStatic(AiProviderFactory.class)) {
factory.when(() -> AiProviderFactory.create(options)).thenReturn(client);
when(client.suggestForClass(eq("com.acme.storage.PathTraversalValidationTest"),
eq("class PathTraversalValidationTest {}"), eq(OptimizedSecurityTaxonomy.text())))
.thenReturn(expected);
AiSuggestionEngineImpl engine = new AiSuggestionEngineImpl(options);
AiClassSuggestion actual = engine.suggestForClass("com.acme.storage.PathTraversalValidationTest",
"class PathTraversalValidationTest {}");
assertSame(expected, actual);
factory.verify(() -> AiProviderFactory.create(options));
verify(client).suggestForClass("com.acme.storage.PathTraversalValidationTest",
"class PathTraversalValidationTest {}", OptimizedSecurityTaxonomy.text());
verifyNoMoreInteractions(client);
}
}
@Test
void suggestForClass_usesExternalTaxonomyFile_whenConfigured() throws Exception {
Path taxonomyFile = tempDir.resolve("security-taxonomy.txt");
String taxonomyText = """
CUSTOM SECURITY TAXONOMY
security
access-control
logging
""";
Files.writeString(taxonomyFile, taxonomyText);
AiProviderClient client = mock(AiProviderClient.class);
AiClassSuggestion expected = new AiClassSuggestion("com.acme.audit.AuditLoggingTest", true,
List.of("security", "logging"), "Class verifies security-relevant audit logging behavior.",
List.of(new AiMethodSuggestion("shouldNotLogRawBearerToken", true,
"SECURITY: logging - redact bearer token", List.of("security", "logging"),
"The test ensures credentials are not written to logs.")));
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENROUTER).taxonomyFile(taxonomyFile)
.build();
try (MockedStatic<AiProviderFactory> factory = mockStatic(AiProviderFactory.class)) {
factory.when(() -> AiProviderFactory.create(options)).thenReturn(client);
when(client.suggestForClass(eq("com.acme.audit.AuditLoggingTest"), eq("class AuditLoggingTest {}"),
eq(taxonomyText))).thenReturn(expected);
AiSuggestionEngineImpl engine = new AiSuggestionEngineImpl(options);
AiClassSuggestion actual = engine.suggestForClass("com.acme.audit.AuditLoggingTest",
"class AuditLoggingTest {}");
assertSame(expected, actual);
factory.verify(() -> AiProviderFactory.create(options));
verify(client).suggestForClass("com.acme.audit.AuditLoggingTest", "class AuditLoggingTest {}",
taxonomyText);
verifyNoMoreInteractions(client);
}
}
@Test
void constructor_throwsWhenTaxonomyFileCannotBeRead() {
Path missingTaxonomyFile = tempDir.resolve("missing-taxonomy.txt");
AiProviderClient client = mock(AiProviderClient.class);
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.ANTHROPIC)
.taxonomyFile(missingTaxonomyFile).build();
try (MockedStatic<AiProviderFactory> factory = mockStatic(AiProviderFactory.class)) {
factory.when(() -> AiProviderFactory.create(options)).thenReturn(client);
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
() -> new AiSuggestionEngineImpl(options));
assertEquals("Failed to read taxonomy file: " + missingTaxonomyFile, ex.getMessage());
assertInstanceOf(IOException.class, ex.getCause());
factory.verify(() -> AiProviderFactory.create(options));
verifyNoMoreInteractions(client);
}
}
@Test
void constructor_propagatesProviderFactoryFailure() throws Exception {
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENAI).build();
AiSuggestionException expected = new AiSuggestionException("Provider initialization failed");
try (MockedStatic<AiProviderFactory> factory = mockStatic(AiProviderFactory.class)) {
factory.when(() -> AiProviderFactory.create(options)).thenThrow(expected);
AiSuggestionException actual = assertThrows(AiSuggestionException.class,
() -> new AiSuggestionEngineImpl(options));
assertSame(expected, actual);
}
}
}

View File

@@ -0,0 +1,126 @@
package org.egothor.methodatlas.ai;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
class JsonTextTest {
@Test
void extractFirstJsonObject_returnsJsonWhenInputIsExactlyJson() throws Exception {
String json = "{\"className\":\"AccessControlServiceTest\",\"methods\":[]}";
String extracted = JsonText.extractFirstJsonObject(json);
assertEquals(json, extracted);
}
@Test
void extractFirstJsonObject_extractsJsonWrappedByPlainText() throws Exception {
String text = """
Here is the analysis result:
{"className":"AccessControlServiceTest","methods":[{"methodName":"shouldRejectUnauthenticatedRequest"}]}
Thank you.
""";
String extracted = JsonText.extractFirstJsonObject(text);
assertEquals(
"{\"className\":\"AccessControlServiceTest\",\"methods\":[{\"methodName\":\"shouldRejectUnauthenticatedRequest\"}]}",
extracted);
}
@Test
void extractFirstJsonObject_extractsJsonWrappedByMarkdownFence() throws Exception {
String text = """
```json
{"className":"AuditLoggingTest","methods":[{"methodName":"shouldNotLogRawBearerToken"}]}
```
""";
String extracted = JsonText.extractFirstJsonObject(text);
assertEquals(
"{\"className\":\"AuditLoggingTest\",\"methods\":[{\"methodName\":\"shouldNotLogRawBearerToken\"}]}",
extracted);
}
@Test
void extractFirstJsonObject_preservesNestedObjectsAndArrays() throws Exception {
String text = """
Model output:
{
"className":"PathTraversalValidationTest",
"methods":[
{
"methodName":"shouldRejectRelativePathTraversalSequence",
"securityRelevant":true,
"tags":["security","input-validation","path-traversal"]
}
]
}
End.
""";
String extracted = JsonText.extractFirstJsonObject(text);
assertEquals("""
{
"className":"PathTraversalValidationTest",
"methods":[
{
"methodName":"shouldRejectRelativePathTraversalSequence",
"securityRelevant":true,
"tags":["security","input-validation","path-traversal"]
}
]
}""", extracted);
}
@Test
void extractFirstJsonObject_nullInput_throwsAiSuggestionException() {
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
() -> JsonText.extractFirstJsonObject(null));
assertEquals("Model returned an empty response", ex.getMessage());
}
@Test
void extractFirstJsonObject_blankInput_throwsAiSuggestionException() {
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
() -> JsonText.extractFirstJsonObject(" \n\t "));
assertEquals("Model returned an empty response", ex.getMessage());
}
@Test
void extractFirstJsonObject_missingOpeningBrace_throwsAiSuggestionException() {
String text = "No JSON object here at all";
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
() -> JsonText.extractFirstJsonObject(text));
assertEquals("Model response does not contain a JSON object: " + text, ex.getMessage());
}
@Test
void extractFirstJsonObject_missingClosingBrace_throwsAiSuggestionException() {
String text = "{\"className\":\"AccessControlServiceTest\"";
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
() -> JsonText.extractFirstJsonObject(text));
assertEquals("Model response does not contain a JSON object: " + text, ex.getMessage());
}
@Test
void extractFirstJsonObject_closingBraceBeforeOpeningBrace_throwsAiSuggestionException() {
String text = "} not json {";
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
() -> JsonText.extractFirstJsonObject(text));
assertEquals("Model response does not contain a JSON object: " + text, ex.getMessage());
}
}

View File

@@ -0,0 +1,191 @@
package org.egothor.methodatlas.ai;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.mockito.MockedConstruction;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
class OllamaClientTest {
@Test
void isAvailable_returnsTrueWhenTagsEndpointResponds() throws Exception {
HttpClient httpClient = mock(HttpClient.class);
@SuppressWarnings("unchecked")
HttpResponse<Void> response = mock(HttpResponse.class);
when(httpClient.send(any(HttpRequest.class), anyVoidBodyHandler())).thenReturn(response);
try (MockedConstruction<HttpSupport> mocked = mockConstruction(HttpSupport.class, (mock, context) -> {
when(mock.httpClient()).thenReturn(httpClient);
})) {
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OLLAMA)
.baseUrl("http://localhost:11434").build();
OllamaClient client = new OllamaClient(options);
assertTrue(client.isAvailable());
verify(httpClient)
.send(argThat(request -> request.uri().toString().equals("http://localhost:11434/api/tags")
&& "GET".equals(request.method())), anyVoidBodyHandler());
}
}
@Test
void isAvailable_returnsFalseWhenTagsEndpointFails() throws Exception {
HttpClient httpClient = mock(HttpClient.class);
when(httpClient.send(any(HttpRequest.class), anyVoidBodyHandler()))
.thenThrow(new java.io.IOException("Connection refused"));
try (MockedConstruction<HttpSupport> mocked = mockConstruction(HttpSupport.class, (mock, context) -> {
when(mock.httpClient()).thenReturn(httpClient);
})) {
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OLLAMA)
.baseUrl("http://localhost:11434").build();
OllamaClient client = new OllamaClient(options);
assertFalse(client.isAvailable());
}
}
@Test
void suggestForClass_parsesWrappedJson_normalizesInvalidEntries_andBuildsExpectedRequestBody() throws Exception {
ObjectMapper mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
String fqcn = "com.acme.storage.PathTraversalValidationTest";
String classSource = """
class PathTraversalValidationTest {
void shouldRejectRelativePathTraversalSequence() {}
}
""";
String taxonomyText = "security, input-validation, owasp";
String responseBody = """
{
"message": {
"content": "Analysis complete:\\n{\\n \\"className\\": \\"com.acme.storage.PathTraversalValidationTest\\",\\n \\"classSecurityRelevant\\": true,\\n \\"classTags\\": null,\\n \\"classReason\\": \\"Class validates filesystem input handling.\\",\\n \\"methods\\": [\\n null,\\n {\\n \\"methodName\\": \\"shouldRejectRelativePathTraversalSequence\\",\\n \\"securityRelevant\\": true,\\n \\"displayName\\": \\"SECURITY: input validation - reject path traversal sequence\\",\\n \\"tags\\": null,\\n \\"reason\\": \\"The test rejects a classic parent-directory traversal payload.\\"\\n },\\n {\\n \\"methodName\\": \\" \\",\\n \\"securityRelevant\\": true,\\n \\"displayName\\": \\"SECURITY: invalid - blank method\\",\\n \\"tags\\": [\\"security\\"],\\n \\"reason\\": \\"This malformed method must be filtered.\\"\\n }\\n ]\\n}"
}
}
""";
AtomicReference<String> capturedBody = new AtomicReference<>();
try (MockedConstruction<HttpSupport> mocked = mockConstruction(HttpSupport.class, (mock, context) -> {
when(mock.objectMapper()).thenReturn(mapper);
when(mock.jsonPost(any(URI.class), any(String.class), any(Duration.class))).thenAnswer(invocation -> {
URI uri = invocation.getArgument(0);
String body = invocation.getArgument(1);
Duration timeout = invocation.getArgument(2);
capturedBody.set(body);
return HttpRequest.newBuilder(uri).timeout(timeout).header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body));
});
when(mock.postJson(any(HttpRequest.class))).thenReturn(responseBody);
})) {
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OLLAMA)
.modelName("qwen2.5-coder:7b").baseUrl("http://localhost:11434").build();
OllamaClient client = new OllamaClient(options);
AiClassSuggestion suggestion = client.suggestForClass(fqcn, classSource, taxonomyText);
assertEquals(fqcn, suggestion.className());
assertEquals(Boolean.TRUE, suggestion.classSecurityRelevant());
assertEquals(List.of(), suggestion.classTags());
assertEquals("Class validates filesystem input handling.", suggestion.classReason());
assertNotNull(suggestion.methods());
assertEquals(1, suggestion.methods().size());
AiMethodSuggestion method = suggestion.methods().get(0);
assertEquals("shouldRejectRelativePathTraversalSequence", method.methodName());
assertTrue(method.securityRelevant());
assertEquals("SECURITY: input validation - reject path traversal sequence", method.displayName());
assertEquals(List.of(), method.tags());
assertEquals("The test rejects a classic parent-directory traversal payload.", method.reason());
HttpSupport httpSupport = mocked.constructed().get(0);
verify(httpSupport)
.postJson(argThat(request -> request.uri().toString().equals("http://localhost:11434/api/chat")
&& "application/json".equals(request.headers().firstValue("Content-Type").orElse(null))
&& "POST".equals(request.method())));
String requestBody = capturedBody.get();
assertNotNull(requestBody);
assertTrue(requestBody.contains("\"model\":\"qwen2.5-coder:7b\""));
assertTrue(requestBody.contains("\"stream\":false"));
assertTrue(requestBody.contains("You are a precise software security classification engine."));
assertTrue(requestBody.contains("You classify JUnit 5 tests and return strict JSON only."));
assertTrue(requestBody.contains("\"temperature\":0.0"));
assertTrue(requestBody.contains("FQCN: " + fqcn));
assertTrue(requestBody.contains("PathTraversalValidationTest"));
assertTrue(requestBody.contains("shouldRejectRelativePathTraversalSequence"));
assertTrue(requestBody.contains(taxonomyText));
}
}
@Test
void suggestForClass_throwsWhenModelReturnsTextWithoutJsonObject() throws Exception {
ObjectMapper mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
String fqcn = "com.acme.audit.AuditLoggingTest";
String responseBody = """
{
"message": {
"content": "This looks security related, but I am not returning JSON."
}
}
""";
try (MockedConstruction<HttpSupport> mocked = mockConstruction(HttpSupport.class, (mock, context) -> {
when(mock.objectMapper()).thenReturn(mapper);
when(mock.jsonPost(any(URI.class), any(String.class), any(Duration.class))).thenAnswer(invocation -> {
URI uri = invocation.getArgument(0);
String body = invocation.getArgument(1);
Duration timeout = invocation.getArgument(2);
return HttpRequest.newBuilder(uri).timeout(timeout).header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body));
});
when(mock.postJson(any(HttpRequest.class))).thenReturn(responseBody);
})) {
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OLLAMA).build();
OllamaClient client = new OllamaClient(options);
AiSuggestionException ex = org.junit.jupiter.api.Assertions.assertThrows(AiSuggestionException.class,
() -> client.suggestForClass(fqcn, "class AuditLoggingTest {}", "security, logging"));
assertEquals("Ollama suggestion failed for " + fqcn, ex.getMessage());
assertInstanceOf(AiSuggestionException.class, ex.getCause());
assertTrue(ex.getCause().getMessage().contains("Model response does not contain a JSON object"));
}
}
@SuppressWarnings("unchecked")
private static HttpResponse.BodyHandler<Void> anyVoidBodyHandler() {
return any(HttpResponse.BodyHandler.class);
}
}

View File

@@ -0,0 +1,227 @@
package org.egothor.methodatlas.ai;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.net.URI;
import java.net.http.HttpRequest;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.mockito.MockedConstruction;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
class OpenAiCompatibleClientTest {
@Test
void isAvailable_returnsTrueWhenApiKeyIsConfigured() {
AiOptions options = AiOptions.builder().provider(AiProvider.OPENAI).apiKey("sk-test-value").build();
OpenAiCompatibleClient client = new OpenAiCompatibleClient(options);
assertTrue(client.isAvailable());
}
@Test
void isAvailable_returnsFalseWhenApiKeyIsMissing() {
AiOptions options = AiOptions.builder().provider(AiProvider.OPENAI).build();
OpenAiCompatibleClient client = new OpenAiCompatibleClient(options);
assertFalse(client.isAvailable());
}
@Test
void suggestForClass_parsesWrappedJson_normalizesInvalidEntries_andBuildsExpectedRequestBody() throws Exception {
ObjectMapper mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
String fqcn = "com.acme.security.AccessControlServiceTest";
String classSource = """
class AccessControlServiceTest {
void shouldRejectUnauthenticatedRequest() {}
}
""";
String taxonomyText = "security, auth, access-control";
String responseBody = """
{
"choices": [
{
"message": {
"content": "Here is the result:\\n{\\n \\"className\\": \\"com.acme.security.AccessControlServiceTest\\",\\n \\"classSecurityRelevant\\": true,\\n \\"classTags\\": null,\\n \\"classReason\\": \\"Class validates authentication and authorization controls.\\",\\n \\"methods\\": [\\n null,\\n {\\n \\"methodName\\": \\"shouldRejectUnauthenticatedRequest\\",\\n \\"securityRelevant\\": true,\\n \\"displayName\\": \\"SECURITY: authentication - reject unauthenticated request\\",\\n \\"tags\\": null,\\n \\"reason\\": \\"The test rejects anonymous access to a protected operation.\\"\\n },\\n {\\n \\"methodName\\": \\"\\",\\n \\"securityRelevant\\": true,\\n \\"displayName\\": \\"SECURITY: invalid - blank method\\",\\n \\"tags\\": [\\"security\\"],\\n \\"reason\\": \\"This malformed method must be filtered.\\"\\n }\\n ]\\n}\\nThanks."
}
}
]
}
""";
AtomicReference<String> capturedBody = new AtomicReference<>();
try (MockedConstruction<HttpSupport> mocked = mockHttpSupport(mapper, responseBody, capturedBody)) {
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENAI).modelName("gpt-4o-mini")
.baseUrl("https://api.openai.com").apiKey("sk-test-value").build();
OpenAiCompatibleClient client = new OpenAiCompatibleClient(options);
AiClassSuggestion suggestion = client.suggestForClass(fqcn, classSource, taxonomyText);
assertEquals(fqcn, suggestion.className());
assertEquals(Boolean.TRUE, suggestion.classSecurityRelevant());
assertEquals(List.of(), suggestion.classTags());
assertEquals("Class validates authentication and authorization controls.", suggestion.classReason());
assertNotNull(suggestion.methods());
assertEquals(1, suggestion.methods().size());
AiMethodSuggestion method = suggestion.methods().get(0);
assertEquals("shouldRejectUnauthenticatedRequest", method.methodName());
assertTrue(method.securityRelevant());
assertEquals("SECURITY: authentication - reject unauthenticated request", method.displayName());
assertEquals(List.of(), method.tags());
assertEquals("The test rejects anonymous access to a protected operation.", method.reason());
HttpSupport httpSupport = mocked.constructed().get(0);
verify(httpSupport).postJson(
argThat(request -> request.uri().toString().equals("https://api.openai.com/v1/chat/completions")
&& "Bearer sk-test-value".equals(request.headers().firstValue("Authorization").orElse(null))
&& "application/json".equals(request.headers().firstValue("Content-Type").orElse(null))
&& "POST".equals(request.method())));
String requestBody = capturedBody.get();
assertNotNull(requestBody);
assertTrue(requestBody.contains("\"model\":\"gpt-4o-mini\""));
assertTrue(requestBody.contains("You are a precise software security classification engine."));
assertTrue(requestBody.contains("You classify JUnit 5 tests and return strict JSON only."));
assertTrue(requestBody.contains("FQCN: " + fqcn));
assertTrue(requestBody.contains("AccessControlServiceTest"));
assertTrue(requestBody.contains("shouldRejectUnauthenticatedRequest"));
assertTrue(requestBody.contains(taxonomyText));
assertTrue(requestBody.contains("\"temperature\":0.0"));
}
}
@Test
void suggestForClass_addsOpenRouterHeaders() throws Exception {
ObjectMapper mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
String responseBody = """
{
"choices": [
{
"message": {
"content": "{\\"className\\":\\"com.acme.audit.AuditLoggingTest\\",\\"classSecurityRelevant\\":false,\\"classTags\\":[],\\"classReason\\":\\"Class is not security-relevant as a whole.\\",\\"methods\\":[]}"
}
}
]
}
""";
try (MockedConstruction<HttpSupport> mocked = mockHttpSupport(mapper, responseBody, null)) {
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENROUTER)
.modelName("openai/gpt-4o-mini").baseUrl("https://openrouter.ai/api").apiKey("or-test-key").build();
OpenAiCompatibleClient client = new OpenAiCompatibleClient(options);
AiClassSuggestion suggestion = client.suggestForClass("com.acme.audit.AuditLoggingTest",
"class AuditLoggingTest {}", "security, logging");
assertEquals("com.acme.audit.AuditLoggingTest", suggestion.className());
HttpSupport httpSupport = mocked.constructed().get(0);
verify(httpSupport).postJson(
argThat(request -> request.uri().toString().equals("https://openrouter.ai/api/v1/chat/completions")
&& "Bearer or-test-key".equals(request.headers().firstValue("Authorization").orElse(null))
&& "https://methodatlas.local"
.equals(request.headers().firstValue("HTTP-Referer").orElse(null))
&& "MethodAtlas".equals(request.headers().firstValue("X-Title").orElse(null))));
}
}
@Test
void suggestForClass_throwsWhenNoChoicesAreReturned() throws Exception {
ObjectMapper mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
String fqcn = "com.acme.audit.AuditLoggingTest";
String responseBody = """
{
"choices": []
}
""";
try (MockedConstruction<HttpSupport> mocked = mockHttpSupport(mapper, responseBody, null)) {
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENAI).apiKey("sk-test-value")
.build();
OpenAiCompatibleClient client = new OpenAiCompatibleClient(options);
AiSuggestionException ex = org.junit.jupiter.api.Assertions.assertThrows(AiSuggestionException.class,
() -> client.suggestForClass(fqcn, "class AuditLoggingTest {}", "security, logging"));
assertEquals("OpenAI-compatible suggestion failed for " + fqcn, ex.getMessage());
assertInstanceOf(AiSuggestionException.class, ex.getCause());
assertEquals("No choices returned by model", ex.getCause().getMessage());
}
}
@Test
void suggestForClass_throwsWhenModelReturnsTextWithoutJsonObject() throws Exception {
ObjectMapper mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
String fqcn = "com.acme.audit.AuditLoggingTest";
String responseBody = """
{
"choices": [
{
"message": {
"content": "I think this class is probably security relevant, but I will not provide JSON."
}
}
]
}
""";
try (MockedConstruction<HttpSupport> mocked = mockHttpSupport(mapper, responseBody, null)) {
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENAI).apiKey("sk-test-value")
.build();
OpenAiCompatibleClient client = new OpenAiCompatibleClient(options);
AiSuggestionException ex = org.junit.jupiter.api.Assertions.assertThrows(AiSuggestionException.class,
() -> client.suggestForClass(fqcn, "class AuditLoggingTest {}", "security, logging"));
assertEquals("OpenAI-compatible suggestion failed for " + fqcn, ex.getMessage());
assertInstanceOf(AiSuggestionException.class, ex.getCause());
assertTrue(ex.getCause().getMessage().contains("Model response does not contain a JSON object"));
}
}
private static MockedConstruction<HttpSupport> mockHttpSupport(ObjectMapper mapper, String responseBody,
AtomicReference<String> capturedBody) {
return mockConstruction(HttpSupport.class, (mock, context) -> {
when(mock.objectMapper()).thenReturn(mapper);
when(mock.jsonPost(any(URI.class), any(String.class), any(Duration.class))).thenAnswer(invocation -> {
URI uri = invocation.getArgument(0);
String body = invocation.getArgument(1);
Duration timeout = invocation.getArgument(2);
if (capturedBody != null) {
capturedBody.set(body);
}
return HttpRequest.newBuilder(uri).timeout(timeout).header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body));
});
when(mock.postJson(any(HttpRequest.class))).thenReturn(responseBody);
});
}
}

View File

@@ -0,0 +1,125 @@
package org.egothor.methodatlas.ai;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
class PromptBuilderTest {
@Test
void build_containsFqcnSourceAndTaxonomy() {
String fqcn = "com.acme.security.AccessControlServiceTest";
String classSource = """
package com.acme.security;
import org.junit.jupiter.api.Test;
class AccessControlServiceTest {
@Test
void shouldRejectUnauthenticatedRequest() {}
@Test
void shouldAllowOwnerToReadOwnStatement() {}
}
""";
String taxonomyText = """
SECURITY TAXONOMY
- security
- auth
- access-control
- input-validation
- logging
""";
String prompt = PromptBuilder.build(fqcn, classSource, taxonomyText);
assertTrue(prompt.contains("FQCN: " + fqcn));
assertTrue(prompt.contains(classSource));
assertTrue(prompt.contains(taxonomyText));
}
@Test
void build_containsExpectedTaskInstructions() {
String prompt = PromptBuilder.build("com.acme.audit.AuditLoggingTest", "class AuditLoggingTest {}",
"security, logging");
assertTrue(prompt.contains("You are analyzing a single JUnit 5 test class and suggesting security tags."));
assertTrue(prompt.contains("- Analyze the WHOLE class for context."));
assertTrue(prompt.contains("- Return per-method suggestions for JUnit test methods only."));
assertTrue(prompt.contains("- Do not invent methods that do not exist."));
assertTrue(prompt.contains("- Be conservative."));
assertTrue(prompt.contains("- If uncertain, classify the method as securityRelevant=false."));
}
@Test
void build_containsClosedTaxonomyRules() {
String prompt = PromptBuilder.build("com.acme.storage.PathTraversalValidationTest",
"class PathTraversalValidationTest {}", "security, input-validation, injection");
assertTrue(prompt.contains("Tags must come only from this closed set:"));
assertTrue(prompt.contains(
"security, auth, access-control, crypto, input-validation, injection, data-protection, logging, error-handling, owasp"));
assertTrue(prompt.contains("If securityRelevant=true, tags MUST include \"security\"."));
assertTrue(prompt.contains("Add 1-3 tags total per method."));
}
@Test
void build_containsDisplayNameRules() {
String prompt = PromptBuilder.build("com.acme.security.AccessControlServiceTest",
"class AccessControlServiceTest {}", "security, auth, access-control");
assertTrue(prompt.contains("displayName must be null when securityRelevant=false."));
assertTrue(prompt.contains("If securityRelevant=true, displayName must match:"));
assertTrue(prompt.contains("SECURITY: <control/property> - <scenario>"));
}
@Test
void build_containsJsonShapeContract() {
String prompt = PromptBuilder.build("com.acme.audit.AuditLoggingTest", "class AuditLoggingTest {}",
"security, logging");
assertTrue(prompt.contains("JSON SHAPE"));
assertTrue(prompt.contains("\"className\": \"string\""));
assertTrue(prompt.contains("\"classSecurityRelevant\": true"));
assertTrue(prompt.contains("\"classTags\": [\"security\", \"crypto\"]"));
assertTrue(prompt.contains("\"classReason\": \"string\""));
assertTrue(prompt.contains("\"methods\": ["));
assertTrue(prompt.contains("\"methodName\": \"string\""));
assertTrue(prompt.contains("\"securityRelevant\": true"));
assertTrue(prompt.contains("\"displayName\": \"SECURITY: ...\""));
assertTrue(prompt.contains("\"tags\": [\"security\", \"crypto\"]"));
assertTrue(prompt.contains("\"reason\": \"string\""));
}
@Test
void build_includesCompleteClassSourceVerbatim() {
String classSource = """
class PathTraversalValidationTest {
void shouldRejectRelativePathTraversalSequence() {
String userInput = "../etc/passwd";
}
}
""";
String prompt = PromptBuilder.build("com.acme.storage.PathTraversalValidationTest", classSource,
"security, input-validation, injection");
assertTrue(prompt.contains("String userInput = \"../etc/passwd\";"));
assertTrue(prompt.contains("void shouldRejectRelativePathTraversalSequence()"));
}
@Test
void build_isDeterministicForSameInput() {
String fqcn = "com.example.X";
String source = "class X {}";
String taxonomy = "security, logging";
String prompt1 = PromptBuilder.build(fqcn, source, taxonomy);
String prompt2 = PromptBuilder.build(fqcn, source, taxonomy);
assertEquals(prompt1, prompt2);
}
}

View File

@@ -0,0 +1,145 @@
package org.egothor.methodatlas.ai;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
class SuggestionLookupTest {
@Test
void from_nullSuggestion_returnsEmptyLookup() {
SuggestionLookup lookup = SuggestionLookup.from(null);
assertNotNull(lookup);
assertFalse(lookup.find("shouldAuthenticateUser").isPresent());
}
@Test
void from_nullMethods_returnsEmptyLookup() {
AiClassSuggestion suggestion = new AiClassSuggestion("com.acme.security.AccessControlServiceTest", Boolean.TRUE,
List.of("security", "access-control"), "Class contains access-control related tests.", null);
SuggestionLookup lookup = SuggestionLookup.from(suggestion);
assertNotNull(lookup);
assertFalse(lookup.find("shouldAllowOwnerToReadOwnStatement").isPresent());
}
@Test
void from_emptyMethods_returnsEmptyLookup() {
AiClassSuggestion suggestion = new AiClassSuggestion("com.acme.security.AccessControlServiceTest", Boolean.TRUE,
List.of("security", "access-control"), "Class contains access-control related tests.", List.of());
SuggestionLookup lookup = SuggestionLookup.from(suggestion);
assertNotNull(lookup);
assertFalse(lookup.find("shouldAllowOwnerToReadOwnStatement").isPresent());
}
@Test
void from_filtersNullBlankAndMissingMethodNames() {
AiMethodSuggestion valid = new AiMethodSuggestion("shouldRejectUnauthenticatedRequest", true,
"Reject unauthenticated access", List.of("security", "authentication", "access-control"),
"The test verifies anonymous access is rejected.");
AiClassSuggestion suggestion = new AiClassSuggestion("com.acme.security.AccessControlServiceTest", Boolean.TRUE,
List.of("security", "authentication", "access-control"), "Class tests protected-access scenarios.",
Arrays.asList(null,
new AiMethodSuggestion(null, true, "Invalid", List.of("security"), "missing method name"),
new AiMethodSuggestion("", true, "Invalid", List.of("security"), "blank method name"),
new AiMethodSuggestion(" ", true, "Invalid", List.of("security"), "blank method name"),
valid));
SuggestionLookup lookup = SuggestionLookup.from(suggestion);
assertFalse(lookup.find("missing").isPresent());
Optional<AiMethodSuggestion> found = lookup.find("shouldRejectUnauthenticatedRequest");
assertTrue(found.isPresent());
assertSame(valid, found.get());
assertTrue(found.get().tags().contains("security"));
assertTrue(found.get().tags().contains("authentication"));
assertTrue(found.get().tags().contains("access-control"));
}
@Test
void from_duplicateMethodNames_keepsFirstOccurrence() {
AiMethodSuggestion first = new AiMethodSuggestion("shouldAllowAdministratorToReadAnyStatement", true,
"Allow administrative access", List.of("security", "access-control", "authorization"),
"The test verifies that an administrator is allowed access.");
AiMethodSuggestion duplicate = new AiMethodSuggestion("shouldAllowAdministratorToReadAnyStatement", true,
"Ignore duplicate", List.of("security", "logging"), "A later duplicate entry that must be ignored.");
AiClassSuggestion suggestion = new AiClassSuggestion("com.acme.security.AccessControlServiceTest", Boolean.TRUE,
List.of("security", "access-control"), "Class covers authorization scenarios.",
List.of(first, duplicate));
SuggestionLookup lookup = SuggestionLookup.from(suggestion);
Optional<AiMethodSuggestion> found = lookup.find("shouldAllowAdministratorToReadAnyStatement");
assertTrue(found.isPresent());
assertSame(first, found.get());
assertTrue(found.get().tags().contains("authorization"));
assertFalse(found.get().tags().contains("logging"));
}
@Test
void find_existingMethod_returnsSuggestion() {
AiMethodSuggestion method = new AiMethodSuggestion("shouldRejectRelativePathTraversalSequence", true,
"Reject path traversal payload", List.of("security", "input-validation", "path-traversal"),
"The test rejects a parent-directory traversal sequence.");
AiClassSuggestion suggestion = new AiClassSuggestion("com.acme.storage.PathTraversalValidationTest",
Boolean.TRUE, List.of("security", "input-validation"), "Class validates filesystem input handling.",
List.of(method));
SuggestionLookup lookup = SuggestionLookup.from(suggestion);
Optional<AiMethodSuggestion> found = lookup.find("shouldRejectRelativePathTraversalSequence");
assertTrue(found.isPresent());
assertSame(method, found.get());
assertTrue(found.get().tags().contains("security"));
assertTrue(found.get().tags().contains("input-validation"));
assertTrue(found.get().tags().contains("path-traversal"));
}
@Test
void find_missingMethod_returnsEmptyOptional() {
AiMethodSuggestion method = new AiMethodSuggestion("shouldWriteAuditEventForPrivilegeChange", true,
"Audit privilege changes", List.of("security", "audit", "logging"),
"The test verifies audit logging for a security-sensitive action.");
AiClassSuggestion suggestion = new AiClassSuggestion("com.acme.audit.AuditLoggingTest", Boolean.TRUE,
List.of("security", "audit", "logging"), "Class contains audit and secure logging tests.",
List.of(method));
SuggestionLookup lookup = SuggestionLookup.from(suggestion);
assertFalse(lookup.find("shouldFormatHumanReadableSupportMessage").isPresent());
}
@Test
void find_nullMethodName_throwsNullPointerException() {
AiMethodSuggestion method = new AiMethodSuggestion("shouldNotLogRawBearerToken", true,
"Redact bearer token in logs", List.of("security", "logging", "secrets-handling"),
"The test ensures sensitive credentials are not written to logs.");
AiClassSuggestion suggestion = new AiClassSuggestion("com.acme.audit.AuditLoggingTest", Boolean.TRUE,
List.of("security", "logging"), "Class checks secure logging behavior.", List.of(method));
SuggestionLookup lookup = SuggestionLookup.from(suggestion);
assertThrows(NullPointerException.class, () -> lookup.find(null));
}
}

View File

@@ -0,0 +1,70 @@
package com.acme.security;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
public class AccessControlServiceTest {
@Test
@Tag("security")
@Tag("authz")
void shouldAllowOwnerToReadOwnStatement() {
String userId = "user-100";
String ownerId = "user-100";
boolean allowed = userId.equals(ownerId);
assertEquals(true, allowed);
}
@Test
@Tag("security")
@Tag("authz")
void shouldAllowAdministratorToReadAnyStatement() {
String role = "ADMIN";
boolean allowed = "ADMIN".equals(role);
assertEquals(true, allowed);
}
@Test
@Tag("security")
@Tag("authz")
void shouldDenyForeignUserFromReadingAnotherUsersStatement() {
String requesterId = "user-200";
String ownerId = "user-100";
boolean allowed = requesterId.equals(ownerId);
assertEquals(false, allowed);
}
@Test
@Tag("security")
@Tag("authn")
void shouldRejectUnauthenticatedRequest() {
String principal = null;
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> {
if (principal == null) {
throw new IllegalStateException("Unauthenticated request");
}
});
assertEquals("Unauthenticated request", ex.getMessage());
}
@Test
void shouldRenderFriendlyAccountLabel() {
String firstName = "Ada";
String lastName = "Lovelace";
String label = firstName + " " + lastName;
assertEquals("Ada Lovelace", label);
}
}

View File

@@ -0,0 +1,53 @@
package com.acme.audit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
public class AuditLoggingTest {
@Test
@Tag("security")
@Tag("audit")
void shouldWriteAuditEventForPrivilegeChange() {
String actor = "admin-7";
String action = "GRANT_ROLE";
String target = "user-17";
String auditLine = "actor=" + actor + ";action=" + action + ";target=" + target;
assertEquals("actor=admin-7;action=GRANT_ROLE;target=user-17", auditLine);
}
@Test
@Tag("security")
@Tag("logging")
void shouldNotLogRawBearerToken() {
String token = "Bearer eyJhbGciOiJIUzI1NiJ9.secret.signature";
String logLine = "Authorization header redacted";
assertFalse(logLine.contains(token));
assertEquals("Authorization header redacted", logLine);
}
@Test
@Tag("security")
@Tag("logging")
void shouldNotLogPlaintextPasswordOnAuthenticationFailure() {
String password = "Sup3rSecret!";
String logLine = "Authentication failed for user alice";
assertFalse(logLine.contains(password));
assertEquals("Authentication failed for user alice", logLine);
}
@Test
void shouldFormatHumanReadableSupportMessage() {
String user = "alice";
String message = "Support ticket opened for " + user;
assertEquals("Support ticket opened for alice", message);
}
}

View File

@@ -0,0 +1,63 @@
package com.acme.storage;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.nio.file.Path;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
public class PathTraversalValidationTest {
@Test
@Tag("security")
@Tag("validation")
void shouldRejectRelativePathTraversalSequence() {
String userInput = "../secrets.txt";
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> {
if (userInput.contains("..")) {
throw new IllegalArgumentException("Path traversal attempt detected");
}
});
assertEquals("Path traversal attempt detected", ex.getMessage());
}
@Test
@Tag("security")
@Tag("validation")
void shouldRejectNestedTraversalAfterNormalization() {
String userInput = "reports/../../admin/keys.txt";
Path normalized = Path.of("/srv/app/uploads").resolve(userInput).normalize();
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> {
if (!normalized.startsWith(Path.of("/srv/app/uploads"))) {
throw new IllegalArgumentException("Escaped upload root");
}
});
assertEquals("Escaped upload root", ex.getMessage());
}
@Test
@Tag("security")
@Tag("validation")
void shouldAllowSafePathInsideUploadRoot() {
String userInput = "reports/2026/statement.pdf";
Path normalized = Path.of("/srv/app/uploads").resolve(userInput).normalize();
boolean allowed = normalized.startsWith(Path.of("/srv/app/uploads"));
assertEquals(true, allowed);
}
@Test
void shouldBuildDownloadFileName() {
String accountId = "ACC-42";
String fileName = accountId + "-statement.pdf";
assertEquals("ACC-42-statement.pdf", fileName);
}
}