plugins { id 'java' id 'eclipse' id 'application' id 'maven-publish' id 'signing' id 'pmd' id 'jacoco' id 'info.solidsoft.pitest' version '1.19.0' id 'me.champeau.jmh' version '0.7.2' id 'org.owasp.dependencycheck' version '12.2.1' id 'org.cyclonedx.bom' version '3.2.4' id 'com.palantir.git-version' version '4.0.0' } group = 'org.egothor' version = gitVersion(prefix:'release@') def benchmarkReportsDirectory = layout.buildDirectory.dir('reports/jmh') def sbomReportsDirectory = layout.buildDirectory.dir('reports/sbom') def jmhIncludesProperty = providers.gradleProperty('jmh.includes') .orElse(providers.systemProperty('jmh.includes')) def nvdApiKey = providers.gradleProperty('nvdApiKey') .orElse(providers.environmentVariable('NVD_API_KEY')) .orNull def dependencyCheckSuppressionFile = rootProject.file('dependency-suppression.xml') apply from: 'gradle/maven-pom.gradle' configurations { mockitoAgent stemmingQualityJmhRuntime { canBeConsumed = false canBeResolved = true extendsFrom(jmhImplementation, jmhRuntimeOnly) } } java { withSourcesJar() withJavadocJar() sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } tasks.withType(AbstractArchiveTask).configureEach { preserveFileTimestamps = false reproducibleFileOrder = true } jacoco { toolVersion = '0.8.14' } pmd { consoleOutput = true toolVersion = '7.20.0' sourceSets = [sourceSets.main] ruleSetFiles = files(rootProject.file(".ruleset")) } dependencyLocking { lockAllConfigurations() lockMode = LockMode.STRICT } dependencies { jmhImplementation sourceSets.main.output testImplementation platform(libs.junit.bom) testImplementation libs.junit.jupiter testRuntimeOnly libs.junit.platform.launcher testImplementation libs.mockito.core testImplementation libs.mockito.junit.jupiter testImplementation libs.jqwik mockitoAgent(libs.mockito.core) { transitive = false } } sourceSets.jmh.compileClasspath = sourceSets.jmh.compileClasspath - sourceSets.test.output sourceSets.jmh.runtimeClasspath = sourceSets.jmh.runtimeClasspath - sourceSets.test.output sourceSets.test.compileClasspath += sourceSets.jmh.output + configurations.jmhCompileClasspath sourceSets.test.runtimeClasspath += sourceSets.jmh.output + configurations.jmhCompileClasspath tasks.named('compileJmhJava', JavaCompile) { classpath = classpath - sourceSets.test.output setDependsOn([tasks.named('classes')]) } dependencyCheck { failBuildOnCVSS = 7.0 failOnError = true autoUpdate = true formats = ['HTML', 'JSON'] outputDirectory = layout.buildDirectory.dir('reports/dependency-check').get().asFile.absolutePath /* * Keep the scan focused on actual Java dependency inputs used by this project. * testRuntimeClasspath is included intentionally because the current external * dependency surface is primarily test-scoped. */ scanConfigurations = ['runtimeClasspath', 'testRuntimeClasspath', 'mockitoAgent'] skipTestGroups = false analyzers { experimentalEnabled = false centralEnabled = true } nvd { apiKey = nvdApiKey delay = nvdApiKey != null ? 3500 : 8000 validForHours = 4 } if (dependencyCheckSuppressionFile.exists()) { suppressionFile = dependencyCheckSuppressionFile.absolutePath failBuildOnUnusedSuppressionRule = true } } def cliIncludeTags = project.findProperty('includeTags')?.toString() ?: System.getProperty('includeTags') def cliExcludeTags = project.findProperty('excludeTags')?.toString() ?: System.getProperty('excludeTags') def splitTagExpression = { String tagsExpr -> if (tagsExpr == null || tagsExpr.isBlank()) { return [] } return tagsExpr.split(',') .collect { it.trim() } .findAll { it != null && !it.isBlank() } } tasks.withType(Test).configureEach { doFirst { jvmArgs "-javaagent:${configurations.mockitoAgent.singleFile}" } /* * Bundled dictionary integration tests compile and reload large real-world * stemming dictionaries, including large language resources such as es_es. * The default Gradle test executor heap is too small for this workload. */ minHeapSize = '1g' maxHeapSize = '4g' reports { junitXml.required = true html.required = true } } def configureJUnitPlatformTags = { Test task, String includeTagsExpr, String excludeTagsExpr -> task.useJUnitPlatform { final def includes = splitTagExpression(includeTagsExpr) final def excludes = splitTagExpression(excludeTagsExpr) if (!includes.isEmpty()) { includeTags(*includes.toArray(new String[0])) } if (!excludes.isEmpty()) { excludeTags(*excludes.toArray(new String[0])) } } } tasks.named('test', Test) { final def requestedIncludes = splitTagExpression(cliIncludeTags) final boolean slowExplicitlyIncluded = requestedIncludes.contains('slow') final String defaultExcludeTags = cliExcludeTags ?: (slowExplicitlyIncluded ? null : 'slow') configureJUnitPlatformTags(it, cliIncludeTags, defaultExcludeTags) finalizedBy(tasks.named('jacocoTestReport')) } def configureTaggedTestProfile = { String taskName, String includeTagsExpr, String excludeTagsExpr = null, String taskDescription = null, String testNameExcludePatterns = null -> tasks.register(taskName, Test) { group = 'verification' description = taskDescription configureJUnitPlatformTags(delegate as Test, includeTagsExpr, excludeTagsExpr) testClassesDirs = sourceSets.test.output.classesDirs classpath = sourceSets.test.runtimeClasspath dependsOn(tasks.named('compileTestJava')) doFirst { jvmArgs "-javaagent:${configurations.mockitoAgent.singleFile}" } if (testNameExcludePatterns != null && !testNameExcludePatterns.isBlank()) { filter { testNameExcludePatterns.split(',').each { String pattern -> final def trimmedPattern = pattern.trim() if (!trimmedPattern.isEmpty()) { excludeTestsMatching(trimmedPattern) } } } } minHeapSize = '1g' maxHeapSize = '4g' reports { junitXml.required = true html.required = true } } } configureTaggedTestProfile( 'ciSmoke', 'unit', 'slow', 'Fast feedback profile for unit tests with slow tests explicitly excluded.', 'org.egothor.stemmer.CompileIntegrationTest*' ) configureTaggedTestProfile( 'ciCore', 'unit,trie,frequency-trie,property', null, 'Focused profile for core trie behavior and trie-specific property checks.' ) configureTaggedTestProfile( 'ciIntegration', 'integration', 'slow', 'Integration pipeline profile (loader/parser/CLI/IO end-to-end flows) excluding slow integration paths.' ) configureTaggedTestProfile( 'ciSlow', 'slow', null, 'Targeted profile for all slow tests (large dictionaries, long-running corpus validation, and heavy integration checks).' ) configureTaggedTestProfile( 'ciCompat', 'compat,regression', null, 'Compatibility profile guarding persisted artifact and compatibility regressions.' ) configureTaggedTestProfile( 'ciRelease', null, 'slow', 'Release-profile validation of all non-slow tests.', 'org.egothor.stemmer.CompileIntegrationTest*,org.egothor.stemmer.StemmerPatchTrieLoaderTest$BundledDictionaryTests*' ) configureTaggedTestProfile( 'ciNightly', 'fuzz', null, 'Nightly robustness profile with fuzz testing emphasis.' ) tasks.register('ci') { group = 'verification' description = 'Runs the full enterprise CI profile set in sequence.' dependsOn(tasks.named('ciSmoke')) dependsOn(tasks.named('ciCore')) dependsOn(tasks.named('ciIntegration')) dependsOn(tasks.named('ciCompat')) } tasks.withType(Pmd).configureEach { reports { xml.required = true html.required = true } } tasks.named('jacocoTestReport', JacocoReport) { dependsOn(tasks.named('test')) classDirectories.setFrom( files(sourceSets.main.output).asFileTree.matching { exclude 'org/egothor/stemmer/StemmerKnowledgeExperiment*' exclude 'org/egothor/stemmer/DiacriticStripper*' } ) reports { xml.required = true csv.required = false html.required = true } } def registerJacocoProfileReport = { String reportTaskName, String sourceTaskName -> tasks.register(reportTaskName, JacocoReport) { group = 'verification' description = "Generates Jacoco report for ${sourceTaskName} execution." dependsOn(tasks.named(sourceTaskName)) classDirectories.setFrom( files(sourceSets.main.output).asFileTree.matching { exclude 'org/egothor/stemmer/StemmerKnowledgeExperiment*' exclude 'org/egothor/stemmer/DiacriticStripper*' } ) executionData.setFrom( fileTree(layout.buildDirectory.dir('jacoco')) { include "${sourceTaskName}.exec" } ) reports { xml.required = true csv.required = false html.required = true } } } registerJacocoProfileReport('jacocoCiReleaseReport', 'ciRelease') tasks.named('check') { dependsOn(tasks.named('jacocoTestReport')) // no-default, only on-demand: dependsOn(tasks.named('dependencyCheckAnalyze')) } allprojects { tasks.matching { it.name == 'cyclonedxDirectBom' }.configureEach { includeConfigs = ['runtimeClasspath', 'compileClasspath'] skipConfigs = ['testRuntimeClasspath', 'testCompileClasspath', 'jmh.*', 'mockitoAgent'] includeBomSerialNumber = true includeLicenseText = false includeMetadataResolution = true includeBuildSystem = true } } tasks.named('cyclonedxBom') { includeBomSerialNumber = true includeLicenseText = false includeBuildSystem = true jsonOutput.set(sbomReportsDirectory.map { it.file('radixor-sbom.json') }) xmlOutput.set(sbomReportsDirectory.map { it.file('radixor-sbom.xml') }) } pitest { pitestVersion = '1.22.1' junit5PluginVersion = '1.2.3' targetClasses = [ 'org.egothor.stemmer.*', 'org.egothor.stemmer.trie.*' ] targetTests = [ 'org.egothor.stemmer.*Test', 'org.egothor.stemmer.trie.*Test' ] excludedClasses = [ 'org.egothor.stemmer.Compile*', 'org.egothor.stemmer.StemmerPatchTrieLoader*', 'org.egothor.stemmer.StemmerKnowledgeExperiment*', 'org.egothor.stemmer.StemmerKnowledgeExperimentCli*' ] excludedTestClasses = [ 'org.egothor.stemmer.CompileIntegrationTest', 'org.egothor.stemmer.StemmerPatchTrieLoaderTest', 'org.egothor.stemmer.StemmerKnowledgeExperimentTest' ] outputFormats = ['XML', 'HTML'] timestampedReports = false exportLineCoverage = true failWhenNoMutations = true threads = Math.max(1, Runtime.runtime.availableProcessors().intdiv(2)) } application { mainClass = 'org.egothor.stemmer.Compile' applicationName = 'radixor' executableDir = 'bin' } tasks.register('stemmerKnowledgeExperiment', JavaExec) { group = 'application' description = 'Runs the stemmer knowledge evaluation experiment.' classpath = sourceSets.main.runtimeClasspath mainClass = 'org.egothor.stemmer.StemmerKnowledgeExperimentCli' } distributions { main { distributionBaseName = 'radixor' contents { from('README.md') { into '' } from('LICENSE') { into '' } from('LICENSE-stemmer-data') { into '' } from('docs') { into 'docs' include '**/*.md' } from(layout.buildDirectory.dir('generated/release-notes')) { into '' include 'CHANGELOG.md' } } } } tasks.named('startScripts') { applicationName = 'radixor' } tasks.named('distZip', Zip) { archiveBaseName = 'radixor' archiveClassifier = 'bin' } tasks.named('distTar') { enabled = false } jmh { jmhVersion = '1.37' includeTests = false warmupIterations = 3 iterations = 5 fork = 1 benchmarkMode = ['avgt'] timeUnit = 'ns' resultFormat = 'CSV' resultsFile = benchmarkReportsDirectory.map { it.file('jmh-results.csv').asFile }.get() humanOutputFile = benchmarkReportsDirectory.map { it.file('jmh-results.txt').asFile }.get() duplicateClassesStrategy = DuplicatesStrategy.EXCLUDE if (jmhIncludesProperty.isPresent()) { includes = [jmhIncludesProperty.get()] } } tasks.named('jmh') { group = 'verification' description = 'Runs JMH benchmarks for the Radixor algorithmic core and external stemmer comparison suites.' } apply from: 'gradle/lucene-benchmarks.gradle' tasks.register('regressionArtifactGenerator', JavaExec) { group = 'verification' description = 'Generates deterministic compiled trie regression artifacts.' classpath = sourceSets.test.runtimeClasspath mainClass = 'org.egothor.stemmer.RegressionArtifactGenerator' if (project.hasProperty('regressionInput')) { args '--input', project.property('regressionInput').toString() } if (project.hasProperty('regressionOutput')) { args '--output', project.property('regressionOutput').toString() } if (project.hasProperty('regressionStoreOriginal')) { args '--store-original', project.property('regressionStoreOriginal').toString() } if (project.hasProperty('regressionReductionMode')) { args '--reduction-mode', project.property('regressionReductionMode').toString() } } tasks.register('stemmingQuality', JavaExec) { group = 'verification' description = 'Evaluates pairwise over-stemming and under-stemming against bundled dictionary groups.' dependsOn(tasks.named('testClasses')) dependsOn(tasks.named('jmhClasses')) classpath = files(sourceSets.test.runtimeClasspath, configurations.stemmingQualityJmhRuntime) mainClass = 'org.egothor.stemmer.benchmark.quality.StemmingQualityApplication' args layout.buildDirectory.dir('reports/stemming-quality').get().asFile.absolutePath, layout.projectDirectory.dir('src/main/resources').asFile.absolutePath, providers.gradleProperty('stemmingQualityLanguage').getOrElse(''), providers.gradleProperty('stemmingQualityStemmer').getOrElse(''), providers.gradleProperty('stemmingQualityMode').getOrElse(''), providers.gradleProperty('stemmingQualityOutputPolicy').getOrElse(''), providers.gradleProperty('stemmingQualityRankMetric').getOrElse('PAIRWISE_F05'), providers.gradleProperty('stemmingQualityAudit').getOrElse('false'), providers.gradleProperty('stemmingQualityAuditLimit').getOrElse('25') maxHeapSize = '6g' } tasks.register('publishStemmingQualityDocumentation', JavaExec) { group = 'documentation' description = 'Publishes validated complete stemming-quality results on the language benchmark pages.' dependsOn(tasks.named('testClasses')) classpath = sourceSets.test.runtimeClasspath mainClass = 'org.egothor.stemmer.benchmark.quality.StemmingQualityDocumentationPublisher' args layout.buildDirectory.file('reports/stemming-quality/stemming-quality.csv').get().asFile.absolutePath, layout.projectDirectory.dir('docs').asFile.absolutePath, 'update' doFirst { if (!file("$buildDir/reports/stemming-quality/stemming-quality.csv").isFile()) { throw new GradleException('A complete stemming-quality CSV is required. Run stemmingQuality only when no validated complete report is available.') } } } tasks.register('verifyStemmingQualityDocumentation', JavaExec) { group = 'verification' description = 'Verifies published language-page quality tables against the checked-in authoritative CSV.' dependsOn(tasks.named('testClasses')) classpath = sourceSets.test.runtimeClasspath mainClass = 'org.egothor.stemmer.benchmark.quality.StemmingQualityDocumentationPublisher' args layout.projectDirectory.file('docs/benchmarks/data/stemming-quality.csv').asFile.absolutePath, layout.projectDirectory.dir('docs').asFile.absolutePath, 'verify' } tasks.named('check') { dependsOn(tasks.named('verifyStemmingQualityDocumentation')) } tasks.register('verifyStemmingQualitySourceSets') { group = 'verification' description = 'Verifies the production, JMH, and standard-test ownership of stemming-quality infrastructure.' doLast { if (sourceSets.findByName('stemmingQualityTest') != null || file('src/stemmingQualityTest').exists()) { throw new GradleException('The obsolete stemmingQualityTest source set or directory still exists.') } if (!file('src/jmh/java/org/egothor/stemmer/benchmark/QualityStemmerMatrix.java').isFile()) { throw new GradleException('The authoritative JMH stemmer matrix is not in src/jmh.') } if (!file('src/test/java/org/egothor/stemmer/benchmark/quality/StemmingQualityApplication.java').isFile()) { throw new GradleException('The stemming-quality evaluator is not in the standard test source set.') } } } tasks.register('verifyProductionJarExcludesStemmingQuality') { group = 'verification' description = 'Verifies that analytical stemming-quality classes are absent from the production JAR.' dependsOn(tasks.named('jar')) doLast { final File archive = tasks.named('jar').get().archiveFile.get().asFile final def forbidden = zipTree(archive).matching { include '**/benchmark/**' }.files if (!forbidden.isEmpty()) { throw new GradleException("Production JAR contains analytical stemming-quality classes: ${forbidden}") } } } tasks.register('printDependencyCheckNvdConfig') { doLast { System.out.println("NVD API key present: " + (nvdApiKey != null && !nvdApiKey.isBlank())) } } tasks.named('dependencyCheckAnalyze') { dependsOn(tasks.named('printDependencyCheckNvdConfig')) } 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 = 'Radixor - Egothor Stemmer' options.docTitle = 'Radixor - Egothor Stemmer API' options.overview = file('src/main/javadoc/overview.html') options.bottom = """