10 Commits

Author SHA1 Message Date
bb4dc2f402 upgrade: gradle 9.4.0 2026-03-16 20:36:42 +01:00
e3f494924e docs: explain deterministic AST discovery vs AI classification
All checks were successful
Release / release (push) Successful in 2h34m2s
2026-03-10 20:52:40 +01:00
a592ce1330 feat: restrict AI tagging to parser-discovered test methods 2026-03-10 20:42:26 +01:00
32ddfa988b chore: fix PMD warnings and improve code quality 2026-03-09 23:30:45 +01:00
2dd3a687a5 orphan removed
All checks were successful
Release / release (push) Successful in 37s
2026-03-09 22:24:10 +01:00
344d24dec9 docs: expand README with full CLI argument documentation and AI usage
example
2026-03-09 22:21:22 +01:00
bbb6adb7e5 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.
2026-03-08 23:44:55 +01:00
d3a8270d8a fix: package application as fat JAR and fix distribution contents
All checks were successful
Release / release (push) Successful in 21s
2026-03-08 13:52:45 +01:00
f156aff839 chore: workflow for standalone apps added
All checks were successful
Release / release (push) Successful in 53s
chore: PMD added
chore: git-version for releases
2026-02-11 20:53:35 +01:00
12108b49d6 chore: Logo 2026-02-11 02:36:21 +01:00
40 changed files with 6813 additions and 345 deletions

View File

@@ -0,0 +1,84 @@
name: Release
on:
push:
tags:
- 'release@*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Java 21
uses: actions/setup-java@v3
with:
distribution: temurin
java-version: 21
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Build
run: ./gradlew clean build --no-daemon
- name: Generate release notes
id: notes
run: |
current_tag="${{ github.ref_name }}"
# strip the prefix for sorting, keep prefix for matching
prefix="release@"
# get all matching tags, strip prefix, sort them
all_versions=$(git tag --list "${prefix}*" | sed "s/^${prefix}//" | sort -V)
# find previous version
previous_tag=""
for v in $all_versions; do
if [[ "$prefix$v" == "$current_tag" ]]; then
break
fi
previous_tag="$prefix$v"
done
if [[ -z "$previous_tag" ]]; then
range=""
else
range="$previous_tag..$current_tag"
fi
echo "Comparing range: $range"
body="## What's New"
for category in "feat: Features" "fix: Bug Fixes" "docs: Documentation" "chore: Chores"; do
prefix="${category%%:*}"
title="${category##*: }"
entries=$(git log $range --pretty=format:"%B" --no-merges | ( grep "^${prefix}" || true ) | sed "s/^${prefix}:/- /")
if [[ -n "$entries" ]]; then
body="$body\n\n### $title\n$entries"
fi
done
echo -e "$body" > /tmp/release_notes.md
- name: Create Gitea Release
uses: softprops/action-gh-release@v2
with:
files: |
build/distributions/*.tar
build/distributions/*.zip
body_path: /tmp/release_notes.md

357
.ruleset Normal file
View File

@@ -0,0 +1,357 @@
<?xml version="1.0" encoding="UTF-8"?>
<ruleset xmlns="http://pmd.sourceforge.net/ruleset/2.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
name="EgothorRuleset"
xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0 https://pmd.sourceforge.io/ruleset_2_0_0.xsd">
<description>Egothor preferences rule set</description>
<exclude-pattern>.*/package-info\.java</exclude-pattern>
<rule ref="category/java/bestpractices.xml/AbstractClassWithoutAbstractMethod"/>
<rule ref="category/java/bestpractices.xml/AccessorClassGeneration"/>
<rule ref="category/java/bestpractices.xml/AccessorMethodGeneration"/>
<!-- rule ref="category/java/bestpractices.xml/ArrayIsStoredDirectly"/ -->
<rule ref="category/java/bestpractices.xml/AvoidMessageDigestField"/>
<rule ref="category/java/bestpractices.xml/AvoidPrintStackTrace"/>
<rule ref="category/java/bestpractices.xml/AvoidReassigningCatchVariables"/>
<rule ref="category/java/bestpractices.xml/AvoidReassigningLoopVariables"/>
<rule ref="category/java/bestpractices.xml/AvoidReassigningParameters"/>
<rule ref="category/java/bestpractices.xml/AvoidStringBufferField"/>
<rule ref="category/java/bestpractices.xml/AvoidUsingHardCodedIP"/>
<rule ref="category/java/bestpractices.xml/CheckResultSet"/>
<rule ref="category/java/bestpractices.xml/ConstantsInInterface"/>
<rule ref="category/java/bestpractices.xml/DefaultLabelNotLastInSwitch"/>
<rule ref="category/java/bestpractices.xml/DoubleBraceInitialization"/>
<rule ref="category/java/bestpractices.xml/ExhaustiveSwitchHasDefault"/>
<rule ref="category/java/bestpractices.xml/ForLoopCanBeForeach"/>
<rule ref="category/java/bestpractices.xml/ForLoopVariableCount"/>
<rule ref="category/java/bestpractices.xml/GuardLogStatement"/>
<rule ref="category/java/bestpractices.xml/ImplicitFunctionalInterface"/>
<rule ref="category/java/bestpractices.xml/JUnit4SuitesShouldUseSuiteAnnotation"/>
<rule ref="category/java/bestpractices.xml/JUnit5TestShouldBePackagePrivate"/>
<rule ref="category/java/bestpractices.xml/JUnitUseExpected"/>
<rule ref="category/java/bestpractices.xml/LiteralsFirstInComparisons"/>
<rule ref="category/java/bestpractices.xml/LooseCoupling"/>
<rule ref="category/java/bestpractices.xml/MethodReturnsInternalArray"/>
<rule ref="category/java/bestpractices.xml/MissingOverride"/>
<rule ref="category/java/bestpractices.xml/NonExhaustiveSwitch"/>
<rule ref="category/java/bestpractices.xml/OneDeclarationPerLine"/>
<rule ref="category/java/bestpractices.xml/PreserveStackTrace"/>
<rule ref="category/java/bestpractices.xml/PrimitiveWrapperInstantiation"/>
<rule ref="category/java/bestpractices.xml/ReplaceEnumerationWithIterator"/>
<rule ref="category/java/bestpractices.xml/ReplaceHashtableWithMap"/>
<rule ref="category/java/bestpractices.xml/ReplaceVectorWithList"/>
<rule ref="category/java/bestpractices.xml/SimplifiableTestAssertion"/>
<!-- rule ref="category/java/bestpractices.xml/SystemPrintln"/ -->
<rule ref="category/java/bestpractices.xml/UnitTestAssertionsShouldIncludeMessage"/>
<rule ref="category/java/bestpractices.xml/UnitTestContainsTooManyAsserts"/>
<rule ref="category/java/bestpractices.xml/UnitTestShouldIncludeAssert"/>
<rule ref="category/java/bestpractices.xml/UnitTestShouldUseAfterAnnotation"/>
<rule ref="category/java/bestpractices.xml/UnitTestShouldUseBeforeAnnotation"/>
<rule ref="category/java/bestpractices.xml/UnitTestShouldUseTestAnnotation"/>
<rule ref="category/java/bestpractices.xml/UnnecessaryVarargsArrayCreation"/>
<rule ref="category/java/bestpractices.xml/UnnecessaryWarningSuppression"/>
<rule ref="category/java/bestpractices.xml/UnusedAssignment"/>
<rule ref="category/java/bestpractices.xml/UnusedFormalParameter"/>
<rule ref="category/java/bestpractices.xml/UnusedLocalVariable"/>
<rule ref="category/java/bestpractices.xml/UnusedPrivateField"/>
<rule ref="category/java/bestpractices.xml/UnusedPrivateMethod"/>
<rule ref="category/java/bestpractices.xml/UseCollectionIsEmpty"/>
<rule ref="category/java/bestpractices.xml/UseEnumCollections"/>
<rule ref="category/java/bestpractices.xml/UseStandardCharsets"/>
<rule ref="category/java/bestpractices.xml/UseTryWithResources"/>
<rule ref="category/java/bestpractices.xml/UseVarargs"/>
<rule ref="category/java/bestpractices.xml/WhileLoopWithLiteralBoolean"/>
<!-- rule ref="category/java/codestyle.xml/AtLeastOneConstructor"/ -->
<rule ref="category/java/codestyle.xml/AvoidDollarSigns"/>
<rule ref="category/java/codestyle.xml/AvoidProtectedFieldInFinalClass"/>
<rule ref="category/java/codestyle.xml/AvoidProtectedMethodInFinalClassNotExtending"/>
<rule ref="category/java/codestyle.xml/AvoidUsingNativeCode"/>
<rule ref="category/java/codestyle.xml/BooleanGetMethodName"/>
<rule ref="category/java/codestyle.xml/CallSuperInConstructor"/>
<rule ref="category/java/codestyle.xml/ClassNamingConventions"/>
<rule ref="category/java/codestyle.xml/CommentDefaultAccessModifier"/>
<rule ref="category/java/codestyle.xml/ConfusingTernary"/>
<rule ref="category/java/codestyle.xml/ControlStatementBraces"/>
<rule ref="category/java/codestyle.xml/EmptyControlStatement"/>
<rule ref="category/java/codestyle.xml/EmptyMethodInAbstractClassShouldBeAbstract"/>
<rule ref="category/java/codestyle.xml/ExtendsObject"/>
<rule ref="category/java/codestyle.xml/FieldDeclarationsShouldBeAtStartOfClass"/>
<rule ref="category/java/codestyle.xml/FieldNamingConventions">
<properties>
<property name="publicConstantPattern" value="[A-Z][A-Z_0-9]*" />
<property name="constantPattern" value="[A-Z][A-Z_0-9]*" />
<property name="enumConstantPattern" value="[A-Z][A-Z_0-9]*" />
<property name="finalFieldPattern" value="[_a-zA-Z][a-zA-Z_0-9]*" />
<property name="staticFieldPattern" value="[_a-zA-Z][a-zA-Z0-9]*" />
<property name="defaultFieldPattern" value="[_a-z][a-zA-Z0-9]*" />
<property name="exclusions" value="serialVersionUID,serialPersistentFields" />
</properties>
</rule>
<rule ref="category/java/codestyle.xml/FinalParameterInAbstractMethod"/>
<rule ref="category/java/codestyle.xml/ForLoopShouldBeWhileLoop"/>
<rule ref="category/java/codestyle.xml/FormalParameterNamingConventions"/>
<!-- rule ref="category/java/codestyle.xml/GenericsNaming"/ -->
<rule ref="category/java/codestyle.xml/IdenticalCatchBranches"/>
<rule ref="category/java/codestyle.xml/LambdaCanBeMethodReference"/>
<rule ref="category/java/codestyle.xml/LinguisticNaming"/>
<rule ref="category/java/codestyle.xml/LocalHomeNamingConvention"/>
<rule ref="category/java/codestyle.xml/LocalInterfaceSessionNamingConvention"/>
<!-- rule ref="category/java/codestyle.xml/LocalVariableCouldBeFinal"/ -->
<rule ref="category/java/codestyle.xml/LocalVariableNamingConventions">
<properties>
<property name="localVarPattern" value="[_a-z][a-zA-Z0-9]*" />
<property name="finalVarPattern" value="[_a-zA-Z][a-zA-Z_0-9]*" />
<property name="catchParameterPattern" value="[a-z][a-zA-Z0-9]*" />
</properties>
</rule>
<rule ref="category/java/codestyle.xml/LongVariable">
<properties>
<property name="minimum" value="28" />
</properties>
</rule>
<rule ref="category/java/codestyle.xml/MDBAndSessionBeanNamingConvention"/>
<!-- rule ref="category/java/codestyle.xml/MethodArgumentCouldBeFinal"/ -->
<rule ref="category/java/codestyle.xml/MethodNamingConventions">
<properties>
<property name="methodPattern" value="[a-z][a-zA-Z0-9_]*" />
<property name="staticPattern" value="[a-z][a-zA-Z0-9_]*" />
<property name="nativePattern" value="[a-z][a-zA-Z0-9_]*" />
<property name="junit3TestPattern" value="test[A-Z0-9][a-zA-Z0-9]*" />
<property name="junit4TestPattern" value="[a-z][a-zA-Z0-9]*" />
<property name="junit5TestPattern" value="[a-z][a-zA-Z0-9]*" />
</properties>
</rule>
<rule ref="category/java/codestyle.xml/NoPackage"/>
<!-- rule ref="category/java/codestyle.xml/OnlyOneReturn"/ -->
<rule ref="category/java/codestyle.xml/PackageCase"/>
<rule ref="category/java/codestyle.xml/PrematureDeclaration"/>
<rule ref="category/java/codestyle.xml/RemoteInterfaceNamingConvention"/>
<rule ref="category/java/codestyle.xml/RemoteSessionInterfaceNamingConvention"/>
<rule ref="category/java/codestyle.xml/ShortClassName"/>
<!-- rule ref="category/java/codestyle.xml/ShortMethodName"/ -->
<!-- rule ref="category/java/codestyle.xml/ShortVariable"/ -->
<rule ref="category/java/codestyle.xml/TooManyStaticImports"/>
<rule ref="category/java/codestyle.xml/UnnecessaryAnnotationValueElement"/>
<rule ref="category/java/codestyle.xml/UnnecessaryBoxing"/>
<rule ref="category/java/codestyle.xml/UnnecessaryCast"/>
<rule ref="category/java/codestyle.xml/UnnecessaryConstructor"/>
<rule ref="category/java/codestyle.xml/UnnecessaryFullyQualifiedName"/>
<rule ref="category/java/codestyle.xml/UnnecessaryImport"/>
<!-- PMD 8.0.0: obsolete rule ref="category/java/codestyle.xml/UnnecessaryLocalBeforeReturn"/ -->
<rule ref="category/java/codestyle.xml/UnnecessaryModifier"/>
<rule ref="category/java/codestyle.xml/UnnecessaryReturn"/>
<rule ref="category/java/codestyle.xml/UnnecessarySemicolon"/>
<rule ref="category/java/codestyle.xml/UseDiamondOperator"/>
<rule ref="category/java/codestyle.xml/UseExplicitTypes"/>
<rule ref="category/java/codestyle.xml/UselessParentheses"/>
<rule ref="category/java/codestyle.xml/UselessQualifiedThis"/>
<rule ref="category/java/codestyle.xml/UseShortArrayInitializer"/>
<rule ref="category/java/codestyle.xml/UseUnderscoresInNumericLiterals"/>
<rule ref="category/java/design.xml/AbstractClassWithoutAnyMethod"/>
<rule ref="category/java/design.xml/AvoidDeeplyNestedIfStmts"/>
<rule ref="category/java/design.xml/AvoidRethrowingException"/>
<rule ref="category/java/design.xml/AvoidThrowingNewInstanceOfSameException"/>
<rule ref="category/java/design.xml/AvoidThrowingNullPointerException"/>
<rule ref="category/java/design.xml/AvoidThrowingRawExceptionTypes"/>
<rule ref="category/java/design.xml/AvoidUncheckedExceptionsInSignatures"/>
<rule ref="category/java/design.xml/ClassWithOnlyPrivateConstructorsShouldBeFinal"/>
<rule ref="category/java/design.xml/CognitiveComplexity">
<properties>
<property name="reportLevel" value="45" />
</properties>
</rule>
<rule ref="category/java/design.xml/CollapsibleIfStatements"/>
<rule ref="category/java/design.xml/CouplingBetweenObjects">
<properties>
<property name="threshold" value="50" />
</properties>
</rule>
<rule ref="category/java/design.xml/CyclomaticComplexity">
<properties>
<property name="methodReportLevel" value="18" />
</properties>
</rule>
<rule ref="category/java/design.xml/DataClass"/>
<rule ref="category/java/design.xml/DoNotExtendJavaLangError"/>
<rule ref="category/java/design.xml/ExceptionAsFlowControl"/>
<!-- rule ref="category/java/design.xml/ExcessiveImports"/ -->
<rule ref="category/java/design.xml/ExcessiveParameterList"/>
<rule ref="category/java/design.xml/ExcessivePublicCount"/>
<rule ref="category/java/design.xml/FinalFieldCouldBeStatic"/>
<!-- rule ref="category/java/design.xml/GodClass"/ -->
<rule ref="category/java/design.xml/ImmutableField"/>
<rule ref="category/java/design.xml/InvalidJavaBean"/>
<!-- rule ref="category/java/design.xml/LawOfDemeter"/ -->
<rule ref="category/java/design.xml/LogicInversion"/>
<!-- rule ref="category/java/design.xml/LoosePackageCoupling"/ -->
<rule ref="category/java/design.xml/MutableStaticState"/>
<rule ref="category/java/design.xml/NcssCount"/>
<rule ref="category/java/design.xml/NPathComplexity">
<properties>
<property name="reportLevel" value="260" />
</properties>
</rule>
<rule ref="category/java/design.xml/SignatureDeclareThrowsException"/>
<rule ref="category/java/design.xml/SimplifiedTernary"/>
<rule ref="category/java/design.xml/SimplifyBooleanExpressions"/>
<rule ref="category/java/design.xml/SimplifyBooleanReturns"/>
<rule ref="category/java/design.xml/SimplifyConditional"/>
<rule ref="category/java/design.xml/SingularField"/>
<rule ref="category/java/design.xml/SwitchDensity"/>
<rule ref="category/java/design.xml/TooManyFields">
<properties>
<property name="maxfields" value="30"/>
</properties>
</rule>
<rule ref="category/java/design.xml/TooManyMethods">
<properties>
<property name="maxmethods" value="50"/>
</properties>
</rule>
<rule ref="category/java/design.xml/UselessOverridingMethod"/>
<rule ref="category/java/design.xml/UseObjectForClearerAPI"/>
<rule ref="category/java/design.xml/UseUtilityClass"/>
<rule ref="category/java/documentation.xml/CommentContent"/>
<rule ref="category/java/documentation.xml/CommentRequired">
<properties>
<property name="fieldCommentRequirement" value="Ignored"/>
</properties>
</rule>
<rule ref="category/java/documentation.xml/CommentSize">
<properties>
<property name="maxLines" value="150"/>
<property name="maxLineLength" value="130"/>
</properties>
</rule>
<rule ref="category/java/documentation.xml/UncommentedEmptyConstructor"/>
<rule ref="category/java/documentation.xml/UncommentedEmptyMethodBody"/>
<!-- rule ref="category/java/errorprone.xml/AssignmentInOperand"/ -->
<rule ref="category/java/errorprone.xml/AssignmentToNonFinalStatic"/>
<rule ref="category/java/errorprone.xml/AvoidAccessibilityAlteration"/>
<rule ref="category/java/errorprone.xml/AvoidAssertAsIdentifier"/>
<rule ref="category/java/errorprone.xml/AvoidBranchingStatementAsLastInLoop"/>
<rule ref="category/java/errorprone.xml/AvoidCallingFinalize"/>
<rule ref="category/java/errorprone.xml/AvoidCatchingGenericException">
<properties>
<property name="typesThatShouldNotBeCaught" value="java.lang.RuntimeException,java.lang.Throwable,java.lang.Error" />
</properties>
</rule>
<rule ref="category/java/errorprone.xml/AvoidDecimalLiteralsInBigDecimalConstructor"/>
<rule ref="category/java/errorprone.xml/AvoidDuplicateLiterals">
<properties>
<property name="maxDuplicateLiterals" value="6"/>
<property name="skipAnnotations" value="true"/>
</properties>
</rule>
<rule ref="category/java/errorprone.xml/AvoidEnumAsIdentifier"/>
<!-- rule ref="category/java/errorprone.xml/AvoidFieldNameMatchingMethodName"/ -->
<rule ref="category/java/errorprone.xml/AvoidFieldNameMatchingTypeName"/>
<rule ref="category/java/errorprone.xml/AvoidInstanceofChecksInCatchClause"/>
<rule ref="category/java/errorprone.xml/AvoidLiteralsInIfCondition"/>
<rule ref="category/java/errorprone.xml/AvoidMultipleUnaryOperators"/>
<rule ref="category/java/errorprone.xml/AvoidUsingOctalValues"/>
<rule ref="category/java/errorprone.xml/BrokenNullCheck"/>
<rule ref="category/java/errorprone.xml/CallSuperFirst"/>
<rule ref="category/java/errorprone.xml/CallSuperLast"/>
<rule ref="category/java/errorprone.xml/CheckSkipResult"/>
<rule ref="category/java/errorprone.xml/ClassCastExceptionWithToArray"/>
<rule ref="category/java/errorprone.xml/CloneMethodMustBePublic"/>
<rule ref="category/java/errorprone.xml/CloneMethodMustImplementCloneable"/>
<rule ref="category/java/errorprone.xml/CloneMethodReturnTypeMustMatchClassName"/>
<rule ref="category/java/errorprone.xml/CloseResource"/>
<rule ref="category/java/errorprone.xml/CompareObjectsWithEquals"/>
<rule ref="category/java/errorprone.xml/ComparisonWithNaN"/>
<rule ref="category/java/errorprone.xml/ConfusingArgumentToVarargsMethod"/>
<rule ref="category/java/errorprone.xml/ConstructorCallsOverridableMethod"/>
<rule ref="category/java/errorprone.xml/DetachedTestCase"/>
<rule ref="category/java/errorprone.xml/DoNotCallGarbageCollectionExplicitly"/>
<rule ref="category/java/errorprone.xml/DoNotExtendJavaLangThrowable"/>
<rule ref="category/java/errorprone.xml/DoNotHardCodeSDCard"/>
<rule ref="category/java/errorprone.xml/DoNotTerminateVM"/>
<rule ref="category/java/errorprone.xml/DoNotThrowExceptionInFinally"/>
<rule ref="category/java/errorprone.xml/DontImportSun"/>
<rule ref="category/java/errorprone.xml/DontUseFloatTypeForLoopIndices"/>
<rule ref="category/java/errorprone.xml/EmptyCatchBlock"/>
<rule ref="category/java/errorprone.xml/EmptyFinalizer"/>
<rule ref="category/java/errorprone.xml/EqualsNull"/>
<rule ref="category/java/errorprone.xml/FinalizeDoesNotCallSuperFinalize"/>
<rule ref="category/java/errorprone.xml/FinalizeOnlyCallsSuperFinalize"/>
<rule ref="category/java/errorprone.xml/FinalizeOverloaded"/>
<rule ref="category/java/errorprone.xml/FinalizeShouldBeProtected"/>
<rule ref="category/java/errorprone.xml/IdempotentOperations"/>
<rule ref="category/java/errorprone.xml/ImplicitSwitchFallThrough"/>
<rule ref="category/java/errorprone.xml/InstantiationToGetClass"/>
<rule ref="category/java/errorprone.xml/InvalidLogMessageFormat"/>
<rule ref="category/java/errorprone.xml/JumbledIncrementer"/>
<rule ref="category/java/errorprone.xml/JUnitSpelling"/>
<rule ref="category/java/errorprone.xml/JUnitStaticSuite"/>
<rule ref="category/java/errorprone.xml/MethodWithSameNameAsEnclosingClass"/>
<rule ref="category/java/errorprone.xml/MisplacedNullCheck"/>
<rule ref="category/java/errorprone.xml/MissingSerialVersionUID"/>
<rule ref="category/java/errorprone.xml/MissingStaticMethodInNonInstantiatableClass"/>
<rule ref="category/java/errorprone.xml/MoreThanOneLogger"/>
<rule ref="category/java/errorprone.xml/NonCaseLabelInSwitch"/>
<rule ref="category/java/errorprone.xml/NonSerializableClass"/>
<rule ref="category/java/errorprone.xml/NonStaticInitializer"/>
<!-- rule ref="category/java/errorprone.xml/NullAssignment"/ -->
<rule ref="category/java/errorprone.xml/OverrideBothEqualsAndHashcode"/>
<rule ref="category/java/errorprone.xml/ProperCloneImplementation"/>
<rule ref="category/java/errorprone.xml/ProperLogger"/>
<rule ref="category/java/errorprone.xml/ReturnEmptyCollectionRatherThanNull"/>
<rule ref="category/java/errorprone.xml/ReturnFromFinallyBlock"/>
<rule ref="category/java/errorprone.xml/SimpleDateFormatNeedsLocale"/>
<rule ref="category/java/errorprone.xml/SingleMethodSingleton"/>
<rule ref="category/java/errorprone.xml/SingletonClassReturningNewInstance"/>
<rule ref="category/java/errorprone.xml/StaticEJBFieldShouldBeFinal"/>
<rule ref="category/java/errorprone.xml/StringBufferInstantiationWithChar"/>
<rule ref="category/java/errorprone.xml/SuspiciousEqualsMethodName"/>
<rule ref="category/java/errorprone.xml/SuspiciousHashcodeMethodName"/>
<rule ref="category/java/errorprone.xml/SuspiciousOctalEscape"/>
<rule ref="category/java/errorprone.xml/TestClassWithoutTestCases"/>
<rule ref="category/java/errorprone.xml/UnconditionalIfStatement"/>
<rule ref="category/java/errorprone.xml/UnnecessaryBooleanAssertion"/>
<rule ref="category/java/errorprone.xml/UnnecessaryCaseChange"/>
<rule ref="category/java/errorprone.xml/UnnecessaryConversionTemporary"/>
<rule ref="category/java/errorprone.xml/UnusedNullCheckInEquals"/>
<rule ref="category/java/errorprone.xml/UseCorrectExceptionLogging"/>
<rule ref="category/java/errorprone.xml/UseEqualsToCompareStrings"/>
<rule ref="category/java/errorprone.xml/UselessPureMethodCall" />
<rule ref="category/java/errorprone.xml/UseLocaleWithCaseConversions"/>
<rule ref="category/java/errorprone.xml/UseProperClassLoader"/>
<rule ref="category/java/multithreading.xml/AvoidSynchronizedAtMethodLevel"/>
<rule ref="category/java/multithreading.xml/AvoidSynchronizedStatement"/>
<rule ref="category/java/multithreading.xml/AvoidThreadGroup"/>
<rule ref="category/java/multithreading.xml/AvoidUsingVolatile"/>
<rule ref="category/java/multithreading.xml/DoNotUseThreads"/>
<rule ref="category/java/multithreading.xml/DontCallThreadRun"/>
<rule ref="category/java/multithreading.xml/DoubleCheckedLocking"/>
<rule ref="category/java/multithreading.xml/NonThreadSafeSingleton"/>
<rule ref="category/java/multithreading.xml/UnsynchronizedStaticFormatter"/>
<!-- rule ref="category/java/multithreading.xml/UseConcurrentHashMap"/ -->
<rule ref="category/java/multithreading.xml/UseNotifyAllInsteadOfNotify"/>
<rule ref="category/java/performance.xml/AddEmptyString"/>
<rule ref="category/java/performance.xml/AppendCharacterWithChar"/>
<rule ref="category/java/performance.xml/AvoidArrayLoops"/>
<rule ref="category/java/performance.xml/AvoidCalendarDateCreation"/>
<rule ref="category/java/performance.xml/AvoidFileStream"/>
<rule ref="category/java/performance.xml/AvoidInstantiatingObjectsInLoops"/>
<rule ref="category/java/performance.xml/BigIntegerInstantiation"/>
<rule ref="category/java/performance.xml/ConsecutiveAppendsShouldReuse"/>
<rule ref="category/java/performance.xml/ConsecutiveLiteralAppends"/>
<rule ref="category/java/performance.xml/InefficientEmptyStringCheck"/>
<rule ref="category/java/performance.xml/InefficientStringBuffering"/>
<rule ref="category/java/performance.xml/InsufficientStringBufferDeclaration"/>
<rule ref="category/java/performance.xml/OptimizableToArrayCall"/>
<rule ref="category/java/performance.xml/RedundantFieldInitializer"/>
<rule ref="category/java/performance.xml/StringInstantiation"/>
<rule ref="category/java/performance.xml/StringToString"/>
<rule ref="category/java/performance.xml/TooFewBranchesForSwitch"/>
<rule ref="category/java/performance.xml/UseArrayListInsteadOfVector"/>
<rule ref="category/java/performance.xml/UseArraysAsList"/>
<rule ref="category/java/performance.xml/UseIndexOfChar"/>
<rule ref="category/java/performance.xml/UseIOStreamsWithApacheCommonsFileItem"/>
<rule ref="category/java/performance.xml/UselessStringValueOf"/>
<rule ref="category/java/performance.xml/UseStringBufferForStringAppends"/>
<rule ref="category/java/performance.xml/UseStringBufferLength"/>
<rule ref="category/java/security.xml/HardCodedCryptoKey"/>
<rule ref="category/java/security.xml/InsecureCryptoIv"/>
</ruleset>

BIN
MethodAtlas.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 KiB

381
README.md
View File

@@ -1,42 +1,117 @@
# MethodAtlasApp
`MethodAtlasApp` is a small standalone CLI that scans Java source trees for JUnit test methods and prints per-method
statistics:
<img src="MethodAtlas.png" width="20%" align="right" alt="MethodAtlas logo" />
- **FQCN** (fully-qualified class name)
- **method name**
- **LOC** (lines of code, based on the AST source range)
- **@Tag values** attached to the method (supports repeated `@Tag` and `@Tags({...})`)
MethodAtlas is a small standalone CLI that scans Java source trees for JUnit 5 test methods and emits one record per discovered method.
It supports two output modes:
The tool combines **deterministic source analysis** with optional **AI-assisted classification** so that developers can quickly understand what a test suite contains and which tests appear security-relevant.
- **CSV** (default)
- **Plain text** (`-plain` as the first CLI argument)
Unlike tools that rely entirely on large language models or agent pipelines, MethodAtlas separates the problem into two parts:
## Build & run
- **Deterministic discovery** — a Java AST parser determines exactly which test methods exist
- **AI interpretation** — an optional model classifies those methods and suggests security-related annotations
Assuming you have a runnable JAR (e.g. `methodatlas.jar`):
This approach keeps the analysis **predictable, reproducible, and reviewable**, while still benefiting from AI where it adds value.
The parser determines *what exists* in the code.
The AI suggests *what it means*.
## What MethodAtlas reports
For each discovered JUnit test method, MethodAtlas emits a single record containing:
- `fqcn` fully qualified class name
- `method` test method name
- `loc` inclusive lines of code for the method declaration
- `tags` existing JUnit `@Tag` values declared on the method
When AI enrichment is enabled, additional fields are included:
- `ai_security_relevant` whether the model classified the test as security-relevant
- `ai_display_name` suggested security-oriented `@DisplayName`
- `ai_tags` suggested security taxonomy tags
- `ai_reason` short rationale for the classification
These suggestions help identify tests that verify authentication, access control, cryptography, input validation, or other security-relevant behavior.
## Deterministic method discovery
Test discovery is performed using **JavaParser** and the Java AST rather than regex scanning or LLM inference.
The CLI:
- scans files matching `*Test.java`
- detects JUnit Jupiter methods annotated with
`@Test`, `@ParameterizedTest`, or `@RepeatedTest`
- extracts existing tags from both repeated `@Tag` usage and `@Tags({...})`
Because the list of test methods is obtained from the AST, the analysis is **deterministic and reproducible** regardless of the AI provider used for classification.
## AI-assisted security classification
If AI mode is enabled, MethodAtlas sends the **full class source for context** together with the **exact list of parser-discovered test methods**.
The model is asked to classify only those methods and suggest:
- whether the test appears security-relevant
- consistent security taxonomy tags
- a meaningful security-oriented display name
This design avoids relying on AI to infer program structure and instead uses it only for semantic interpretation.
MethodAtlas supports multiple providers and can also run against **locally hosted models via Ollama**, allowing teams to use AI without exposing proprietary source code.
MethodAtlas is designed to be lightweight, deterministic, and easy to integrate into developer workflows or CI pipelines.
## Distribution layout
After building and packaging, the distribution archive has this structure:
```text
methodatlas-<version>/
├── bin/
│ ├── methodatlas
│ └── methodatlas.bat
└── lib/
└── methodatlas-<version>.jar
```
Run the CLI from the `bin` directory, for example:
```bash
java -jar methodatlas.jar [ -plain ] <path1> [<path2> ...]
````
cd methodatlas-<version>/bin
./methodatlas /path/to/project
```
* If **no paths** are provided, the current directory (i.e., `.`) is scanned.
* Multiple root paths are supported.
## Usage
```bash
./methodatlas [options] [path1] [path2] ...
```
If no scan path is provided, the current directory is scanned. Multiple root paths are supported.
## Output modes
### CSV (default)
### CSV mode (default)
* Prints a **header line**
* Each record contains **values only**
* Tags are **semicolon-separated** in the `tags` column (empty if no tags)
CSV mode prints a header followed by one record per discovered test method.
Without AI:
```text
fqcn,method,loc,tags
```
With AI:
```text
fqcn,method,loc,tags,ai_security_relevant,ai_display_name,ai_tags,ai_reason
```
Example:
```text
Feb 11, 2026 1:33:35 AM org.egothor.methodatlas.MethodAtlasApp scanRoot
INFO: Scanning /tmp/junit-15560885133010516491 for JUnit files
fqcn,method,loc,tags
com.acme.tests.SampleOneTest,alpha,8,fast;crypto
com.acme.tests.SampleOneTest,beta,6,param
@@ -44,30 +119,264 @@ com.acme.tests.SampleOneTest,gamma,4,nested1;nested2
com.acme.other.AnotherTest,delta,3,
```
### Plain text (`-plain`)
### Plain mode
* Prints one line per detected method:
* `FQCN, method, LOC=<n>, TAGS=<tag1;tag2;...>`
* If a method has **no tags**, it prints `TAGS=-`
Enable plain mode with `-plain`:
Example:
```bash
./methodatlas -plain /path/to/project
```
Plain mode renders one line per method:
```text
Feb 11, 2026 1:33:35 AM org.egothor.methodatlas.MethodAtlasApp scanRoot
INFO: Scanning /tmp/junit-12139245189413750595 for JUnit files
com.acme.tests.SampleOneTest, alpha, LOC=8, TAGS=fast;crypto
com.acme.tests.SampleOneTest, beta, LOC=6, TAGS=param
com.acme.tests.SampleOneTest, gamma, LOC=4, TAGS=nested1;nested2
com.acme.other.AnotherTest, delta, LOC=3, TAGS=-
```
If a method has no source-level JUnit tags, plain mode prints `TAGS=-`.
## AI enrichment
When AI support is enabled, MethodAtlas submits each parsed test class to a provider-agnostic suggestion engine and merges returned method-level suggestions into the emitted output.
The AI subsystem can:
- classify whether a test is security-relevant
- propose a `SECURITY: ...` display name
- assign controlled taxonomy tags
- provide a short rationale
Supported providers:
- `auto`
- `ollama`
- `openai`
- `openrouter`
- `anthropic`
In `auto` mode, MethodAtlas prefers a reachable local Ollama instance and otherwise falls back to an OpenAI-compatible provider when an API key is configured.
## Complete command-line arguments
### General options
| Argument | Meaning | Default |
| --- | --- | --- |
| `-plain` | Emit plain text instead of CSV | CSV mode |
| `[path ...]` | One or more root paths to scan | Current directory |
### AI options
| Argument | Meaning | Notes / default |
| --- | --- | --- |
| `-ai` | Enable AI enrichment | Disabled by default |
| `-ai-provider <provider>` | Select provider | `auto`, `ollama`, `openai`, `openrouter`, `anthropic` |
| `-ai-model <model>` | Provider-specific model identifier | Default is `qwen2.5-coder:7b` |
| `-ai-base-url <url>` | Override provider base URL | Provider-specific default URL is used otherwise |
| `-ai-api-key <key>` | Supply API key directly on the command line | Useful for quick experiments; env vars are often preferable |
| `-ai-api-key-env <name>` | Read API key from an environment variable | Used if `-ai-api-key` is not supplied |
| `-ai-taxonomy <path>` | Load taxonomy text from an external file | Overrides built-in taxonomy text |
| `-ai-taxonomy-mode <mode>` | Select built-in taxonomy mode | `default` or `optimized`; default is `default` |
| `-ai-max-class-chars <count>` | Skip AI analysis for larger classes | Default is `40000` |
| `-ai-timeout-sec <seconds>` | Set request timeout for provider calls | Default is `90` seconds |
| `-ai-max-retries <count>` | Set retry limit for AI operations | Default is `1` |
Unknown options cause an error. Missing option values also fail fast.
### Argument details
#### `-plain`
Switches output rendering from CSV to a human-readable line-oriented format. This affects rendering only; method discovery and AI classification behavior remain the same.
#### `-ai`
Turns on AI enrichment. Without this flag, MethodAtlas behaves as a pure static scanner and emits only source-derived metadata. When this flag is present, the application initializes an AI suggestion engine before scanning.
#### `-ai-provider <provider>`
Selects the provider implementation.
Accepted values are case-insensitive because the CLI normalizes them internally before mapping them to the provider enum. Available providers are:
- `auto`
- `ollama`
- `openai`
- `openrouter`
- `anthropic`
`auto` is the default.
#### `-ai-model <model>`
Specifies the provider-specific model name. Examples include local Ollama model names or hosted model identifiers accepted by OpenAI-compatible providers. The default is `qwen2.5-coder:7b`.
#### `-ai-base-url <url>`
Overrides the provider base URL.
If omitted, MethodAtlas uses these defaults:
| Provider | Default base URL |
| --- | --- |
| `auto` | `http://localhost:11434` |
| `ollama` | `http://localhost:11434` |
| `openai` | `https://api.openai.com` |
| `openrouter` | `https://openrouter.ai/api` |
| `anthropic` | `https://api.anthropic.com` |
This is useful for self-hosted gateways, proxies, compatible endpoints, or non-default local deployments.
#### `-ai-api-key <key>`
Provides the API key directly. This takes precedence over `-ai-api-key-env` because the resolved API key logic first checks the explicit key and only then consults the environment variable.
#### `-ai-api-key-env <name>`
Reads the API key from an environment variable such as:
```bash
export OPENROUTER_API_KEY=...
./methodatlas -ai -ai-provider openrouter -ai-api-key-env OPENROUTER_API_KEY /path/to/tests
```
If both `-ai-api-key` and `-ai-api-key-env` are omitted, providers that require hosted authentication will be unavailable.
#### `-ai-taxonomy <path>`
Loads taxonomy text from an external file instead of using the built-in taxonomy. This lets you tailor classification categories or rules to your own security testing conventions.
#### `-ai-taxonomy-mode <mode>`
Selects one of the built-in taxonomy variants:
- `default` — more descriptive, human-readable taxonomy
- `optimized` — more compact taxonomy intended to improve model reliability and reduce prompt size
When `-ai-taxonomy` is also supplied, the external taxonomy file takes precedence.
#### `-ai-max-class-chars <count>`
Sets the maximum serialized class size eligible for AI analysis. If a class source exceeds this number of characters, MethodAtlas skips AI classification for that class and continues scanning normally.
#### `-ai-timeout-sec <seconds>`
Configures the timeout applied to AI provider requests. The default is 90 seconds.
#### `-ai-max-retries <count>`
Configures the retry count retained in AI runtime options. The current default is `1`.
## Example commands
Basic scan:
```bash
./methodatlas /path/to/project
```
Plain output:
```bash
./methodatlas -plain /path/to/project
```
AI with OpenRouter and direct API key:
```bash
./methodatlas -ai -ai-provider openrouter -ai-api-key YOUR_API_KEY -ai-model stepfun/step-3.5-flash:free /path/to/junit/tests
```
AI with OpenRouter and environment variable:
```bash
export OPENROUTER_API_KEY=YOUR_API_KEY
./methodatlas -ai -ai-provider openrouter -ai-api-key-env OPENROUTER_API_KEY -ai-model stepfun/step-3.5-flash:free /path/to/junit/tests
```
AI with local Ollama:
```bash
./methodatlas -ai -ai-provider ollama -ai-model qwen2.5-coder:7b /path/to/junit/tests
```
Automatic provider selection:
```bash
./methodatlas -ai /path/to/junit/tests
```
## Highlighted example: AI extension in action
In a real packaged setup, running MethodAtlas from the unzipped distribution against a subset of MethodAtlas and ZeroEcho test sources with:
```bash
./methodatlas -ai -ai-provider openrouter -ai-api-key OBTAIN_YOUR_API_KEY -ai-model stepfun/step-3.5-flash:free some/dir/with/junit/tests/
```
produced output such as:
```csv
fqcn,method,loc,tags,ai_security_relevant,ai_display_name,ai_tags,ai_reason
org.egothor.methodatlas.MethodAtlasAppTest,csvMode_detectsMethodsLocAndTags,22,,false,,,"Test verifies functional output format and data extraction of MethodAtlasApp, not security properties."
org.egothor.methodatlas.MethodAtlasAppTest,plainMode_detectsMethodsLocAndTags,20,,false,,,"Test verifies functional output format and data extraction of MethodAtlasApp, not security properties."
zeroecho.core.alg.aes.AesGcmCrossCheckTest,aesGcm_stream_vs_jca_ctxOnly_crosscheck,52,,true,SECURITY: crypto - cross-check AES-GCM stream encryption with JCA reference,security;crypto,"The test verifies that the custom AES-GCM stream implementation produces identical ciphertexts and plaintexts as the JCA reference, ensuring cryptographic correctness and preventing failures that could lead to loss of confidentiality or integrity."
zeroecho.core.alg.aes.AesLargeDataTest,aesGcmLargeData_ctxOnly,27,,true,SECURITY: crypto - AES-GCM round-trip with context-only parameters,security;crypto,"Tests encryption and decryption correctness for large data using AES-GCM, ensuring the authenticated encryption mechanism functions properly for confidentiality and integrity."
zeroecho.core.alg.aes.AesLargeDataTest,aesGcmLargeData_headerCodec,29,,true,SECURITY: crypto - AES-GCM round-trip with header codec,security;crypto,"Validates AES-GCM with an in-band header codec, confirming correct handling of additional authenticated data in the encryption process."
zeroecho.core.alg.aes.AesLargeDataTest,aesCbcPkcs5LargeData_ctxOnly,27,,true,SECURITY: crypto - AES-CBC/PKCS7Padding round-trip with context-only IV,security;crypto,"Ensures AES-CBC encryption and decryption with PKCS7 padding works correctly for large data, testing confidentiality without integrity protection."
zeroecho.core.alg.mldsa.MldsaLargeDataTest,mldsa_complete_suite_streaming_sign_verify_large_data,24,,true,SECURITY: crypto - ML-DSA streaming signature and verification for large data with integrity check,security;crypto;owasp,"Validates cryptographic correctness of ML-DSA signature creation and verification, including handling large data streams, signature length checks, and rejection of tampered signatures via bit-flip, ensuring data integrity and resistance to forgery."
```
What this shows in practice:
- Functional tests remain untouched.
- Security-relevant cryptographic tests are detected correctly.
- The tool suggests consistent taxonomy tags such as `security`, `crypto`, and, where appropriate, `owasp`.
- The generated display names are already suitable as candidate `@DisplayName` values.
- The rationale column explains why a method was classified as security-relevant.
For a programmer, this turns a raw test tree into a searchable, structured inventory of security tests without requiring manual tagging of every method.
## Built-in security taxonomy
The prompt builder enforces a closed tag set so that providers do not invent categories. The built-in taxonomy covers these security areas:
- `auth`
- `access-control`
- `crypto`
- `input-validation`
- `injection`
- `data-protection`
- `logging`
- `error-handling`
- `owasp`
Every security-relevant method must include the umbrella tag `security`, and suggested display names should follow:
```text
SECURITY: <security property> - <scenario>
```
MethodAtlas ships both a default taxonomy and a more compact optimized taxonomy.
## Why this is useful
MethodAtlas is useful when you need to:
- inventory a large JUnit suite quickly
- find tests that already validate security properties
- identify where security tagging is inconsistent or missing
- export structured metadata for reporting, dashboards, or CI jobs
- review security test coverage before an audit or release
Because the application emits one row per test method, the output is easy to pipe into shell scripts, spreadsheets, data pipelines, or further static analysis.
## Notes
* The scanner looks for files ending with `*Test.java`.
* JUnit methods are detected by annotations such as:
* `@Test`
* `@ParameterizedTest`
* `@RepeatedTest`
* Tag extraction supports:
* `@Tag("x")` (including repeated `@Tag`)
* `@Tags({ @Tag("x"), @Tag("y") })`
- The scanner currently considers files ending with `*Test.java`.
- AI classification is class-contextual: the full class source is submitted so the model can classify methods with more context.
- If AI support is enabled but engine initialization fails, the application aborts.
- If AI classification of a particular class fails, the scan continues and MethodAtlas emits base metadata without AI suggestions for that class.

View File

@@ -1,10 +1,27 @@
plugins {
id 'java'
id 'application'
id 'pmd'
id 'com.palantir.git-version' version '4.0.0'
}
group = 'org.egothor.methodatlas'
version = '0.1.0-SNAPSHOT'
version = gitVersion(prefix:'release@')
configurations {
mockitoAgent
}
pmd {
consoleOutput = true
toolVersion = '7.20.0'
sourceSets = [sourceSets.main]
ruleSetFiles = files(rootProject.file(".ruleset"))
}
tasks.withType(Pmd) {
maxHeapSize = "16g"
}
java {
toolchain {
@@ -18,10 +35,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 {
@@ -32,17 +66,47 @@ tasks.test {
useJUnitPlatform()
}
jar {
javadoc {
failOnError = false
options.addStringOption('Xdoclint:all,-missing', '-quiet')
options.addBooleanOption('html5', true)
options.tags('apiNote:a:API Note:')
options.tags('implSpec:a:Implementation Requirements:')
options.tags('implNote:a:Implementation Note:')
options.tags('param')
options.tags('return')
options.tags('throws')
options.tags('since')
options.tags('version')
options.tags('serialData')
options.tags('factory')
options.tags('see')
options.use = true
options.author = true
options.version = true
options.windowTitle = 'MethodAtlas'
options.docTitle = 'MethodAtlas API'
source = sourceSets.main.allJava
}
tasks.named('jar') {
enabled = false
}
tasks.register('fatJar', Jar) {
archiveClassifier = ''
manifest {
attributes(
'Main-Class': application.mainClass,
'Main-Class': application.mainClass.get(),
'Implementation-Title': rootProject.name,
'Implementation-Version': "${version}"
'Implementation-Version': "${version}"
)
}
from sourceSets.main.output
dependsOn configurations.runtimeClasspath
// Include each JAR dependency
@@ -66,3 +130,51 @@ jar {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
distributions {
create('fat') {
distributionBaseName = project.name
contents {
into('bin') {
from(tasks.named('startScripts'))
filePermissions {
unix('rwxr-xr-x')
}
}
into('lib') {
from(tasks.named('fatJar'))
}
from('src/dist')
}
}
}
tasks.named('assemble') {
dependsOn tasks.named('fatDistZip'), tasks.named('fatDistTar')
}
tasks.named('startScripts') {
dependsOn tasks.named('fatJar')
classpath = files(tasks.named('fatJar').flatMap { it.archiveFile })
}
gradle.taskGraph.whenReady { taskGraph ->
def banner = """
\u001B[34m
8888888888 .d8888b. .d88888b. 88888888888 888 888 .d88888b. 8888888b.
888 d88P Y88b d88P" "Y88b 888 888 888 d88P" "Y88b 888 Y88b
888 888 888 888 888 888 888 888 888 888 888 888
8888888 888 888 888 888 8888888888 888 888 888 d88P
888 888 88888 888 888 888 888 888 888 888 8888888P"
888 888 888 888 888 888 888 888 888 888 888 T88b
888 Y88b d88P Y88b. .d88P 888 888 888 Y88b. .d88P 888 T88b
8888888888 "Y8888P88 "Y88888P" 888 888 888 "Y88888P" 888 T88b
\u001B[36m
Project : ${project.name}
Version : ${project.version}
\u001B[0m
"""
println banner
}

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

File diff suppressed because it is too large Load Diff

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,97 @@
package org.egothor.methodatlas.ai;
import java.util.List;
/**
* 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
* @param targetMethods deterministically extracted JUnit test methods that must
* be classified
* @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,
List<PromptBuilder.TargetMethod> targetMethods) 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,72 @@
package org.egothor.methodatlas.ai;
import java.util.List;
/**
* 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
*/
@SuppressWarnings("PMD.ImplicitFunctionalInterface")
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
* @param targetMethods deterministically extracted JUnit test methods that must
* be classified
* @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, List<PromptBuilder.TargetMethod> targetMethods)
throws AiSuggestionException;
}

View File

@@ -0,0 +1,130 @@
package org.egothor.methodatlas.ai;
import java.io.IOException;
import java.nio.file.Files;
import java.util.List;
/**
* 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
* @param targetMethods deterministically extracted JUnit test methods that must
* be classified
* @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,
List<PromptBuilder.TargetMethod> targetMethods) throws AiSuggestionException {
return client.suggestForClass(fqcn, classSource, taxonomyText, targetMethods);
}
/**
* 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,261 @@
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
* @param targetMethods deterministically extracted JUnit test methods that must
* be classified
*
* @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,
List<PromptBuilder.TargetMethod> targetMethods) throws AiSuggestionException {
try {
String prompt = PromptBuilder.build(fqcn, classSource, taxonomyText, targetMethods);
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) { // NOPMD
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,279 @@
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
* @param targetMethods deterministically extracted JUnit test methods that must
* be classified
* @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,
List<PromptBuilder.TargetMethod> targetMethods) throws AiSuggestionException {
try {
String prompt = PromptBuilder.build(fqcn, classSource, taxonomyText, targetMethods);
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) { // NOPMD
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,258 @@
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
* @param targetMethods deterministically extracted JUnit test methods that must
* be classified
* @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,
List<PromptBuilder.TargetMethod> targetMethods) throws AiSuggestionException {
try {
String prompt = PromptBuilder.build(fqcn, classSource, taxonomyText, targetMethods);
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) { // NOPMD
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,207 @@
package org.egothor.methodatlas.ai;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* 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>
* This revision keeps the full class source as semantic context but removes
* method discovery from the AI model. The caller supplies the exact list of
* JUnit test methods that must be classified, optionally with source line
* anchors.
* </p>
*
* <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 {
/**
* Deterministically extracted test method descriptor supplied to the prompt.
*
* @param methodName name of the JUnit test method
* @param beginLine first source line of the method, or {@code null} if unknown
* @param endLine last source line of the method, or {@code null} if unknown
*/
public record TargetMethod(String methodName, Integer beginLine, Integer endLine) {
public TargetMethod {
Objects.requireNonNull(methodName, "methodName");
if (methodName.isBlank()) {
throw new IllegalArgumentException("methodName must not be blank");
}
}
}
/**
* 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>the exact list of target test methods to classify</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
* @param targetMethods exact list of deterministically discovered JUnit test
* methods to classify
* @return formatted prompt supplied to the AI provider
*
* @see AiSuggestionEngine#suggestForClass(String, String)
*/
public static String build(String fqcn, String classSource, String taxonomyText, List<TargetMethod> targetMethods) {
Objects.requireNonNull(fqcn, "fqcn");
Objects.requireNonNull(classSource, "classSource");
Objects.requireNonNull(taxonomyText, "taxonomyText");
Objects.requireNonNull(targetMethods, "targetMethods");
if (targetMethods.isEmpty()) {
throw new IllegalArgumentException("targetMethods must not be empty");
}
String targetMethodBlock = targetMethods.stream().map(PromptBuilder::formatTargetMethod)
.collect(Collectors.joining("\n"));
String expectedMethodNames = targetMethods.stream().map(TargetMethod::methodName)
.map(name -> "\"" + name + "\"").collect(Collectors.joining(", "));
return """
You are analyzing a single JUnit 5 test class and suggesting security tags.
TASK
- Analyze the WHOLE class for context.
- Classify ONLY the methods explicitly listed in TARGET TEST METHODS.
- Do not invent methods that do not exist.
- Do not classify helper methods, lifecycle methods, nested classes, or any method not listed.
- 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
TARGET TEST METHODS
The following methods were extracted deterministically by the parser and are the ONLY methods
you are allowed to classify. Use the full class source only as context for understanding them.
%s
OUTPUT RULES
- Return JSON only.
- No markdown.
- No prose outside JSON.
- Return exactly one result for each target method.
- methodName values in the output must exactly match one of:
[%s]
- Do not omit any listed method.
- Do not include any additional methods.
- 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.
- If securityRelevant=false, displayName must be null.
- If securityRelevant=false, tags must be [].
- If securityRelevant=true, displayName must match:
SECURITY: <control/property> - <scenario>
- reason should be short and specific.
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, targetMethodBlock, expectedMethodNames, fqcn, classSource);
}
private static String formatTargetMethod(TargetMethod targetMethod) {
StringBuilder builder = new StringBuilder("- ").append(targetMethod.methodName());
if (targetMethod.beginLine() != null || targetMethod.endLine() != null) {
builder.append(" [lines ").append(targetMethod.beginLine() == null ? "?" : targetMethod.beginLine())
.append('-').append(targetMethod.endLine() == null ? "?" : targetMethod.endLine()).append(']');
}
return builder.toString();
}
}

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,397 @@
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.any;
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(), any()))
.thenReturn(sampleOneSuggestion());
when(mock.suggestForClass(eq("com.acme.other.AnotherTest"), anyString(), any()))
.thenReturn(anotherSuggestion());
when(mock.suggestForClass(eq("com.acme.security.AccessControlServiceTest"), anyString(), any()))
.thenReturn(accessControlSuggestion());
when(mock.suggestForClass(eq("com.acme.storage.PathTraversalValidationTest"), anyString(), any()))
.thenReturn(pathTraversalSuggestion());
when(mock.suggestForClass(eq("com.acme.audit.AuditLoggingTest"), anyString(), any()))
.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(), any()))
.thenReturn(sampleOneSuggestion());
when(mock.suggestForClass(eq("com.acme.other.AnotherTest"), anyString(), any()))
.thenReturn(anotherSuggestion());
when(mock.suggestForClass(eq("com.acme.security.AccessControlServiceTest"), anyString(), any()))
.thenThrow(new AiSuggestionException("Simulated provider failure"));
when(mock.suggestForClass(eq("com.acme.storage.PathTraversalValidationTest"), anyString(), any()))
.thenReturn(pathTraversalSuggestion());
when(mock.suggestForClass(eq("com.acme.audit.AuditLoggingTest"), anyString(), any()))
.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(), any());
}
}
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) {

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,186 @@
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.")));
List<PromptBuilder.TargetMethod> targetMethods = List
.of(new PromptBuilder.TargetMethod("shouldAllowOwnerToReadOwnStatement", null, null),
new PromptBuilder.TargetMethod("shouldAllowAdministratorToReadAnyStatement", null, null),
new PromptBuilder.TargetMethod("shouldDenyForeignUserFromReadingAnotherUsersStatement", null,
null),
new PromptBuilder.TargetMethod("shouldRejectUnauthenticatedRequest", null, null),
new PromptBuilder.TargetMethod("shouldRenderFriendlyAccountLabel", null, null));
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()), eq(targetMethods)))
.thenReturn(expected);
AiSuggestionEngineImpl engine = new AiSuggestionEngineImpl(options);
AiClassSuggestion actual = engine.suggestForClass("com.acme.security.AccessControlServiceTest",
"class AccessControlServiceTest {}", targetMethods);
assertSame(expected, actual);
factory.verify(() -> AiProviderFactory.create(options));
verify(client).suggestForClass("com.acme.security.AccessControlServiceTest",
"class AccessControlServiceTest {}", DefaultSecurityTaxonomy.text(), targetMethods);
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.")));
List<PromptBuilder.TargetMethod> targetMethods = List.of(
new PromptBuilder.TargetMethod("shouldRejectRelativePathTraversalSequence", null, null),
new PromptBuilder.TargetMethod("shouldRejectNestedTraversalAfterNormalization", null, null),
new PromptBuilder.TargetMethod("shouldAllowSafePathInsideUploadRoot", null, null),
new PromptBuilder.TargetMethod("shouldBuildDownloadFileName", null, null));
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()),
eq(targetMethods))).thenReturn(expected);
AiSuggestionEngineImpl engine = new AiSuggestionEngineImpl(options);
AiClassSuggestion actual = engine.suggestForClass("com.acme.storage.PathTraversalValidationTest",
"class PathTraversalValidationTest {}", targetMethods);
assertSame(expected, actual);
factory.verify(() -> AiProviderFactory.create(options));
verify(client).suggestForClass("com.acme.storage.PathTraversalValidationTest",
"class PathTraversalValidationTest {}", OptimizedSecurityTaxonomy.text(), targetMethods);
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.")));
List<PromptBuilder.TargetMethod> targetMethods = List.of(
new PromptBuilder.TargetMethod("shouldWriteAuditEventForPrivilegeChange", null, null),
new PromptBuilder.TargetMethod("shouldNotLogRawBearerToken", null, null),
new PromptBuilder.TargetMethod("shouldNotLogPlaintextPasswordOnAuthenticationFailure", null, null),
new PromptBuilder.TargetMethod("shouldFormatHumanReadableSupportMessage", null, null));
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), eq(targetMethods))).thenReturn(expected);
AiSuggestionEngineImpl engine = new AiSuggestionEngineImpl(options);
AiClassSuggestion actual = engine.suggestForClass("com.acme.audit.AuditLoggingTest",
"class AuditLoggingTest {}", targetMethods);
assertSame(expected, actual);
factory.verify(() -> AiProviderFactory.create(options));
verify(client).suggestForClass("com.acme.audit.AuditLoggingTest", "class AuditLoggingTest {}", taxonomyText,
targetMethods);
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,203 @@
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";
List<PromptBuilder.TargetMethod> targetMethods = List.of(
new PromptBuilder.TargetMethod("shouldRejectRelativePathTraversalSequence", null, null),
new PromptBuilder.TargetMethod("shouldRejectNestedTraversalAfterNormalization", null, null),
new PromptBuilder.TargetMethod("shouldAllowSafePathInsideUploadRoot", null, null),
new PromptBuilder.TargetMethod("shouldBuildDownloadFileName", null, null));
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, targetMethods);
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("FQCN: " + fqcn));
assertTrue(requestBody.contains("PathTraversalValidationTest"));
assertTrue(requestBody.contains("shouldRejectRelativePathTraversalSequence"));
assertTrue(requestBody.contains("shouldRejectNestedTraversalAfterNormalization"));
assertTrue(requestBody.contains("shouldAllowSafePathInsideUploadRoot"));
assertTrue(requestBody.contains("shouldBuildDownloadFileName"));
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";
List<PromptBuilder.TargetMethod> targetMethods = List.of(
new PromptBuilder.TargetMethod("shouldWriteAuditEventForPrivilegeChange", null, null),
new PromptBuilder.TargetMethod("shouldNotLogRawBearerToken", null, null),
new PromptBuilder.TargetMethod("shouldNotLogPlaintextPasswordOnAuthenticationFailure", null, null),
new PromptBuilder.TargetMethod("shouldFormatHumanReadableSupportMessage", null, null));
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",
targetMethods));
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,256 @@
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";
List<PromptBuilder.TargetMethod> targetMethods = List
.of(new PromptBuilder.TargetMethod("shouldAllowOwnerToReadOwnStatement", null, null),
new PromptBuilder.TargetMethod("shouldAllowAdministratorToReadAnyStatement", null, null),
new PromptBuilder.TargetMethod("shouldDenyForeignUserFromReadingAnotherUsersStatement", null,
null),
new PromptBuilder.TargetMethod("shouldRejectUnauthenticatedRequest", null, null),
new PromptBuilder.TargetMethod("shouldRenderFriendlyAccountLabel", null, null));
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, targetMethods);
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("FQCN: " + fqcn));
assertTrue(requestBody.contains("AccessControlServiceTest"));
assertTrue(requestBody.contains("shouldAllowOwnerToReadOwnStatement"));
assertTrue(requestBody.contains("shouldAllowAdministratorToReadAnyStatement"));
assertTrue(requestBody.contains("shouldDenyForeignUserFromReadingAnotherUsersStatement"));
assertTrue(requestBody.contains("shouldRejectUnauthenticatedRequest"));
assertTrue(requestBody.contains("shouldRenderFriendlyAccountLabel"));
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\\":[]}"
}
}
]
}
""";
List<PromptBuilder.TargetMethod> targetMethods = List.of(
new PromptBuilder.TargetMethod("shouldWriteAuditEventForPrivilegeChange", null, null),
new PromptBuilder.TargetMethod("shouldNotLogRawBearerToken", null, null),
new PromptBuilder.TargetMethod("shouldNotLogPlaintextPasswordOnAuthenticationFailure", null, null),
new PromptBuilder.TargetMethod("shouldFormatHumanReadableSupportMessage", null, null));
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", targetMethods);
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": []
}
""";
List<PromptBuilder.TargetMethod> targetMethods = List.of(
new PromptBuilder.TargetMethod("shouldWriteAuditEventForPrivilegeChange", null, null),
new PromptBuilder.TargetMethod("shouldNotLogRawBearerToken", null, null),
new PromptBuilder.TargetMethod("shouldNotLogPlaintextPasswordOnAuthenticationFailure", null, null),
new PromptBuilder.TargetMethod("shouldFormatHumanReadableSupportMessage", null, null));
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",
targetMethods));
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."
}
}
]
}
""";
List<PromptBuilder.TargetMethod> targetMethods = List.of(
new PromptBuilder.TargetMethod("shouldWriteAuditEventForPrivilegeChange", null, null),
new PromptBuilder.TargetMethod("shouldNotLogRawBearerToken", null, null),
new PromptBuilder.TargetMethod("shouldNotLogPlaintextPasswordOnAuthenticationFailure", null, null),
new PromptBuilder.TargetMethod("shouldFormatHumanReadableSupportMessage", null, null));
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",
targetMethods));
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,163 @@
package org.egothor.methodatlas.ai;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
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
""";
List<PromptBuilder.TargetMethod> targetMethods = List.of(
new PromptBuilder.TargetMethod("shouldRejectUnauthenticatedRequest", 8, 8),
new PromptBuilder.TargetMethod("shouldAllowOwnerToReadOwnStatement", 11, 11));
String prompt = PromptBuilder.build(fqcn, classSource, taxonomyText, targetMethods);
assertTrue(prompt.contains("FQCN: " + fqcn));
assertTrue(prompt.contains(classSource));
assertTrue(prompt.contains(taxonomyText));
assertTrue(prompt.contains("- shouldRejectUnauthenticatedRequest [lines 8-8]"));
assertTrue(prompt.contains("- shouldAllowOwnerToReadOwnStatement [lines 11-11]"));
}
@Test
void build_containsExpectedTaskInstructions() {
String prompt = PromptBuilder.build("com.acme.audit.AuditLoggingTest", "class AuditLoggingTest {}",
"security, logging",
List.of(new PromptBuilder.TargetMethod("shouldWriteAuditEventForPrivilegeChange", null, null)));
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("- Classify ONLY the methods explicitly listed in TARGET TEST METHODS."));
assertTrue(prompt.contains("- Do not invent methods that do not exist."));
assertTrue(prompt.contains(
"- Do not classify helper methods, lifecycle methods, nested classes, or any method not listed."));
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",
List.of(new PromptBuilder.TargetMethod("shouldRejectRelativePathTraversalSequence", null, null)));
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",
List.of(new PromptBuilder.TargetMethod("shouldRejectUnauthenticatedRequest", null, null)));
assertTrue(prompt.contains("If securityRelevant=false, displayName must be null."));
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",
List.of(new PromptBuilder.TargetMethod("shouldWriteAuditEventForPrivilegeChange", null, null)));
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",
List.of(new PromptBuilder.TargetMethod("shouldRejectRelativePathTraversalSequence", 3, 5)));
assertTrue(prompt.contains("String userInput = \"../etc/passwd\";"));
assertTrue(prompt.contains("void shouldRejectRelativePathTraversalSequence()"));
assertTrue(prompt.contains("- shouldRejectRelativePathTraversalSequence [lines 3-5]"));
}
@Test
void build_includesExpectedMethodNamesConstraint() {
String prompt = PromptBuilder.build("com.acme.tests.SampleOneTest", "class SampleOneTest {}",
"security, crypto", List.of(new PromptBuilder.TargetMethod("alpha", 1, 1),
new PromptBuilder.TargetMethod("beta", 2, 2), new PromptBuilder.TargetMethod("gamma", 3, 3)));
assertTrue(prompt.contains("- methodName values in the output must exactly match one of:"));
assertTrue(prompt.contains("[\"alpha\", \"beta\", \"gamma\"]"));
assertTrue(prompt.contains("- Do not omit any listed method."));
assertTrue(prompt.contains("- Do not include any additional methods."));
}
@Test
void build_isDeterministicForSameInput() {
String fqcn = "com.example.X";
String source = "class X {}";
String taxonomy = "security, logging";
List<PromptBuilder.TargetMethod> targetMethods = List.of(new PromptBuilder.TargetMethod("alpha", null, null));
String prompt1 = PromptBuilder.build(fqcn, source, taxonomy, targetMethods);
String prompt2 = PromptBuilder.build(fqcn, source, taxonomy, targetMethods);
assertEquals(prompt1, prompt2);
}
@Test
void build_rejectsEmptyTargetMethods() {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> PromptBuilder.build("com.example.X", "class X {}", "security", List.of()));
assertEquals("targetMethods must not be empty", ex.getMessage());
}
}

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);
}
}