plugins { id 'org.egothor.radixor.build-support' 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.3' id 'org.owasp.dependencycheck' version '12.2.1' id 'org.cyclonedx.bom' version '3.3.0' 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(JavaCompile).configureEach { options.compilerArgs.addAll(['-Xlint:deprecation', '-Xlint:unchecked']) } 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 modelProjects().each { Project modelProject -> testRuntimeOnly project(modelProject.path) jmhRuntimeOnly project(modelProject.path) } 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 testImplementation gradleTestKit() mockitoAgent(libs.mockito.core) { transitive = false } } def modelProjects() { Properties topology = new Properties() rootProject.file('models/model-projects.properties').withInputStream { InputStream input -> topology.load(input) } return topology.stringPropertyNames().toList().sort().collect { String modelId -> project(":models:${modelId}") } } def defaultModelProjects() { Properties topology = new Properties() rootProject.file('models/model-projects.properties').withInputStream { InputStream input -> topology.load(input) } return topology.stringPropertyNames().findAll { String modelId -> topology.getProperty(modelId) == 'default' }.sort().collect { String modelId -> project(":models:${modelId}") } } tasks.named('projects') { actions.clear() doLast { logger.lifecycle('Root project \'{}\'', rootProject.name) rootProject.allprojects.findAll { Project candidate -> candidate != rootProject } .sort { Project left, Project right -> left.path <=> right.path } .each { Project candidate -> logger.lifecycle('+--- Project \'{}\'', candidate.path) } gradle.includedBuilds.toList().sort { left, right -> left.name <=> right.name } .each { includedBuild -> logger.lifecycle('Included build \'{}\'', includedBuild.name) } } } 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 { final def mockitoAgentArguments = objects.newInstance( org.egothor.radixor.MockitoAgentArgumentProvider) mockitoAgentArguments.agentClasspath.from(configurations.mockitoAgent) jvmArgumentProviders.add(mockitoAgentArguments) /* * 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 } } tasks.named('test', Test) { dependsOn('prepareModelConsumerTestRepository') systemProperty('radixor.consumer.repository', layout.buildDirectory.dir('model-consumer-repository').get().asFile.absolutePath) systemProperty('radixor.core.version', version.toString()) systemProperty('radixor.catalog.version', project(':models:standard').version.toString()) } tasks.register('modelDependencyResolutionTest', Test) { group = 'verification' description = 'Verifies that published model coordinates resolve from the generated consumer repository.' dependsOn(tasks.named('prepareModelConsumerTestRepository')) testClassesDirs = sourceSets.test.output.classesDirs classpath = sourceSets.test.runtimeClasspath useJUnitPlatform() filter { includeTestsMatching('org.egothor.stemmer.ModelDependencyResolutionTest') } systemProperty('radixor.consumer.repository', layout.buildDirectory.dir('model-consumer-repository').get().asFile.absolutePath) systemProperty('radixor.core.version', version.toString()) systemProperty('radixor.catalog.version', project(':models:standard').version.toString()) } 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 ? 'large-model' : 'slow,large-model') configureJUnitPlatformTags(it, cliIncludeTags, defaultExcludeTags) finalizedBy(tasks.named('jacocoTestReport')) } def largeModelMaxHeap = providers.gradleProperty('radixorLargeModelMaxHeap').orElse('6g') def runtimeModelId = providers.gradleProperty('modelId').orElse('pl-pl-polimorf') def runtimeModelClasspath = configurations.testRuntimeClasspath.incoming.artifactView { componentFilter { componentIdentifier -> if (!(componentIdentifier instanceof org.gradle.api.artifacts.component.ProjectComponentIdentifier)) { return true } final String projectPath = componentIdentifier.projectPath return !projectPath.startsWith(':models:') || projectPath == ":models:${runtimeModelId.get()}" } }.files tasks.register('runtimeModelIntegrationTest', Test) { group = 'verification' description = 'Constructs one complete selected runtime model in an isolated, memory-sized JVM.' testClassesDirs = sourceSets.test.output.classesDirs classpath = sourceSets.test.output + sourceSets.main.output + sourceSets.jmh.output + runtimeModelClasspath dependsOn(tasks.named('compileTestJava')) useJUnitPlatform { includeTags('large-model') } systemProperty('radixor.test.modelId', runtimeModelId.get()) minHeapSize = '1g' maxHeapSize = largeModelMaxHeap.get() maxParallelForks = 1 forkEvery = 1 reports { junitXml.required = true html.required = true } doFirst { logger.lifecycle("Runtime model integration uses model '{}' with maximum heap {}.", systemProperties.get('radixor.test.modelId'), maxHeapSize) } } 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')) 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,large-model', 'Release-profile validation of all non-slow tests.', 'org.egothor.stemmer.CompileIntegrationTest*,org.egothor.stemmer.StemmerPatchTrieLoaderTest$BundledDictionaryTests*' ) tasks.named('ciRelease', Test) { dependsOn('prepareModelConsumerTestRepository') systemProperty('radixor.consumer.repository', layout.buildDirectory.dir('model-consumer-repository').get().asFile.absolutePath) systemProperty('radixor.core.version', version.toString()) systemProperty('radixor.catalog.version', project(':models:standard').version.toString()) } 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')) } tasks.register('verifyCoreJarExcludesModels') { group = 'verification' description = 'Verifies that the root Radixor JAR contains no language dictionary bytes.' dependsOn(tasks.named('jar')) doLast { File archive = tasks.named('jar', Jar).get().archiveFile.get().asFile List dictionaries = [] new java.util.zip.ZipFile(archive).withCloseable { zip -> zip.entries().each { entry -> if (entry.name.endsWith('/stemmer.gz')) dictionaries.add(entry.name) } } if (!dictionaries.isEmpty()) { throw new GradleException('The org.egothor:radixor JAR must not contain model data: ' + dictionaries) } } } tasks.register('verifyJavaLicenseHeaders') { group = 'verification' description = 'Verifies deterministic license classification for every maintained Java source file.' inputs.file(layout.projectDirectory.file('gradle/java-license-header.txt')) inputs.files(fileTree('src/main/java') { include '**/*.java' }) inputs.files(fileTree('src/test/java') { include '**/*.java' }) inputs.files(fileTree('src/jmh/java') { include '**/*.java' }) outputs.file(layout.buildDirectory.file('reports/license/java-license-headers.txt')) doLast { String canonicalHeader = layout.projectDirectory.file('gradle/java-license-header.txt') .asFile.getText('UTF-8') File canonicalSource = file('src/main/java/org/egothor/stemmer/CaseProcessingMode.java') if (!canonicalSource.getText('UTF-8').startsWith(canonicalHeader)) { throw new GradleException('CaseProcessingMode.java does not begin with the canonical Radixor license template.') } List maintainedSources = files( fileTree('src/main/java') { include '**/*.java' }, fileTree('src/test/java') { include '**/*.java' }, fileTree('src/jmh/java') { include '**/*.java' }) .files.toList().sort { File left, File right -> relativePath(left) <=> relativePath(right) } List classifications = [] List failures = [] maintainedSources.each { File sourceFile -> String relative = relativePath(sourceFile) String content = sourceFile.getText('UTF-8') if (content.startsWith(canonicalHeader)) { classifications.add("RADIXOR_CANONICAL_HEADER ${relative}") return } String leadingNotice = '' if (content.startsWith('/*')) { int closingIndex = content.indexOf('*/') if (closingIndex >= 0) { leadingNotice = content.substring(0, closingIndex + 2) } } else if (content.startsWith('//')) { leadingNotice = content.readLines().takeWhile { String line -> line.startsWith('//') || line.isBlank() }.join('\n') } String remainder = content.substring(leadingNotice.length()).stripLeading() boolean duplicateNotice = !leadingNotice.isEmpty() && (remainder.startsWith('/*') || remainder.startsWith('//')) boolean historicalRadixor = leadingNotice =~ /(?s)Copyright \(C\) \d{4}(?:-\d{4})?, Leo Galambos/ && leadingNotice.contains('All rights reserved.') && leadingNotice.contains('Redistribution and use in source and binary forms') && leadingNotice.contains('THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS') boolean thirdPartyOrProvenance = leadingNotice =~ /(?is)(SPDX-License-Identifier|Licensed under|MIT License|Apache License|Permission is hereby granted|Original source|Adapted from|Ported from|Source:\s*\S)/ if (duplicateNotice) { classifications.add("AMBIGUOUS_AUTHORSHIP ${relative}") failures.add("${relative}: duplicate leading comment blocks") } else if (historicalRadixor) { classifications.add("RADIXOR_HISTORICAL_HEADER ${relative}") } else if (thirdPartyOrProvenance) { classifications.add("THIRD_PARTY_OR_PROVENANCE_HEADER ${relative}") } else { classifications.add("AMBIGUOUS_AUTHORSHIP ${relative}") failures.add("${relative}: no recognized governing license or provenance header") } } File report = layout.buildDirectory.file('reports/license/java-license-headers.txt').get().asFile report.parentFile.mkdirs() report.setText(classifications.join('\n') + '\n', 'UTF-8') if (!failures.isEmpty()) { throw new GradleException('Maintained Java license verification failed: ' + failures.sort().join(', ')) } } } tasks.register('verifyAllDefaultModels') { group = 'verification' description = 'Verifies that every language default model project is configured.' dependsOn(defaultModelProjects().collect { Project modelProject -> modelProject.path + ':verifyModelDescriptor' }) } tasks.register('verifyAllModels') { group = 'verification' description = 'Runs complete validation and artifact verification for every independently versioned model module.' dependsOn(modelProjects().collect { Project modelProject -> modelProject.tasks.named('check') }) } tasks.register('verifyJmhModelClasspath') { group = 'verification' description = 'Verifies that JMH receives each individual model JAR exactly once and embeds no dictionary.' dependsOn(tasks.named('jmhJar')) dependsOn(modelProjects().collect { Project modelProject -> modelProject.tasks.named('jar') }) outputs.file(layout.buildDirectory.file('reports/models/jmh-model-classpath.txt')) doLast { List modelJars = configurations.jmhRuntimeClasspath.files.findAll { File dependency -> dependency.name.startsWith('radixor-model-') && dependency.name.endsWith('.jar') }.sort { File left, File right -> left.name <=> right.name } List expectedPrefixes = modelProjects().collect { Project modelProject -> "radixor-model-${modelProject.name}-" } expectedPrefixes.each { String prefix -> List matches = modelJars.findAll { File dependency -> dependency.name.startsWith(prefix) } if (matches.size() != 1) { throw new GradleException("JMH must resolve exactly one model JAR with prefix ${prefix}; resolved ${matches}.") } } if (modelJars.any { File dependency -> dependency.name.contains('benchmark-pack') }) { throw new GradleException('JMH must not resolve a benchmark-pack artifact.') } File executable = tasks.named('jmhJar', Jar).get().archiveFile.get().asFile if (!zipTree(executable).matching { include '**/stemmer.gz' }.isEmpty()) { throw new GradleException('The JMH executable JAR must not embed model dictionaries.') } File report = layout.buildDirectory.file('reports/models/jmh-model-classpath.txt').get().asFile report.parentFile.mkdirs() report.setText(modelJars.collect { File dependency -> dependency.name }.join('\n') + '\n', 'UTF-8') } } tasks.named('prepareModelConsumerTestRepository') { coreVersion = version.toString() catalogVersion = project(':models:standard').version.toString() modelVersions = modelProjects().collectEntries { Project modelProject -> final String modelVersion = providers.gradleProperty('modelReleaseVersion') .orElse(providers.fileContents(modelProject.layout.projectDirectory.file('model-version.txt')) .asText.map(String::trim)) .get() [(modelProject.name): modelVersion] } corePom = layout.file(tasks.named('generatePomFileForMavenJavaPublication').map { it.destination }) coreJar = tasks.named('jar', Jar).flatMap { it.archiveFile } modelPoms.from(modelProjects().collect { Project modelProject -> modelProject.tasks.named('generatePomFileForModelPublication').map { it.destination } }) modelJars.from(modelProjects().collect { Project modelProject -> modelProject.tasks.named('jar', Jar).flatMap { it.archiveFile } }) standardPom = layout.file(project(':models:standard').tasks.named('generatePomFileForStandardPublication') .map { it.destination }) bomPom = layout.file(project(':models:bom').tasks.named('generatePomFileForBomPublication') .map { it.destination }) repositoryDirectory = layout.buildDirectory.dir('model-consumer-repository') } def rawModelCatalogRepository = layout.buildDirectory.dir('model-catalog-staging-repository') def preparedModelCatalogBundleInput = layout.buildDirectory.dir('model-catalog-bundle-input') def cleanModelCatalogStaging = tasks.register('cleanModelCatalogStaging', Delete) { group = 'publishing' description = 'Cleans the isolated model catalog Maven staging repository.' delete(rawModelCatalogRepository) } gradle.projectsEvaluated { project(':models:standard').tasks.named('publishStandardPublicationToCatalogStagingRepository') { dependsOn(cleanModelCatalogStaging) } project(':models:bom').tasks.named('publishBomPublicationToCatalogStagingRepository') { dependsOn(cleanModelCatalogStaging) } } def prepareModelCatalogReleaseCandidate = tasks.named('prepareModelCatalogReleaseCandidate') { dependsOn(project(':models:standard').tasks.named('check')) dependsOn(project(':models:bom').tasks.named('check')) dependsOn(':models:standard:publishStandardPublicationToCatalogStagingRepository') dependsOn(':models:bom:publishBomPublicationToCatalogStagingRepository') rawRepositoryDirectory = rawModelCatalogRepository preparedBundleDirectory = preparedModelCatalogBundleInput catalogVersion = project(':models:standard').version.toString() } def modelCatalogCentralBundle = tasks.register('modelCatalogCentralBundle', Zip) { group = 'publishing' description = 'Builds the local POM-only model catalog bundle without remote publication.' dependsOn(prepareModelCatalogReleaseCandidate) from(preparedModelCatalogBundleInput) destinationDirectory = layout.buildDirectory.dir('model-catalog-release-candidate') archiveFileName = "radixor-models-catalog-${project(':models:standard').version}-central-bundle.zip" doFirst { File preparedInput = preparedModelCatalogBundleInput.get().asFile if (!preparedInput.isDirectory() || preparedInput.listFiles() == null || preparedInput.listFiles().length == 0) { throw new GradleException("The prepared model catalog bundle input is missing or empty: ${preparedInput}.") } if (providers.environmentVariable('GITHUB_REF_TYPE').orNull == 'tag' && (providers.environmentVariable('SIGNING_KEY').orNull?.isBlank() != false || providers.environmentVariable('SIGNING_PASSWORD').orNull?.isBlank() != false)) { throw new GradleException('A tagged model catalog release requires SIGNING_KEY and SIGNING_PASSWORD.') } } } def publishedModelVersions = modelProjects().collect { Project modelProject -> modelProject.file('model-version.txt').getText('UTF-8').trim() }.toSet() if (publishedModelVersions.size() != 1) { throw new GradleException("The catalog verifier requires one common model version; found ${publishedModelVersions}.") } tasks.named('verifyModelCatalogReleaseCandidate') { bundleFile = modelCatalogCentralBundle.flatMap { Zip archive -> archive.archiveFile } reportFile = layout.buildDirectory.file('reports/models/catalog-release-candidate.txt') catalogVersion = project(':models:standard').version.toString() modelVersion = publishedModelVersions.first() defaultModelIds = defaultModelProjects().collect { Project modelProject -> modelProject.name } allModelIds = modelProjects().collect { Project modelProject -> modelProject.name } } tasks.register('verifyArtifactSizes') { group = 'verification' description = 'Reports artifact sizes and rejects dictionary bytes in core.' dependsOn(tasks.named('verifyCoreJarExcludesModels')) doLast { File archive = tasks.named('jar', Jar).get().archiveFile.get().asFile println('org.egothor:radixor:' + version + ' ' + archive.length() + ' bytes') } } tasks.named('check') { dependsOn(tasks.named('verifyCoreJarExcludesModels')) dependsOn(tasks.named('verifyAllDefaultModels')) dependsOn(tasks.named('verifyJmhModelClasspath')) dependsOn(tasks.named('verifyJavaLicenseHeaders')) } def modelCatalogText = providers.provider { StringBuilder output = new StringBuilder() output.append('| Model ID | Language | Default | Coordinates | Version | Source | Repository | Source version | Revision | Revision status | License | Attribution | SHA-256 | Bytes |\n') output.append('|---|---|---:|---|---:|---|---|---|---|---|---|---|---|---:|\n') modelProjects().each { Project modelProject -> String script = modelProject.file('build.gradle').getText('UTF-8') def value = { String key -> def matcher = script =~ /(?m)^\s*${key}\s*=\s*(?:'([^']+)'|([^\s]+))\s*$/ if (!matcher.find()) return 'unavailable' return matcher.group(1) != null ? matcher.group(1) : matcher.group(2) } File input = modelProject.file('src/modelInput/stemmer.gz') String checksum = java.security.MessageDigest.getInstance('SHA-256').digest(input.bytes).encodeHex().toString() output.append('| ').append(modelProject.name) .append(' | ').append(value('language')) .append(' | ').append(value('defaultModel')) .append(' | org.egothor:radixor-model-').append(modelProject.name) .append(' | ').append(modelProject.file('model-version.txt').text.trim()) .append(' | ').append(value('sourceName')) .append(' | ').append(value('sourceRepository')) .append(' | ').append(value('sourceVersion')) .append(' | ').append(value('sourceRevision')) .append(' | ').append(value('sourceRevisionStatus')) .append(' | ').append(value('sourceLicense')) .append(' | ').append(value('sourceAttribution')) .append(' | ').append(checksum) .append(' | ').append(input.length()).append(' |\n') } return output.toString() } def modelCatalogDocumentationInputs = files(modelProjects().collectMany { Project modelProject -> [ modelProject.file('build.gradle'), modelProject.file('model-version.txt'), modelProject.file('src/modelInput/stemmer.gz') ] }) tasks.register('generateModelCatalogDocumentation') { group = 'documentation' description = 'Generates the deterministic model catalog in the build documentation staging tree.' inputs.files(modelCatalogDocumentationInputs) outputs.file(layout.buildDirectory.file('mkdocs-source/stemmer-model-catalog.md')) doLast { File catalog = layout.buildDirectory.file('mkdocs-source/stemmer-model-catalog.md').get().asFile catalog.parentFile.mkdirs() catalog.setText('# Published Stemmer Model Catalog\n\n' + modelCatalogText.get(), 'UTF-8') } } tasks.register('publishModelCatalogDocumentation') { group = 'documentation' description = 'Updates the checked-in model catalog used by a direct local MkDocs invocation.' dependsOn(tasks.named('generateModelCatalogDocumentation')) inputs.file(layout.buildDirectory.file('mkdocs-source/stemmer-model-catalog.md')) outputs.file(layout.projectDirectory.file('docs/stemmer-model-catalog.md')) doLast { File generated = layout.buildDirectory.file('mkdocs-source/stemmer-model-catalog.md').get().asFile File published = layout.projectDirectory.file('docs/stemmer-model-catalog.md').asFile published.setText(generated.getText('UTF-8'), 'UTF-8') } } tasks.register('prepareMkDocsSource', Sync) { group = 'documentation' description = 'Stages maintained documentation, generated catalog, and MkDocs configuration under build/.' dependsOn(modelProjects().collect { Project modelProject -> modelProject.path + ':verifyModelDescriptor' }) inputs.files(modelCatalogDocumentationInputs) into(layout.buildDirectory.dir('mkdocs-source')) from(layout.projectDirectory.dir('docs')) doLast { File catalog = layout.buildDirectory.file('mkdocs-source/stemmer-model-catalog.md').get().asFile catalog.setText('# Published Stemmer Model Catalog\n\n' + modelCatalogText.get(), 'UTF-8') File buildsPage = layout.buildDirectory.file('mkdocs-source/builds.md').get().asFile buildsPage.setText('# Historical Builds\n\nThe Pages publication workflow replaces this staging placeholder with the retained build index.\n', 'UTF-8') File configuration = layout.buildDirectory.file('mkdocs/mkdocs.yml').get().asFile configuration.parentFile.mkdirs() String configurationText = layout.projectDirectory.file('mkdocs.yml').asFile.getText('UTF-8') .replace('custom_dir: docs/overrides', 'custom_dir: ../mkdocs-source/overrides') configuration.setText(configurationText + '\ndocs_dir: ../mkdocs-source\nsite_dir: ../mkdocs-site\n', 'UTF-8') } } tasks.register('verifyModelCatalogDocumentation') { group = 'verification' description = 'Validates model metadata and both the checked-in and staged MkDocs catalogs.' dependsOn(tasks.named('prepareMkDocsSource')) dependsOn(tasks.named('verifyAllDefaultModels')) doLast { File catalog = layout.buildDirectory.file('mkdocs-source/stemmer-model-catalog.md').get().asFile String expected = '# Published Stemmer Model Catalog\n\n' + modelCatalogText.get() if (!catalog.isFile() || catalog.getText('UTF-8') != expected) { throw new GradleException('The staged model catalog is missing or nondeterministic.') } File publishedCatalog = layout.projectDirectory.file('docs/stemmer-model-catalog.md').asFile if (!publishedCatalog.isFile() || publishedCatalog.getText('UTF-8') != expected) { throw new GradleException( 'The checked-in model catalog is stale; run ./gradlew publishModelCatalogDocumentation.') } List identifiers = modelProjects().collect { Project modelProject -> modelProject.name } if (identifiers != identifiers.sort()) { throw new GradleException('Published model projects are not in deterministic model-ID order.') } identifiers.each { String identifier -> if (!expected.contains('org.egothor:radixor-model-' + identifier)) { throw new GradleException('The staged model catalog omits published model ' + identifier + '.') } } if (!layout.buildDirectory.file('mkdocs/mkdocs.yml').get().asFile.isFile()) { throw new GradleException('The staged MkDocs configuration is missing.') } } } tasks.named('check') { dependsOn(tasks.named('verifyModelCatalogDocumentation')) } tasks.named('cyclonedxDirectBom') { includeConfigs = ['runtimeClasspath', 'compileClasspath'] skipConfigs = ['testRuntimeClasspath', 'testCompileClasspath', 'jmh.*', 'mockitoAgent'] includeBomSerialNumber = true includeLicenseText = false includeMetadataResolution = true includeBuildSystem = true jsonOutput.set(sbomReportsDirectory.map { it.file('radixor-sbom.json') }) xmlOutput.set(sbomReportsDirectory.map { it.file('radixor-sbom.xml') }) } subprojects { tasks.matching { Task candidate -> candidate.name == 'cyclonedxDirectBom' }.configureEach { enabled = false description = 'Disabled because the root project exclusively owns CycloneDX SBOM generation.' } } 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', // These integration tests require dedicated Gradle task wiring and must not run in PIT worker JVMs. 'org.egothor.stemmer.FullRuntimeModelIntegrationTest', 'org.egothor.stemmer.ModelDependencyResolutionTest' ] 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.' } tasks.named('jmhJar', Jar) { exclude 'META-INF/radixor/models.index' exclude 'META-INF/radixor/models/**' exclude 'org/egothor/stemmer/models/**' exclude 'META-INF/LICENSES/**' } 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 registered model dictionary groups.' dependsOn(tasks.named('testClasses')) dependsOn(tasks.named('jmhClasses')) dependsOn(tasks.named('prepareBenchmarkModelInputs')) 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.buildDirectory.dir('generated/benchmark-model-inputs').get().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('benchmarkCorpusReport', JavaExec) { group = 'verification' description = 'Reports corpus and preferred patch-command counts for every default model.' dependsOn(tasks.named('jmhClasses')) classpath = files(sourceSets.jmh.runtimeClasspath, configurations.stemmingQualityJmhRuntime) mainClass = 'org.egothor.stemmer.benchmark.BenchmarkCorpusReportApplication' args layout.buildDirectory.file('reports/jmh/benchmark-corpora.csv').get().asFile.absolutePath maxHeapSize = '6g' } tasks.register('writeJmhRuntimeClasspath') { group = 'verification' description = 'Writes the complete modular JMH runtime classpath for isolated direct JMH execution.' dependsOn(tasks.named('jmhJar')) dependsOn(modelProjects().collect { Project modelProject -> modelProject.tasks.named('jar') }) outputs.file(layout.buildDirectory.file('reports/jmh/jmh-runtime-classpath.txt')) doLast { File report = layout.buildDirectory.file('reports/jmh/jmh-runtime-classpath.txt').get().asFile report.parentFile.mkdirs() File executable = tasks.named('jmhJar', Jar).get().archiveFile.get().asFile report.setText(executable.absolutePath + File.pathSeparator + sourceSets.jmh.runtimeClasspath.asPath + System.lineSeparator(), 'UTF-8') } } tasks.register('prepareBenchmarkModelInputs', Sync) { group = 'verification' description = 'Prepares default model inputs for JMH and quality evaluation without changing source data.' into(layout.buildDirectory.dir('generated/benchmark-model-inputs')) defaultModelProjects().each { Project modelProject -> String languageDirectory = modelProject.name == 'pl-pl-unimorph' ? 'pl_pl' : modelProject.name.replace('-default', '').replace('-', '_') from(modelProject.file('src/modelInput/stemmer.gz')) { into(languageDirectory) } } } 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 = """ """ options.links('https://docs.oracle.com/en/java/javase/21/docs/api/') options.group('Core Stemming API', 'org.egothor.stemmer') options.group('Trie Infrastructure', 'org.egothor.stemmer.trie') source = sourceSets.main.allJava } apply from: 'gradle/snowball-benchmarks.gradle' apply from: 'gradle/paicehusk-benchmarks.gradle' apply from: 'gradle/opennlp-benchmarks.gradle' apply from: 'gradle/hunspell-benchmarks.gradle' apply from: 'gradle/cistem-benchmarks.gradle' apply from: 'gradle/python.gradle' 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 }