From 7c4026322bfe9924fb84310b430fc23bb3615143 Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Mon, 26 Oct 2026 10:14:47 +0100 Subject: [PATCH 01/15] TS-48007 Match includes and excludes against the class name in reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report side matched the patterns against a name derived from the class file's location, e.g. ".home.build.target.classes.com.company.Foo", so com.company.* selected classes for instrumentation and then dropped them from the report. FilteringAnalyzer now reads the class name from the class file header, which only scans the constant pool and costs about 1 µs per class, and matches it like JaCoCo does. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + .../jacoco/agent/options/AgentOptionsTest.kt | 22 ++--- .../report/jacoco/FilteringAnalyzer.kt | 13 +-- .../testwise/jacoco/cache/ProbesCache.kt | 5 +- .../util/ClasspathWildcardIncludeFilter.kt | 50 ++--------- .../report/jacoco/FilteringAnalyzerTest.kt | 86 +++++++++++++++++++ .../ClasspathWildcardIncludeFilterTest.kt | 62 +++++++------ sample-app/java-profiler.properties | 2 +- .../maven-dump-local-project/pom.xml | 2 +- system-tests/tia-maven/maven-project/pom.xml | 2 +- .../teamscale/config/AgentConfiguration.kt | 9 -- 11 files changed, 156 insertions(+), 99 deletions(-) create mode 100644 report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fde6e1b5..adc370e89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ We use [semantic versioning](http://semver.org/): - PATCH version when you make backwards compatible bug fixes. # Next version +- [breaking] _agent_, _teamscale-maven-plugin_: `includes` and `excludes` no longer match parts of the class file's path outside of its package (e.g. `*target.classes.*`), and nested classes are matched with `$` (e.g. `com.company.Outer$Inner`) instead of `.`, like JaCoCo does during instrumentation. +- [fix] _agent_, _teamscale-maven-plugin_: `includes` and `excludes` are now matched against the class name read from the class file when generating coverage reports. Previously, they were matched against a name derived from the file location, so a pattern without a leading `*` (e.g. `com.company.*`) excluded all classes from class directories and from `BOOT-INF/classes` or `WEB-INF/classes` in archives, which resulted in an empty testwise coverage report, and `excludes` had no effect for these classes. - [fix] _teamscale-maven-plugin_: `prepare-tia-unit-test` and `prepare-tia-integration-test` no longer require a commit or revision when `runImpacted` is `false`. - [fix] _teamscale-gradle-plugin_, _teamscale-maven-plugin_: Testwise coverage reports no longer drop a class directory that contains an archive (JAR, WAR, EAR, ZIP) that contains a JAR. diff --git a/agent/src/test/kotlin/com/teamscale/jacoco/agent/options/AgentOptionsTest.kt b/agent/src/test/kotlin/com/teamscale/jacoco/agent/options/AgentOptionsTest.kt index 2aa5545f8..c16338e15 100644 --- a/agent/src/test/kotlin/com/teamscale/jacoco/agent/options/AgentOptionsTest.kt +++ b/agent/src/test/kotlin/com/teamscale/jacoco/agent/options/AgentOptionsTest.kt @@ -49,24 +49,20 @@ class AgentOptionsTest { @Throws(Exception::class) fun testIncludePatternMatching() { Assertions.assertThat(includeFilter("com.*")).accepts( - "file.jar@com/foo/Bar.class", $$"file.jar@com/foo/Bar$Goo.class", - "file1.jar@goo/file2.jar@com/foo/Bar.class", "com/foo/Bar.class", "com.foo/Bar.class" + "com/foo/Bar", $$"com/foo/Bar$Goo", "com.foo.Bar" ) Assertions.assertThat(includeFilter("com.*")).rejects( - "foo/com/Bar.class", "com.class", "file.jar@com.class", - $$"A$com$Bar.class" + "foo/com/Bar", "com", $$"A$com$Bar" ) Assertions.assertThat(includeFilter("*com.*")).accepts( - "file.jar@com/foo/Bar.class", $$"file.jar@com/foo/Bar$Goo.class", - "file1.jar@goo/file2.jar@com/foo/Bar.class", "com/foo/Bar.class", "foo/com/goo/Bar.class", - $$"A$com$Bar.class", "src/com/foo/Bar.class" + "com/foo/Bar", $$"com/foo/Bar$Goo", "foo/com/goo/Bar" ) - Assertions.assertThat(includeFilter("*com.*;*de.*")) - .accepts("file.jar@com/foo/Bar.class", $$"file.jar@de/foo/Bar$Goo.class") - Assertions.assertThat(excludeFilter("*com.*;*de.*")) - .rejects("file.jar@com/foo/Bar.class", $$"file.jar@de/foo/Bar$Goo.class") - Assertions.assertThat(includeFilter("*com.customer.*")).accepts( - "C:\\client-daily\\client\\plugins\\com.customer.something.client_1.2.3.4.1234566778.jar@com/customer/something/SomeClass.class" + Assertions.assertThat(includeFilter("com.*;de.*")) + .accepts("com/foo/Bar", $$"de/foo/Bar$Goo") + Assertions.assertThat(excludeFilter("com.*;de.*")) + .rejects("com/foo/Bar", $$"de/foo/Bar$Goo") + Assertions.assertThat(includeFilter("com.customer.*")).accepts( + "com/customer/something/SomeClass" ) } diff --git a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt index 0d968b445..cb9101fce 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt @@ -5,13 +5,14 @@ import com.teamscale.report.util.ClasspathWildcardIncludeFilter import com.teamscale.report.util.ILogger import org.jacoco.core.analysis.ICoverageVisitor import org.jacoco.core.data.ExecutionDataStore +import org.jacoco.core.internal.instr.InstrSupport import java.io.IOException import java.io.InputStream import java.util.zip.ZipEntry import java.util.zip.ZipInputStream /** - * [org.jacoco.core.analysis.Analyzer] that filters the analyzed class files based on a given predicate. + * [org.jacoco.core.analysis.Analyzer] that filters the analyzed class files by their class name. * * @param executionData The execution data store. * @param coverageVisitor The coverage visitor. @@ -27,10 +28,6 @@ open class FilteringAnalyzer( /** {@inheritDoc} */ @Throws(IOException::class) override fun analyzeAll(input: InputStream, location: String): Int { - if (location.endsWith(".class") && !locationIncludeFilter.isIncluded(location)) { - logger.debug("Excluding class file $location") - return 1 - } if (location.endsWith(".jar")) { return analyzeJar(input, location) } @@ -40,6 +37,12 @@ open class FilteringAnalyzer( @Throws(IOException::class) override fun analyzeClass(buffer: ByteArray, location: String) { try { + // Only reads the constant pool, which is cheap compared to the analysis of the whole class + val className = InstrSupport.classReaderFor(buffer).className + if (!locationIncludeFilter.isIncluded(className)) { + logger.debug("Excluding class file $location ($className)") + return + } analyzeClass(buffer) } catch (cause: RuntimeException) { if (cause.isUnsupportedClassFile()) { diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/ProbesCache.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/ProbesCache.kt index a6dcca6ef..07b2b6fe0 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/ProbesCache.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/ProbesCache.kt @@ -74,9 +74,8 @@ class ProbesCache( ): FileCoverageBuilder? { val classId = executionData.id if (!containsClassId(classId)) { - val fullyQualifiedClassName = JavaNames().getQualifiedClassName(executionData.name) - if (locationIncludeFilter.isIncluded("$fullyQualifiedClassName.class")) { - classNotFoundLogger.log(fullyQualifiedClassName) + if (locationIncludeFilter.isIncluded(executionData.name)) { + classNotFoundLogger.log(JavaNames().getQualifiedClassName(executionData.name)) } return null } diff --git a/report-generator/src/main/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilter.kt b/report-generator/src/main/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilter.kt index ffafb1225..a8d61ff74 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilter.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilter.kt @@ -1,16 +1,11 @@ package com.teamscale.report.util -import com.teamscale.client.FileSystemUtils -import com.teamscale.client.StringUtils import org.jacoco.core.runtime.WildcardMatcher -import org.jacoco.report.JavaNames -import java.util.* -import java.util.concurrent.ConcurrentHashMap /*** - * Tests given class file paths against call name patterns. - * E.g. "/some/file/path/test.jar@my/package/Test.class" matches "my/package/ *" or "my/package/Test" + * Tests class names against wildcard include and exclude patterns, the same way JaCoCo's agent does. + * E.g. "com/example/Test" and "com.example.Test" match "com.example.*" or "com.example.Test". */ open class ClasspathWildcardIncludeFilter( locationIncludeFilters: String?, @@ -44,43 +39,14 @@ open class ClasspathWildcardIncludeFilter( } /** - * Tests if the given file path (e.g. "/some/file/path/test.jar@my/package/Test.class" or "org/mypackage/MyClass" + * Tests if the given class name (e.g. "com/example/Outer$Inner" or "com.example.Outer$Inner") is included. Like + * JaCoCo, only slashes are replaced by dots, so nested classes keep their "$". */ - fun isIncluded(path: String): Boolean { - val className = getClassName(path) - if (locationIncludeFilters != null && locationIncludeFilters?.matches(className) == false) { + fun isIncluded(className: String): Boolean { + val dottedClassName = className.replace('/', '.') + if (locationIncludeFilters != null && locationIncludeFilters?.matches(dottedClassName) == false) { return false } - return locationExcludeFilters == null || locationExcludeFilters?.matches(className) == false - } - - - companion object { - private val AT_REGEX = "@".toRegex() - private val JAVA_NAMES = JavaNames() - - private val classNameCache = ConcurrentHashMap(1024) - - /** - * Returns the normalized class name of the given class file's path. I.e. turns something like - * "/opt/deploy/some.jar@com/teamscale/Class.class" into something like "com.teamscale.Class". - */ - fun getClassName(path: String): String = - classNameCache.computeIfAbsent(path) { computeClassName(it) } - - private fun computeClassName(path: String): String { - val parts = FileSystemUtils.normalizeSeparators(path) - .split(AT_REGEX).dropLastWhile { it.isEmpty() } - .toTypedArray() - if (parts.isEmpty()) { - return "" - } - - var pathInsideJar = parts[parts.size - 1] - if (path.lowercase(Locale.getDefault()).endsWith(".class")) { - pathInsideJar = StringUtils.removeLastPart(pathInsideJar, '.') - } - return JAVA_NAMES.getQualifiedClassName(pathInsideJar) - } + return locationExcludeFilters == null || locationExcludeFilters?.matches(dottedClassName) == false } } diff --git a/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt b/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt new file mode 100644 index 000000000..187c3e28a --- /dev/null +++ b/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt @@ -0,0 +1,86 @@ +package com.teamscale.report.jacoco + +import com.teamscale.report.util.ClasspathWildcardIncludeFilter +import com.teamscale.report.util.ILogger +import org.assertj.core.api.Assertions.assertThat +import org.jacoco.core.data.ExecutionDataStore +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.mockito.Mockito +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** Tests that [FilteringAnalyzer] filters class files by their class name, independent of their location. */ +class FilteringAnalyzerTest { + + @TempDir + private lateinit var tempDir: File + + private val classBytes = classBytes("FilteringAnalyzerTest.class") + + /** + * The location of a class file must not influence whether it is included: class directories may contain further + * directories above the package structure, class files need not be in a directory matching their package (e.g. + * in Kotlin projects) and archives may contain classes below e.g. `BOOT-INF/classes`. + */ + @ParameterizedTest + @CsvSource( + "com.teamscale.report.*, , true", + "*com.teamscale.report.*, , true", + "com.teamscale.report.jacoco.FilteringAnalyzerTest, , true", + "org.other.*, , false", + ", com.teamscale.*, false", + "com.teamscale.*, com.teamscale.report.jacoco.*, false", + ) + fun filtersByClassNameIndependentOfLocation(includes: String?, excludes: String?, expectedIncluded: Boolean) { + tempDir.resolve("build/classes/main/com/teamscale/report/jacoco/FilteringAnalyzerTest.class").write(classBytes) + tempDir.resolve("flat/FilteringAnalyzerTest.class").write(classBytes) + tempDir.resolve("app.jar").write(zip("BOOT-INF/classes/com/teamscale/report/jacoco/FilteringAnalyzerTest.class")) + tempDir.resolve("app.war").write(zip("WEB-INF/classes/com/teamscale/report/jacoco/FilteringAnalyzerTest.class")) + + val analyzedClasses = analyze(ClasspathWildcardIncludeFilter(includes, excludes)) + + assertThat(analyzedClasses).hasSize(if (expectedIncluded) 4 else 0) + } + + /** Like JaCoCo, nested classes are matched with their `$`. */ + @Test + fun matchesNestedClassesWithDollar() { + tempDir.resolve("Outer.class").write(classBytes) + tempDir.resolve("Nested.class").write(classBytes("FilteringAnalyzerTest\$Nested.class")) + + assertThat(analyze(ClasspathWildcardIncludeFilter("com.teamscale.report.jacoco.FilteringAnalyzerTest$*", null))) + .containsExactly("com/teamscale/report/jacoco/FilteringAnalyzerTest\$Nested") + } + + private fun analyze(filter: ClasspathWildcardIncludeFilter): List { + val analyzedClasses = mutableListOf() + FilteringAnalyzer(ExecutionDataStore(), { analyzedClasses.add(it.name) }, filter, Mockito.mock()) + .analyzeAll(tempDir) + return analyzedClasses + } + + private fun File.write(content: ByteArray) { + parentFile.mkdirs() + writeBytes(content) + } + + private fun classBytes(name: String) = javaClass.getResourceAsStream(name)!!.use { it.readBytes() } + + private fun zip(entryName: String): ByteArray { + val output = ByteArrayOutputStream() + ZipOutputStream(output).use { zip -> + zip.putNextEntry(ZipEntry(entryName)) + zip.write(classBytes) + zip.closeEntry() + } + return output.toByteArray() + } + + /** A nested class to test the matching of nested class names. */ + private class Nested +} diff --git a/report-generator/src/test/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilterTest.kt b/report-generator/src/test/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilterTest.kt index fa7d3b076..b630183e2 100644 --- a/report-generator/src/test/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilterTest.kt +++ b/report-generator/src/test/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilterTest.kt @@ -1,36 +1,50 @@ package com.teamscale.report.util -import com.teamscale.report.util.ClasspathWildcardIncludeFilter.Companion.getClassName -import org.assertj.core.api.Assertions import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test class ClasspathWildcardIncludeFilterTest { - /** Tests path to class name conversion. */ + + /** Patterns must match the class name with and without leading `*`, given in internal or dotted form. */ + @Test + fun testIncludes() { + val filter = ClasspathWildcardIncludeFilter("com.company.*", null) + assertThat(filter.isIncluded("com/company/Foo")).isTrue() + assertThat(filter.isIncluded("com.company.Foo")).isTrue() + assertThat(filter.isIncluded("com/company/sub/Foo")).isTrue() + assertThat(filter.isIncluded("org/company/Foo")).isFalse() + assertThat(filter.isIncluded("org/com/company/Foo")).isFalse() + + val filterWithLeadingStar = ClasspathWildcardIncludeFilter("*com.company.*", null) + assertThat(filterWithLeadingStar.isIncluded("com/company/Foo")).isTrue() + assertThat(filterWithLeadingStar.isIncluded("org/com/company/Foo")).isTrue() + } + @Test - fun testPathToClassNameConversion() { - assertThat(getClassName("file.jar@com/foo/Bar.class")).isEqualTo("com.foo.Bar") - assertThat(getClassName("file.jar@com/foo/Bar\$Goo.class")).isEqualTo("com.foo.Bar.Goo") - assertThat(getClassName("file1.jar@goo/file2.jar@com/foo/Bar.class")).isEqualTo("com.foo.Bar") - assertThat(getClassName("com/foo/Bar.class")).isEqualTo("com.foo.Bar") - assertThat(getClassName("com/foo/Bar")).isEqualTo("com.foo.Bar") - assertThat( - getClassName( - "C:\\client-daily\\client\\plugins\\com.customer.something.client_1.2.3.4.1234566778.jar@com/customer/something/SomeClass.class" - ) - ).isEqualTo("com.customer.something.SomeClass") + fun testExcludes() { + val filter = ClasspathWildcardIncludeFilter(null, "org.junit.*") + assertThat(filter.isIncluded("org/junit/jupiter/engine/Constants")).isFalse() + assertThat(filter.isIncluded("com/company/Foo")).isTrue() + + val filter2 = ClasspathWildcardIncludeFilter("com.company.*", "com.company.generated.*") + assertThat(filter2.isIncluded("com/company/Foo")).isTrue() + assertThat(filter2.isIncluded("com/company/generated/Foo")).isFalse() } + /** Like JaCoCo, nested classes keep their `$`. */ + @Test + fun testNestedClasses() { + assertThat(ClasspathWildcardIncludeFilter("com.company.Outer$*", null).isIncluded("com/company/Outer\$Inner")) + .isTrue() + assertThat(ClasspathWildcardIncludeFilter("com.company.Outer.Inner", null).isIncluded("com/company/Outer\$Inner")) + .isFalse() + } @Test - fun testMatching() { - assertThat( - ClasspathWildcardIncludeFilter(null, "org.junit.*") - .isIncluded("/junit-jupiter-engine-5.1.0.jar@org/junit/jupiter/engine/Constants.class") - ).isFalse() - assertThat( - ClasspathWildcardIncludeFilter(null, "org.junit.*") - .isIncluded("org/junit/platform/commons/util/ModuleUtils\$ModuleReferenceScanner.class") - ).isFalse() + fun testMultiplePatterns() { + val filter = ClasspathWildcardIncludeFilter("com.*:de.*", null) + assertThat(filter.isIncluded("com/foo/Bar")).isTrue() + assertThat(filter.isIncluded("de/foo/Bar\$Goo")).isTrue() + assertThat(filter.isIncluded("org/foo/Bar")).isFalse() } -} \ No newline at end of file +} diff --git a/sample-app/java-profiler.properties b/sample-app/java-profiler.properties index 37729c4ba..b926d314d 100644 --- a/sample-app/java-profiler.properties +++ b/sample-app/java-profiler.properties @@ -19,7 +19,7 @@ debug=true # Local configuration -includes=*com.example.* +includes=com.example.* # Uncomment and complete the following to upload coverage to a local Teamscale instance. #teamscale-project= #teamscale-partition=Agent Debugging diff --git a/system-tests/tia-maven/maven-dump-local-project/pom.xml b/system-tests/tia-maven/maven-dump-local-project/pom.xml index 55b4be13d..058433bed 100644 --- a/system-tests/tia-maven/maven-dump-local-project/pom.xml +++ b/system-tests/tia-maven/maven-dump-local-project/pom.xml @@ -51,7 +51,7 @@ true false - *foo.* + foo.* diff --git a/system-tests/tia-maven/maven-project/pom.xml b/system-tests/tia-maven/maven-project/pom.xml index 96cb549e7..64b824bf2 100644 --- a/system-tests/tia-maven/maven-project/pom.xml +++ b/system-tests/tia-maven/maven-project/pom.xml @@ -54,7 +54,7 @@ ${baselineCommit} myRepoId - *example.* + org.example.* diff --git a/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/config/AgentConfiguration.kt b/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/config/AgentConfiguration.kt index aa1102ac1..590576616 100644 --- a/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/config/AgentConfiguration.kt +++ b/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/config/AgentConfiguration.kt @@ -1,6 +1,5 @@ package com.teamscale.config -import com.teamscale.report.util.ClasspathWildcardIncludeFilter import com.teamscale.utils.ArgumentAppender import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl @@ -64,14 +63,6 @@ abstract class AgentConfiguration @Inject constructor( remoteAgent = TeamscaleAgent(url.toHttpUrl()) } - /** Returns a filter predicate that respects the configured wildcard include and exclude patterns. */ - fun getPredicate(): ClasspathWildcardIncludeFilter { - return ClasspathWildcardIncludeFilter( - jacocoExtension.includes?.joinToString(":") { "*$it".replace('/', '.') }, - jacocoExtension.excludes?.joinToString(":") { "*$it".replace('/', '.') } - ) - } - inner class TeamscaleAgent(val url: HttpUrl) { /** Builds the jvm argument to start the impacted test executor. */ From 5d671e145f133db0298056ff59d1e6d5ad80703b Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Mon, 26 Oct 2026 10:18:35 +0100 Subject: [PATCH 02/15] TS-48007 Replace OpenAnalyzer by JaCoCo's Analyzer OpenAnalyzer was a copy of JaCoCo's Analyzer that only made analyzeClass(byte[]) and analyzerError protected. Now that FilteringAnalyzer filters in the public analyzeClass(byte[], String), it can extend JaCoCo's Analyzer directly, and AnalyzerCache overrides the new analyzeIncludedClass instead. This removes the need to keep the copy in sync with every JaCoCo update. Errors from JaCoCo's own ZIP, GZIP and Pack200 handling now carry JaCoCo's message. Co-Authored-By: Claude Opus 5.5 --- .../teamscale/report/jacoco/OpenAnalyzer.java | 299 ------------------ .../report/jacoco/FilteringAnalyzer.kt | 29 +- .../testwise/jacoco/cache/AnalyzerCache.kt | 12 +- .../report/jacoco/FilteringAnalyzerTest.kt | 18 +- 4 files changed, 47 insertions(+), 311 deletions(-) delete mode 100644 report-generator/src/main/java/com/teamscale/report/jacoco/OpenAnalyzer.java diff --git a/report-generator/src/main/java/com/teamscale/report/jacoco/OpenAnalyzer.java b/report-generator/src/main/java/com/teamscale/report/jacoco/OpenAnalyzer.java deleted file mode 100644 index 983c91d5d..000000000 --- a/report-generator/src/main/java/com/teamscale/report/jacoco/OpenAnalyzer.java +++ /dev/null @@ -1,299 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2009, 2025 Mountainminds GmbH & Co. KG and Contributors - * This program and the accompanying materials are made available under - * the terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Marc R. Hoffmann - initial API and implementation - * - *******************************************************************************/ - -package com.teamscale.report.jacoco; - -import org.jacoco.core.JaCoCo; -import org.jacoco.core.analysis.Analyzer; -import org.jacoco.core.analysis.ICoverageVisitor; -import org.jacoco.core.data.ExecutionData; -import org.jacoco.core.data.ExecutionDataStore; -import org.jacoco.core.internal.ContentTypeDetector; -import org.jacoco.core.internal.InputStreams; -import org.jacoco.core.internal.Pack200Streams; -import org.jacoco.core.internal.analysis.ClassAnalyzer; -import org.jacoco.core.internal.analysis.ClassCoverageImpl; -import org.jacoco.core.internal.analysis.StringPool; -import org.jacoco.core.internal.data.CRC64; -import org.jacoco.core.internal.flow.ClassProbesAdapter; -import org.jacoco.core.internal.instr.InstrSupport; -import org.objectweb.asm.ClassReader; -import org.objectweb.asm.ClassVisitor; -import org.objectweb.asm.Opcodes; - -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.util.StringTokenizer; -import java.util.zip.GZIPInputStream; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; - -/** - * This is a copy of the {@link Analyzer} class from JaCoCo. The only changes are that the following methods are - * protected instead of private: - {@link #analyzeClass(byte[])} - {@link #analyzerError(String, Exception)} - *

- * When performing an update of JaCoCo we need to check that this file is still up to date. - *

- * An {@link Analyzer} instance processes a set of Java class files and calculates coverage data for them. For each - * class file the result is reported to a given {@link ICoverageVisitor} instance. In addition, the {@link Analyzer} - * requires a {@link ExecutionDataStore} instance that holds the execution data for the classes to analyze. The - * {@link Analyzer} offers several methods to analyze classes from a variety of sources. - *

- * CAUTION: Do not convert to Kotlin. This class has to stay in Java for future maintenance reasons! - */ -public class OpenAnalyzer { - - private final ExecutionDataStore executionData; - - private final ICoverageVisitor coverageVisitor; - - private final StringPool stringPool; - - /** - * Creates a new analyzer reporting to the given output. - * - * @param executionData execution data - * @param coverageVisitor the output instance that will coverage data for every analyzed class - */ - public OpenAnalyzer(final ExecutionDataStore executionData, - final ICoverageVisitor coverageVisitor) { - this.executionData = executionData; - this.coverageVisitor = coverageVisitor; - this.stringPool = new StringPool(); - } - - /** - * Creates an ASM class visitor for analysis. - * - * @param classId id of the class calculated with {@link CRC64} - * @param className VM name of the class - * @return ASM visitor to write class definition to - */ - private ClassVisitor createAnalyzingVisitor(final long classId, - final String className) { - final ExecutionData data = executionData.get(classId); - final boolean[] probes; - final boolean noMatch; - if (data == null) { - probes = null; - noMatch = executionData.contains(className); - } else { - probes = data.getProbes(); - noMatch = false; - } - final ClassCoverageImpl coverage = new ClassCoverageImpl(className, - classId, noMatch); - final ClassAnalyzer analyzer = new ClassAnalyzer(coverage, probes, - stringPool) { - @Override - public void visitEnd() { - super.visitEnd(); - coverageVisitor.visitCoverage(coverage); - } - }; - return new ClassProbesAdapter(analyzer, false); - } - - /** Analyzes the given class in binary form. */ - protected void analyzeClass(final byte[] source) { - final long classId = CRC64.classId(source); - final ClassReader reader = InstrSupport.classReaderFor(source); - if ((reader.getAccess() & Opcodes.ACC_MODULE) != 0) { - return; - } - if (reader.getClassName().endsWith("/package-info")) { - return; - } - final ClassVisitor visitor = createAnalyzingVisitor(classId, - reader.getClassName()); - reader.accept(visitor, 0); - } - - /** - * Analyzes the class definition from a given in-memory buffer. - * - * @param buffer class definitions - * @param location a location description used for exception messages - * @throws IOException if the class can't be analyzed - */ - public void analyzeClass(final byte[] buffer, final String location) - throws IOException { - try { - analyzeClass(buffer); - } catch (final RuntimeException cause) { - throw analyzerError(location, cause); - } - } - - /** - * Analyzes the class definition from a given input stream. The provided {@link InputStream} is not closed by this - * method. - * - * @param input stream to read class definition from - * @param location a location description used for exception messages - * @throws IOException if the stream can't be read or the class can't be analyzed - */ - public void analyzeClass(final InputStream input, final String location) - throws IOException { - final byte[] buffer; - try { - buffer = InputStreams.readFully(input); - } catch (final IOException e) { - throw analyzerError(location, e); - } - analyzeClass(buffer, location); - } - - /** Creates an {@link IOException} which includes the affected file location and JaCoCo version. */ - protected IOException analyzerError(final String location, - final Exception cause) { - final IOException ex = new IOException( - String.format( - "Could not analyze coverage for '%s' (JaCoCo %s/%s): %s." - + " Verify that the file is a readable .class, JAR, ZIP, GZ, or Pack200 archive" - + " and that it is not corrupt.", - location, JaCoCo.VERSION, JaCoCo.COMMITID_SHORT, - cause.getMessage())); - ex.initCause(cause); - return ex; - } - - /** - * Analyzes all classes found in the given input stream. The input stream may either represent a single class file, - * a ZIP archive, a Pack200 archive or a gzip stream that is searched recursively for class files. All other content - * types are ignored. The provided {@link InputStream} is not closed by this method. - * - * @param input input data - * @param location a location description used for exception messages - * @return number of class files found - * @throws IOException if the stream can't be read or a class can't be analyzed - */ - public int analyzeAll(final InputStream input, final String location) - throws IOException { - final ContentTypeDetector detector; - try { - detector = new ContentTypeDetector(input); - } catch (final IOException e) { - throw analyzerError(location, e); - } - switch (detector.getType()) { - case ContentTypeDetector.CLASSFILE: - analyzeClass(detector.getInputStream(), location); - return 1; - case ContentTypeDetector.ZIPFILE: - return analyzeZip(detector.getInputStream(), location); - case ContentTypeDetector.GZFILE: - return analyzeGzip(detector.getInputStream(), location); - case ContentTypeDetector.PACK200FILE: - return analyzePack200(detector.getInputStream(), location); - default: - return 0; - } - } - - /** - * Analyzes all class files contained in the given file or folder. Class files as well as ZIP files are considered. - * Folders are searched recursively. - * - * @param file file or folder to look for class files - * @return number of class files found - * @throws IOException if the file can't be read or a class can't be analyzed - */ - public int analyzeAll(final File file) throws IOException { - int count = 0; - if (file.isDirectory()) { - for (final File f : file.listFiles()) { - count += analyzeAll(f); - } - } else { - final InputStream in = new FileInputStream(file); - try { - count += analyzeAll(in, file.getPath()); - } finally { - in.close(); - } - } - return count; - } - - /** - * Analyzes all classes from the given class path. Directories containing class files as well as archive files are - * considered. - * - * @param path path definition - * @param basedir optional base directory, if null the current working directory is used as the base - * for relative path entries - * @return number of class files found - * @throws IOException if a file can't be read or a class can't be analyzed - */ - public int analyzeAll(final String path, final File basedir) - throws IOException { - int count = 0; - final StringTokenizer st = new StringTokenizer(path, - File.pathSeparator); - while (st.hasMoreTokens()) { - count += analyzeAll(new File(basedir, st.nextToken())); - } - return count; - } - - private int analyzeZip(final InputStream input, final String location) - throws IOException { - final ZipInputStream zip = new ZipInputStream(input); - ZipEntry entry; - int count = 0; - while ((entry = nextEntry(zip, location)) != null) { - count += analyzeAll(zip, location + "@" + entry.getName()); - } - return count; - } - - private ZipEntry nextEntry(final ZipInputStream input, - final String location) throws IOException { - try { - return input.getNextEntry(); - } catch (final IOException e) { - throw analyzerError(location, e); - } catch (final IllegalArgumentException e) { - // might be thrown in JDK versions below 23 - see - // https://bugs.openjdk.org/browse/JDK-8321156 - // https://github.com/openjdk/jdk/commit/20c71ceacdcb791f5b70cda456bdc47bdd9acf6c - throw analyzerError(location, e); - } - } - - private int analyzeGzip(final InputStream input, final String location) - throws IOException { - GZIPInputStream gzipInputStream; - try { - gzipInputStream = new GZIPInputStream(input); - } catch (final IOException e) { - throw analyzerError(location, e); - } - return analyzeAll(gzipInputStream, location); - } - - private int analyzePack200(final InputStream input, final String location) - throws IOException { - InputStream unpackedInput; - try { - unpackedInput = Pack200Streams.unpack(input); - } catch (final IOException e) { - throw analyzerError(location, e); - } - return analyzeAll(unpackedInput, location); - } - -} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt index cb9101fce..7e1e3259d 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt @@ -3,6 +3,8 @@ package com.teamscale.report.jacoco import com.teamscale.report.util.BashFileSkippingInputStream import com.teamscale.report.util.ClasspathWildcardIncludeFilter import com.teamscale.report.util.ILogger +import org.jacoco.core.JaCoCo +import org.jacoco.core.analysis.Analyzer import org.jacoco.core.analysis.ICoverageVisitor import org.jacoco.core.data.ExecutionDataStore import org.jacoco.core.internal.instr.InstrSupport @@ -12,7 +14,7 @@ import java.util.zip.ZipEntry import java.util.zip.ZipInputStream /** - * [org.jacoco.core.analysis.Analyzer] that filters the analyzed class files by their class name. + * [Analyzer] that filters the analyzed class files by their class name. * * @param executionData The execution data store. * @param coverageVisitor The coverage visitor. @@ -24,7 +26,7 @@ open class FilteringAnalyzer( coverageVisitor: ICoverageVisitor?, private val locationIncludeFilter: ClasspathWildcardIncludeFilter, private val logger: ILogger -) : OpenAnalyzer(executionData, coverageVisitor) { +) : Analyzer(executionData, coverageVisitor) { /** {@inheritDoc} */ @Throws(IOException::class) override fun analyzeAll(input: InputStream, location: String): Int { @@ -43,7 +45,7 @@ open class FilteringAnalyzer( logger.debug("Excluding class file $location ($className)") return } - analyzeClass(buffer) + analyzeIncludedClass(buffer, location) } catch (cause: RuntimeException) { if (cause.isUnsupportedClassFile()) { logger.error( @@ -58,6 +60,27 @@ open class FilteringAnalyzer( } } + /** + * Analyzes a class that passed the filter. Errors are thrown as [RuntimeException], so that [analyzeClass] reports + * them uniformly. + */ + protected open fun analyzeIncludedClass(buffer: ByteArray, location: String) { + try { + super.analyzeClass(buffer, location) + } catch (e: IOException) { + // JaCoCo wraps the RuntimeException of the analysis into an IOException + throw e.cause as? RuntimeException ?: e + } + } + + /** Creates an [IOException] which includes the affected file location and JaCoCo version. */ + protected fun analyzerError(location: String, cause: Exception) = IOException( + "Could not analyze coverage for '$location' (JaCoCo ${JaCoCo.VERSION}/${JaCoCo.COMMITID_SHORT}): " + + "${cause.message}. Verify that the file is a readable .class, JAR, ZIP, GZ, or Pack200 archive" + + " and that it is not corrupt.", + cause + ) + /** * Checks if the error indicates that the class file might be newer than what is currently supported by * JaCoCo. The concrete error message seems to depend on the used JVM, so we only check for "Unsupported" which seems diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt index 4ea7496dd..1fc0f9bdc 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt @@ -17,8 +17,8 @@ import java.nio.file.Files /** * An [AnalyzerCache] instance processes a set of Java class/jar/war/... files and builds a cache for each of the classes. * - * For every class that gets found, [analyzeClass] is called. A class is identified by its class ID, which - * is a CRC64 checksum of the class file. We process each class with `CachingClassAnalyzer` to fill a cache. + * For every included class that gets found, [analyzeIncludedClass] is called. A class is identified by its class ID, + * which is a CRC64 checksum of the class file. We process each class with `CachingClassAnalyzer` to fill a cache. */ class AnalyzerCache( private val probesCache: ProbesCache, @@ -28,15 +28,15 @@ class AnalyzerCache( private val stringPool = StringPool() /** - * Analyzes the given class. Instead of the original implementation in [Analyzer.analyzeClass] we + * Analyzes the given class. Instead of the original implementation in [FilteringAnalyzer.analyzeIncludedClass] we * don't use concrete execution data, but instead build a probe cache to speed up repeated lookups. */ - override fun analyzeClass(source: ByteArray) { - val classId = CRC64.classId(source) + override fun analyzeIncludedClass(buffer: ByteArray, location: String) { + val classId = CRC64.classId(buffer) if (probesCache.containsClassId(classId)) { return } - val reader = InstrSupport.classReaderFor(source) + val reader = InstrSupport.classReaderFor(buffer) // Dummy class coverage object that allows us to subclass ClassAnalyzer with CachingClassAnalyzer and reuse its // IFilterContext implementation diff --git a/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt b/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt index 187c3e28a..b41d4d510 100644 --- a/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt +++ b/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt @@ -57,10 +57,22 @@ class FilteringAnalyzerTest { .containsExactly("com/teamscale/report/jacoco/FilteringAnalyzerTest\$Nested") } - private fun analyze(filter: ClasspathWildcardIncludeFilter): List { + /** A class file too new for JaCoCo must be logged and skipped instead of failing the whole report. */ + @Test + fun skipsUnsupportedClassFiles() { + // Bytes 6 and 7 hold the class file's major version + tempDir.resolve("Future.class").write(classBytes.copyOf().also { it[6] = 0x7F; it[7] = 0x7F }) + tempDir.resolve("Supported.class").write(classBytes) + val logger = Mockito.mock() + + assertThat(analyze(ClasspathWildcardIncludeFilter(null, null), logger)) + .containsExactly("com/teamscale/report/jacoco/FilteringAnalyzerTest") + Mockito.verify(logger).error(Mockito.contains("Unsupported class file"), Mockito.any(Throwable::class.java)) + } + + private fun analyze(filter: ClasspathWildcardIncludeFilter, logger: ILogger = Mockito.mock()): List { val analyzedClasses = mutableListOf() - FilteringAnalyzer(ExecutionDataStore(), { analyzedClasses.add(it.name) }, filter, Mockito.mock()) - .analyzeAll(tempDir) + FilteringAnalyzer(ExecutionDataStore(), { analyzedClasses.add(it.name) }, filter, logger).analyzeAll(tempDir) return analyzedClasses } From f00b4079671a9e5bc75e08ec6d6c942ab6654003 Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Mon, 26 Oct 2026 10:25:20 +0100 Subject: [PATCH 03/15] TS-48007 Split the class file version patch into separate statements Co-Authored-By: Claude Opus 5.5 --- .../com/teamscale/report/jacoco/FilteringAnalyzerTest.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt b/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt index b41d4d510..5fb87d29e 100644 --- a/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt +++ b/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt @@ -60,8 +60,11 @@ class FilteringAnalyzerTest { /** A class file too new for JaCoCo must be logged and skipped instead of failing the whole report. */ @Test fun skipsUnsupportedClassFiles() { + val futureClassBytes = classBytes.copyOf() // Bytes 6 and 7 hold the class file's major version - tempDir.resolve("Future.class").write(classBytes.copyOf().also { it[6] = 0x7F; it[7] = 0x7F }) + futureClassBytes[6] = 0x7F + futureClassBytes[7] = 0x7F + tempDir.resolve("Future.class").write(futureClassBytes) tempDir.resolve("Supported.class").write(classBytes) val logger = Mockito.mock() From 6b5e4e41380a01f8d69e0ae8f86c9f10f909ce42 Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Mon, 26 Oct 2026 10:30:24 +0100 Subject: [PATCH 04/15] TS-48007 Drop OpenAnalyzer from the JaCoCo upgrade note Co-Authored-By: Claude Opus 5.5 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 797482b4a..4ab69a534 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] # When upgrading JaCoCo to a newer version, make sure to -# check the comment in the OpenAnalyzer.java, JaCoCoPreMain.java and CachingInstructionsBuilder.java +# check the comment in the JaCoCoPreMain.java and CachingInstructionsBuilder.java # and update the internal_xxxxxx hash included in the imports in LenientCoverageTransformer.java and JaCoCoPreMain.java. jacoco = "0.8.15" # We need to stay on the 1.3.x release line as 1.4.x requires Java 11 From 690715563901e3320cdf823b52865e160ac20984 Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Mon, 26 Oct 2026 10:34:30 +0100 Subject: [PATCH 05/15] TS-48007 Document that the filter takes JVM internal class names All callers pass internal names (ClassReader, ExecutionData, ClassFileTransformer), so the contract and tests no longer mention dotted names. Co-Authored-By: Claude Opus 5.5 --- .../teamscale/jacoco/agent/options/AgentOptionsTest.kt | 2 +- .../report/util/ClasspathWildcardIncludeFilter.kt | 10 +++++----- .../report/util/ClasspathWildcardIncludeFilterTest.kt | 3 +-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/agent/src/test/kotlin/com/teamscale/jacoco/agent/options/AgentOptionsTest.kt b/agent/src/test/kotlin/com/teamscale/jacoco/agent/options/AgentOptionsTest.kt index c16338e15..d71e898d3 100644 --- a/agent/src/test/kotlin/com/teamscale/jacoco/agent/options/AgentOptionsTest.kt +++ b/agent/src/test/kotlin/com/teamscale/jacoco/agent/options/AgentOptionsTest.kt @@ -49,7 +49,7 @@ class AgentOptionsTest { @Throws(Exception::class) fun testIncludePatternMatching() { Assertions.assertThat(includeFilter("com.*")).accepts( - "com/foo/Bar", $$"com/foo/Bar$Goo", "com.foo.Bar" + "com/foo/Bar", $$"com/foo/Bar$Goo" ) Assertions.assertThat(includeFilter("com.*")).rejects( "foo/com/Bar", "com", $$"A$com$Bar" diff --git a/report-generator/src/main/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilter.kt b/report-generator/src/main/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilter.kt index a8d61ff74..e26b11f23 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilter.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilter.kt @@ -5,7 +5,7 @@ import org.jacoco.core.runtime.WildcardMatcher /*** * Tests class names against wildcard include and exclude patterns, the same way JaCoCo's agent does. - * E.g. "com/example/Test" and "com.example.Test" match "com.example.*" or "com.example.Test". + * E.g. "com/example/Test" matches "com.example.*" or "com.example.Test". */ open class ClasspathWildcardIncludeFilter( locationIncludeFilters: String?, @@ -39,11 +39,11 @@ open class ClasspathWildcardIncludeFilter( } /** - * Tests if the given class name (e.g. "com/example/Outer$Inner" or "com.example.Outer$Inner") is included. Like - * JaCoCo, only slashes are replaced by dots, so nested classes keep their "$". + * Tests if the class with the given JVM internal name (e.g. "com/example/Outer$Inner") is included. Like JaCoCo, + * only slashes are replaced by dots, so nested classes keep their "$". */ - fun isIncluded(className: String): Boolean { - val dottedClassName = className.replace('/', '.') + fun isIncluded(internalClassName: String): Boolean { + val dottedClassName = internalClassName.replace('/', '.') if (locationIncludeFilters != null && locationIncludeFilters?.matches(dottedClassName) == false) { return false } diff --git a/report-generator/src/test/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilterTest.kt b/report-generator/src/test/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilterTest.kt index b630183e2..d8bd07113 100644 --- a/report-generator/src/test/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilterTest.kt +++ b/report-generator/src/test/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilterTest.kt @@ -5,12 +5,11 @@ import org.junit.jupiter.api.Test class ClasspathWildcardIncludeFilterTest { - /** Patterns must match the class name with and without leading `*`, given in internal or dotted form. */ + /** Patterns must match the class name with and without leading `*`. */ @Test fun testIncludes() { val filter = ClasspathWildcardIncludeFilter("com.company.*", null) assertThat(filter.isIncluded("com/company/Foo")).isTrue() - assertThat(filter.isIncluded("com.company.Foo")).isTrue() assertThat(filter.isIncluded("com/company/sub/Foo")).isTrue() assertThat(filter.isIncluded("org/company/Foo")).isFalse() assertThat(filter.isIncluded("org/com/company/Foo")).isFalse() From d49173e72cd97f66b46fe0ac1c1e41697578ed52 Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Mon, 26 Oct 2026 10:38:52 +0100 Subject: [PATCH 06/15] TS-48007 Shorten the changelog entries Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index adc370e89..22b98c14e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,8 @@ We use [semantic versioning](http://semver.org/): - PATCH version when you make backwards compatible bug fixes. # Next version -- [breaking] _agent_, _teamscale-maven-plugin_: `includes` and `excludes` no longer match parts of the class file's path outside of its package (e.g. `*target.classes.*`), and nested classes are matched with `$` (e.g. `com.company.Outer$Inner`) instead of `.`, like JaCoCo does during instrumentation. -- [fix] _agent_, _teamscale-maven-plugin_: `includes` and `excludes` are now matched against the class name read from the class file when generating coverage reports. Previously, they were matched against a name derived from the file location, so a pattern without a leading `*` (e.g. `com.company.*`) excluded all classes from class directories and from `BOOT-INF/classes` or `WEB-INF/classes` in archives, which resulted in an empty testwise coverage report, and `excludes` had no effect for these classes. +- [breaking] _agent_, _teamscale-maven-plugin_: `includes` and `excludes` only match the class name, no longer parts of the class file's path (e.g. `*target.classes.*`). Nested classes are written with `$` (e.g. `com.company.Outer$Inner`). +- [fix] _agent_, _teamscale-maven-plugin_: `includes` and `excludes` patterns no longer need a leading `*`. Previously, e.g. `com.company.*` led to an empty testwise coverage report. - [fix] _teamscale-maven-plugin_: `prepare-tia-unit-test` and `prepare-tia-integration-test` no longer require a commit or revision when `runImpacted` is `false`. - [fix] _teamscale-gradle-plugin_, _teamscale-maven-plugin_: Testwise coverage reports no longer drop a class directory that contains an archive (JAR, WAR, EAR, ZIP) that contains a JAR. From ac81d22331a163c071f549160e5595a4aa012c52 Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Mon, 26 Oct 2026 11:44:43 +0100 Subject: [PATCH 07/15] TS-48007 Let JaCoCo walk the entries of JARs analyzeJar copied JaCoCo's ZIP handling only to skip a shell script in front of executable JARs. It now hands the wrapped stream to JaCoCo's analyzeAll, which calls back into our analyzeAll for every entry. This also picks up JaCoCo's handling of the IllegalArgumentException that JDKs below 23 throw for malformed ZIP entries (JDK-8321156), which the copy lacked. Co-Authored-By: Claude Opus 5.5 --- .../report/jacoco/FilteringAnalyzer.kt | 24 ++++--------------- .../report/jacoco/FilteringAnalyzerTest.kt | 10 ++++++++ 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt index 7e1e3259d..f512ff611 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt @@ -10,8 +10,6 @@ import org.jacoco.core.data.ExecutionDataStore import org.jacoco.core.internal.instr.InstrSupport import java.io.IOException import java.io.InputStream -import java.util.zip.ZipEntry -import java.util.zip.ZipInputStream /** * [Analyzer] that filters the analyzed class files by their class name. @@ -90,24 +88,10 @@ open class FilteringAnalyzer( this is IllegalArgumentException && message?.startsWith("Unsupported") == true /** - * Copied from [org.jacoco.core.analysis.Analyzer.analyzeZip] renamed to analyzeJar - * and added wrapping [BashFileSkippingInputStream]. + * Analyzes a JAR, skipping a shell script in front of it (e.g. Spring Boot executable JARs) that would otherwise + * keep JaCoCo from detecting the ZIP format. */ @Throws(IOException::class) - protected open fun analyzeJar(input: InputStream, location: String): Int { - val zip = ZipInputStream(BashFileSkippingInputStream(input)) - return generateSequence { zip.nextEntry(location) } - .map { entry -> analyzeAll(zip, "$location@${entry.name}") } - .sum() - } - - /** Copied from [org.jacoco.core.analysis.Analyzer.nextEntry]. */ - @Throws(IOException::class) - private fun ZipInputStream.nextEntry(location: String): ZipEntry? { - try { - return nextEntry - } catch (e: IOException) { - throw analyzerError(location, e) - } - } + protected open fun analyzeJar(input: InputStream, location: String) = + super.analyzeAll(BashFileSkippingInputStream(input), location) } diff --git a/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt b/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt index 5fb87d29e..21baa1849 100644 --- a/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt +++ b/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt @@ -57,6 +57,16 @@ class FilteringAnalyzerTest { .containsExactly("com/teamscale/report/jacoco/FilteringAnalyzerTest\$Nested") } + /** A JAR with a shell script in front, e.g. a Spring Boot executable JAR, must be analyzed like a plain JAR. */ + @Test + fun analyzesExecutableJars() { + tempDir.resolve("app.jar").write(classBytes("/com/teamscale/report/util/spring-boot-executable-example.jar")) + + assertThat(analyze(ClasspathWildcardIncludeFilter("org.springframework.boot.loader.jar.*", null))) + .contains("org/springframework/boot/loader/jar/Bytes") + .allMatch { it.startsWith("org/springframework/boot/loader/jar/") } + } + /** A class file too new for JaCoCo must be logged and skipped instead of failing the whole report. */ @Test fun skipsUnsupportedClassFiles() { From b0516a77550ef30fb8269faaf78c92f69c65e4cb Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Tue, 27 Oct 2026 09:48:08 +0100 Subject: [PATCH 08/15] TS-48007 Report unreadable archives with the cause and a hint again Since OpenAnalyzer is gone, errors raised by JaCoCo itself (e.g. a corrupt ZIP) only said "Error while analyzing with JaCoCo ." analyzeAll now replaces them by our error, which includes the message of the cause and a hint how to fix it. Our errors are passed on unchanged to the archives that contain their location, so they are not wrapped once per nesting level. Co-Authored-By: Claude Opus 5.5 --- .../report/jacoco/FilteringAnalyzer.kt | 18 ++++++++++++++---- .../report/jacoco/FilteringAnalyzerTest.kt | 17 +++++++++++++++++ .../jacoco/JaCoCoXmlReportGeneratorTest.kt | 4 ++-- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt index f512ff611..9b2def598 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt @@ -28,10 +28,17 @@ open class FilteringAnalyzer( /** {@inheritDoc} */ @Throws(IOException::class) override fun analyzeAll(input: InputStream, location: String): Int { - if (location.endsWith(".jar")) { - return analyzeJar(input, location) + try { + if (location.endsWith(".jar")) { + return analyzeJar(input, location) + } + return super.analyzeAll(input, location) + } catch (e: AnalyzerException) { + throw e + } catch (e: IOException) { + // JaCoCo's error for this location (e.g. a corrupt ZIP) lacks the message of its cause and a hint + throw analyzerError(location, e.cause as? Exception ?: e) } - return super.analyzeAll(input, location) } @Throws(IOException::class) @@ -72,13 +79,16 @@ open class FilteringAnalyzer( } /** Creates an [IOException] which includes the affected file location and JaCoCo version. */ - protected fun analyzerError(location: String, cause: Exception) = IOException( + protected fun analyzerError(location: String, cause: Exception): IOException = AnalyzerException( "Could not analyze coverage for '$location' (JaCoCo ${JaCoCo.VERSION}/${JaCoCo.COMMITID_SHORT}): " + "${cause.message}. Verify that the file is a readable .class, JAR, ZIP, GZ, or Pack200 archive" + " and that it is not corrupt.", cause ) + /** Error of [analyzerError], which [analyzeAll] passes on unchanged to the locations that contain its location. */ + private class AnalyzerException(message: String, cause: Exception) : IOException(message, cause) + /** * Checks if the error indicates that the class file might be newer than what is currently supported by * JaCoCo. The concrete error message seems to depend on the used JVM, so we only check for "Unsupported" which seems diff --git a/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt b/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt index 21baa1849..ece5580e2 100644 --- a/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt +++ b/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt @@ -3,6 +3,7 @@ package com.teamscale.report.jacoco import com.teamscale.report.util.ClasspathWildcardIncludeFilter import com.teamscale.report.util.ILogger import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy import org.jacoco.core.data.ExecutionDataStore import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir @@ -10,7 +11,9 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource import org.mockito.Mockito import java.io.ByteArrayOutputStream +import java.io.EOFException import java.io.File +import java.io.IOException import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream @@ -83,6 +86,20 @@ class FilteringAnalyzerTest { Mockito.verify(logger).error(Mockito.contains("Unsupported class file"), Mockito.any(Throwable::class.java)) } + /** An archive that JaCoCo cannot read must be reported with its location, the cause and a hint. */ + @Test + fun reportsUnreadableArchives() { + val jar = zip("com/teamscale/report/jacoco/FilteringAnalyzerTest.class") + // Cuts the JAR in the middle of the class file's data + tempDir.resolve("app.jar").write(jar.copyOf(jar.size / 2)) + + assertThatThrownBy { analyze(ClasspathWildcardIncludeFilter(null, null)) } + .isInstanceOf(IOException::class.java) + .hasMessageContaining("app.jar@com/teamscale/report/jacoco/FilteringAnalyzerTest.class") + .hasMessageContaining("Verify that the file is a readable") + .hasCauseInstanceOf(EOFException::class.java) + } + private fun analyze(filter: ClasspathWildcardIncludeFilter, logger: ILogger = Mockito.mock()): List { val analyzedClasses = mutableListOf() FilteringAnalyzer(ExecutionDataStore(), { analyzedClasses.add(it.name) }, filter, logger).analyzeAll(tempDir) diff --git a/report-generator/src/test/kotlin/com/teamscale/report/jacoco/JaCoCoXmlReportGeneratorTest.kt b/report-generator/src/test/kotlin/com/teamscale/report/jacoco/JaCoCoXmlReportGeneratorTest.kt index 65bd154a0..1ab8f5357 100644 --- a/report-generator/src/test/kotlin/com/teamscale/report/jacoco/JaCoCoXmlReportGeneratorTest.kt +++ b/report-generator/src/test/kotlin/com/teamscale/report/jacoco/JaCoCoXmlReportGeneratorTest.kt @@ -57,7 +57,7 @@ class JaCoCoXmlReportGeneratorTest : TestDataBase() { "different-duplicate-classes", EDuplicateClassFileBehavior.FAIL ) - }.isExactlyInstanceOf(IOException::class.java) + }.isInstanceOf(IOException::class.java) .hasCauseExactlyInstanceOf(IllegalStateException::class.java) } @@ -105,7 +105,7 @@ class JaCoCoXmlReportGeneratorTest : TestDataBase() { "different-duplicate-classes", EDuplicateClassFileBehavior.FAIL ) - }.isExactlyInstanceOf(IOException::class.java) + }.isInstanceOf(IOException::class.java) .hasCauseExactlyInstanceOf(IllegalStateException::class.java) } From 6dd4aee00fef86ea0355dd3bb7c846e6617be6a3 Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Tue, 27 Oct 2026 09:48:22 +0100 Subject: [PATCH 09/15] TS-48007 Reuse the filter's ClassReader in AnalyzerCache analyzeClass already creates a ClassReader to read the class name for the filter. analyzeIncludedClass now receives it, so AnalyzerCache no longer parses the class header a second time. FilteringAnalyzer itself cannot reuse it, because JaCoCo's analyzeClass only takes the bytes. Co-Authored-By: Claude Opus 5.5 --- .../teamscale/report/jacoco/FilteringAnalyzer.kt | 14 ++++++++------ .../report/testwise/jacoco/cache/AnalyzerCache.kt | 5 ++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt index 9b2def598..598c18998 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt @@ -8,6 +8,7 @@ import org.jacoco.core.analysis.Analyzer import org.jacoco.core.analysis.ICoverageVisitor import org.jacoco.core.data.ExecutionDataStore import org.jacoco.core.internal.instr.InstrSupport +import org.objectweb.asm.ClassReader import java.io.IOException import java.io.InputStream @@ -45,12 +46,12 @@ open class FilteringAnalyzer( override fun analyzeClass(buffer: ByteArray, location: String) { try { // Only reads the constant pool, which is cheap compared to the analysis of the whole class - val className = InstrSupport.classReaderFor(buffer).className - if (!locationIncludeFilter.isIncluded(className)) { - logger.debug("Excluding class file $location ($className)") + val reader = InstrSupport.classReaderFor(buffer) + if (!locationIncludeFilter.isIncluded(reader.className)) { + logger.debug("Excluding class file $location (${reader.className})") return } - analyzeIncludedClass(buffer, location) + analyzeIncludedClass(buffer, reader, location) } catch (cause: RuntimeException) { if (cause.isUnsupportedClassFile()) { logger.error( @@ -66,10 +67,11 @@ open class FilteringAnalyzer( } /** - * Analyzes a class that passed the filter. Errors are thrown as [RuntimeException], so that [analyzeClass] reports + * Analyzes a class that passed the filter. [reader] is the filter's reader of [buffer], which JaCoCo's analysis + * cannot take, so only subclasses reuse it. Errors are thrown as [RuntimeException], so that [analyzeClass] reports * them uniformly. */ - protected open fun analyzeIncludedClass(buffer: ByteArray, location: String) { + protected open fun analyzeIncludedClass(buffer: ByteArray, reader: ClassReader, location: String) { try { super.analyzeClass(buffer, location) } catch (e: IOException) { diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt index 1fc0f9bdc..bab6dbe7d 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt @@ -8,7 +8,7 @@ import org.jacoco.core.internal.analysis.ClassCoverageImpl import org.jacoco.core.internal.analysis.StringPool import org.jacoco.core.internal.data.CRC64 import org.jacoco.core.internal.flow.ClassProbesAdapter -import org.jacoco.core.internal.instr.InstrSupport +import org.objectweb.asm.ClassReader import java.io.File import java.io.IOException import java.io.InputStream @@ -31,12 +31,11 @@ class AnalyzerCache( * Analyzes the given class. Instead of the original implementation in [FilteringAnalyzer.analyzeIncludedClass] we * don't use concrete execution data, but instead build a probe cache to speed up repeated lookups. */ - override fun analyzeIncludedClass(buffer: ByteArray, location: String) { + override fun analyzeIncludedClass(buffer: ByteArray, reader: ClassReader, location: String) { val classId = CRC64.classId(buffer) if (probesCache.containsClassId(classId)) { return } - val reader = InstrSupport.classReaderFor(buffer) // Dummy class coverage object that allows us to subclass ClassAnalyzer with CachingClassAnalyzer and reuse its // IFilterContext implementation From 8bfb59fc4c99a7dcae5eba2b8bf0ec9c9d2a8c46 Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Tue, 27 Oct 2026 09:48:48 +0100 Subject: [PATCH 10/15] TS-48007 Show the message of the error for duplicate classes error { ... } passed the lambda to error(Any), so the message of the error for a duplicate class with duplicates=FAIL was the lambda's name, e.g. "JaCoCoBasedReportGenerator$EnhancedCoverageVisitor$$Lambda/0x...@c730e65". Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../com/teamscale/report/jacoco/JaCoCoBasedReportGenerator.kt | 4 ++-- .../teamscale/report/jacoco/JaCoCoXmlReportGeneratorTest.kt | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22b98c14e..228ff0376 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ We use [semantic versioning](http://semver.org/): - [fix] _agent_, _teamscale-maven-plugin_: `includes` and `excludes` patterns no longer need a leading `*`. Previously, e.g. `com.company.*` led to an empty testwise coverage report. - [fix] _teamscale-maven-plugin_: `prepare-tia-unit-test` and `prepare-tia-integration-test` no longer require a commit or revision when `runImpacted` is `false`. - [fix] _teamscale-gradle-plugin_, _teamscale-maven-plugin_: Testwise coverage reports no longer drop a class directory that contains an archive (JAR, WAR, EAR, ZIP) that contains a JAR. +- [fix] _agent_: With `duplicates=FAIL`, the error for a duplicate, non-identical class now names the class and how to resolve the problem. # 39.0.0 - [breaking] _agent_: The deprecated Docker image `cqse/teamscale-jacoco-agent` is no longer published. Use `cqse/teamscale-java-profiler` instead, which has replaced it since version 36.5.0. diff --git a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/JaCoCoBasedReportGenerator.kt b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/JaCoCoBasedReportGenerator.kt index 5ce800429..a6fee2ea0 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/JaCoCoBasedReportGenerator.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/JaCoCoBasedReportGenerator.kt @@ -155,10 +155,10 @@ abstract class JaCoCoBasedReportGenerator( return } - EDuplicateClassFileBehavior.FAIL -> error { + EDuplicateClassFileBehavior.FAIL -> error( "Duplicate non-identical class '${coverage.name}' encountered with 'duplicates=FAIL'." + " Either resolve the duplicate in your application or set 'duplicates=WARN'." - } + ) } } } diff --git a/report-generator/src/test/kotlin/com/teamscale/report/jacoco/JaCoCoXmlReportGeneratorTest.kt b/report-generator/src/test/kotlin/com/teamscale/report/jacoco/JaCoCoXmlReportGeneratorTest.kt index 1ab8f5357..0ce2ca333 100644 --- a/report-generator/src/test/kotlin/com/teamscale/report/jacoco/JaCoCoXmlReportGeneratorTest.kt +++ b/report-generator/src/test/kotlin/com/teamscale/report/jacoco/JaCoCoXmlReportGeneratorTest.kt @@ -59,6 +59,7 @@ class JaCoCoXmlReportGeneratorTest : TestDataBase() { ) }.isInstanceOf(IOException::class.java) .hasCauseExactlyInstanceOf(IllegalStateException::class.java) + .hasMessageContaining("Duplicate non-identical class") } /** @@ -107,6 +108,7 @@ class JaCoCoXmlReportGeneratorTest : TestDataBase() { ) }.isInstanceOf(IOException::class.java) .hasCauseExactlyInstanceOf(IllegalStateException::class.java) + .hasMessageContaining("Duplicate non-identical class") } /** From 1939491ee9d9b8b271ca19cb08afffde46ae99da Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Tue, 27 Oct 2026 11:16:10 +0100 Subject: [PATCH 11/15] TS-48007 Skip class files whose class name cannot be read The filter needs the class name, so every class file's header is read before filtering, while the path-based filter never read excluded class files. A corrupt class file therefore failed the whole analysis even if it was excluded. Since its class name is unknown, it is now logged and skipped. Class files of unsupported Java versions are still reported as before. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../report/jacoco/FilteringAnalyzer.kt | 28 ++++++++++++++++--- .../report/jacoco/FilteringAnalyzerTest.kt | 16 +++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 228ff0376..2f21c9988 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ We use [semantic versioning](http://semver.org/): - [fix] _teamscale-maven-plugin_: `prepare-tia-unit-test` and `prepare-tia-integration-test` no longer require a commit or revision when `runImpacted` is `false`. - [fix] _teamscale-gradle-plugin_, _teamscale-maven-plugin_: Testwise coverage reports no longer drop a class directory that contains an archive (JAR, WAR, EAR, ZIP) that contains a JAR. - [fix] _agent_: With `duplicates=FAIL`, the error for a duplicate, non-identical class now names the class and how to resolve the problem. +- [fix] _agent_, _teamscale-gradle-plugin_, _teamscale-maven-plugin_: A corrupt class file whose class name cannot be read is skipped with a warning instead of failing the coverage report. # 39.0.0 - [breaking] _agent_: The deprecated Docker image `cqse/teamscale-jacoco-agent` is no longer published. Use `cqse/teamscale-java-profiler` instead, which has replaced it since version 36.5.0. diff --git a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt index 598c18998..ea60d910b 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt @@ -45,10 +45,9 @@ open class FilteringAnalyzer( @Throws(IOException::class) override fun analyzeClass(buffer: ByteArray, location: String) { try { - // Only reads the constant pool, which is cheap compared to the analysis of the whole class - val reader = InstrSupport.classReaderFor(buffer) - if (!locationIncludeFilter.isIncluded(reader.className)) { - logger.debug("Excluding class file $location (${reader.className})") + val (reader, className) = readHeader(buffer, location) ?: return + if (!locationIncludeFilter.isIncluded(className)) { + logger.debug("Excluding class file $location ($className)") return } analyzeIncludedClass(buffer, reader, location) @@ -66,6 +65,27 @@ open class FilteringAnalyzer( } } + /** + * Creates a reader for the class file and reads its class name. This only reads the constant pool, which is cheap + * compared to the analysis of the whole class. A corrupt class file is logged and skipped by returning `null` instead + * of failing the analysis: without its class name, we cannot tell whether it is excluded. + */ + private fun readHeader(buffer: ByteArray, location: String): Pair? { + try { + val reader = InstrSupport.classReaderFor(buffer) + return reader to reader.className + } catch (cause: RuntimeException) { + if (cause.isUnsupportedClassFile()) { + throw cause + } + logger.warn( + "Skipping class file '$location', whose class name cannot be read ($cause). The file is probably corrupt.", + cause + ) + return null + } + } + /** * Analyzes a class that passed the filter. [reader] is the filter's reader of [buffer], which JaCoCo's analysis * cannot take, so only subclasses reuse it. Errors are thrown as [RuntimeException], so that [analyzeClass] reports diff --git a/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt b/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt index ece5580e2..a0d9e0cc0 100644 --- a/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt +++ b/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt @@ -86,6 +86,22 @@ class FilteringAnalyzerTest { Mockito.verify(logger).error(Mockito.contains("Unsupported class file"), Mockito.any(Throwable::class.java)) } + /** + * A class file whose class name cannot be read must be logged and skipped instead of failing the whole report, + * since it may be excluded. + */ + @Test + fun skipsCorruptClassFiles() { + // Cuts the class file in its constant pool, which holds the class name + tempDir.resolve("Corrupt.class").write(classBytes.copyOf(40)) + tempDir.resolve("Supported.class").write(classBytes) + val logger = Mockito.mock() + + assertThat(analyze(ClasspathWildcardIncludeFilter(null, null), logger)) + .containsExactly("com/teamscale/report/jacoco/FilteringAnalyzerTest") + Mockito.verify(logger).warn(Mockito.contains("Corrupt.class"), Mockito.any(Throwable::class.java)) + } + /** An archive that JaCoCo cannot read must be reported with its location, the cause and a hint. */ @Test fun reportsUnreadableArchives() { From b925a76a8ff7834216dd5a8cb40c94b199ae2102 Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Tue, 27 Oct 2026 12:48:23 +0100 Subject: [PATCH 12/15] TS-48007 Describe class name matching in the convert command's help The help of --includes and --excludes still described the matching against the class file's location, e.g. that ZIP contents are separated with @. Co-Authored-By: Claude Opus 5.5 --- .../jacoco/agent/convert/ConvertCommand.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/ConvertCommand.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/ConvertCommand.kt index 4dbc3b85c..e204f6284 100644 --- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/ConvertCommand.kt +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/ConvertCommand.kt @@ -38,10 +38,10 @@ class ConvertCommand : ICommand { */ @Parameter( names = ["--includes"], description = ("" - + "Wildcard include patterns to apply to all found class file locations during JaCoCo's traversal of class files." - + " Note that zip contents are separated from zip files with @ and that you can filter only" - + " class files, not intermediate folders/zips. Use with great care as missing class files" - + " lead to broken coverage files! Turn on debug logging to see which locations are being filtered." + + "Wildcard include patterns for the fully qualified names of the analyzed classes (e.g. com.company.*)." + + " Nested classes are written with \$ (e.g. com.company.Outer\$Inner). Use with great care as missing" + + " class files lead to broken coverage files! Turn on debug logging to see which class files are" + + " being filtered." + " Defaults to no filtering. Excludes overrule includes.") ) var locationIncludeFilters = mutableListOf() @@ -51,10 +51,10 @@ class ConvertCommand : ICommand { */ @Parameter( names = ["--excludes", "-e"], description = ("" - + "Wildcard exclude patterns to apply to all found class file locations during JaCoCo's traversal of class files." - + " Note that zip contents are separated from zip files with @ and that you can filter only" - + " class files, not intermediate folders/zips. Use with great care as missing class files" - + " lead to broken coverage files! Turn on debug logging to see which locations are being filtered." + + "Wildcard exclude patterns for the fully qualified names of the analyzed classes (e.g. com.company.*)." + + " Nested classes are written with \$ (e.g. com.company.Outer\$Inner). Use with great care as missing" + + " class files lead to broken coverage files! Turn on debug logging to see which class files are" + + " being filtered." + " Defaults to no filtering. Excludes overrule includes.") ) var locationExcludeFilters = mutableListOf() From fcccb9b9043f71f608e2100cbde9cb773b0240be Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Tue, 27 Oct 2026 12:48:23 +0100 Subject: [PATCH 13/15] TS-48007 Skip excluded class files of unsupported Java versions silently ASM rejects a class file of an unsupported Java version before its class name is known, so such a class file was reported as an error even if it was excluded, while the error recommends to exclude it. Its class name is now read from a copy with a supported version, so that excluded ones are skipped. FilteringAnalyzerTest now names the locations that were filtered incorrectly and shares its ZIP helper with AnalyzerCacheTest. Co-Authored-By: Claude Opus 5.5 --- .../report/jacoco/FilteringAnalyzer.kt | 35 ++++++++- .../report/jacoco/FilteringAnalyzerTest.kt | 74 +++++++++++-------- .../kotlin/com/teamscale/test/ZipTestUtils.kt | 18 +++++ 3 files changed, 92 insertions(+), 35 deletions(-) create mode 100644 report-generator/src/test/kotlin/com/teamscale/test/ZipTestUtils.kt diff --git a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt index ea60d910b..b597c4379 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt @@ -9,6 +9,7 @@ import org.jacoco.core.analysis.ICoverageVisitor import org.jacoco.core.data.ExecutionDataStore import org.jacoco.core.internal.instr.InstrSupport import org.objectweb.asm.ClassReader +import org.objectweb.asm.Opcodes import java.io.IOException import java.io.InputStream @@ -46,13 +47,15 @@ open class FilteringAnalyzer( override fun analyzeClass(buffer: ByteArray, location: String) { try { val (reader, className) = readHeader(buffer, location) ?: return - if (!locationIncludeFilter.isIncluded(className)) { - logger.debug("Excluding class file $location ($className)") - return + if (isIncluded(className, location)) { + analyzeIncludedClass(buffer, reader, location) } - analyzeIncludedClass(buffer, reader, location) } catch (cause: RuntimeException) { if (cause.isUnsupportedClassFile()) { + val className = readClassNameOfUnsupportedClassFile(buffer) + if (className != null && !isIncluded(className, location)) { + return + } logger.error( "Unsupported class file in '$location': ${cause.message}." + " This is usually a class file compiled for a Java version newer than the profiler supports" + @@ -86,6 +89,30 @@ open class FilteringAnalyzer( } } + /** + * Reads the class name of a class file whose version ASM does not support from a copy with a supported version, so + * that such a class file can still be excluded. Returns `null` if the class name cannot be read anyway, e.g. + * because the class file uses constant pool entries that ASM does not know. + */ + private fun readClassNameOfUnsupportedClassFile(buffer: ByteArray): String? { + val supportedCopy = buffer.copyOf() + InstrSupport.setMajorVersion(Opcodes.V1_8, supportedCopy) + return try { + ClassReader(supportedCopy).className + } catch (_: RuntimeException) { + null + } + } + + /** Tests if the class with the given JVM internal name passes the filter and logs if it does not. */ + private fun isIncluded(className: String, location: String): Boolean { + if (locationIncludeFilter.isIncluded(className)) { + return true + } + logger.debug("Excluding class file $location ($className)") + return false + } + /** * Analyzes a class that passed the filter. [reader] is the filter's reader of [buffer], which JaCoCo's analysis * cannot take, so only subclasses reuse it. Errors are thrown as [RuntimeException], so that [analyzeClass] reports diff --git a/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt b/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt index a0d9e0cc0..1a7ac225c 100644 --- a/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt +++ b/report-generator/src/test/kotlin/com/teamscale/report/jacoco/FilteringAnalyzerTest.kt @@ -2,6 +2,7 @@ package com.teamscale.report.jacoco import com.teamscale.report.util.ClasspathWildcardIncludeFilter import com.teamscale.report.util.ILogger +import com.teamscale.test.zip import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy import org.jacoco.core.data.ExecutionDataStore @@ -10,12 +11,9 @@ import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource import org.mockito.Mockito -import java.io.ByteArrayOutputStream import java.io.EOFException import java.io.File import java.io.IOException -import java.util.zip.ZipEntry -import java.util.zip.ZipOutputStream /** Tests that [FilteringAnalyzer] filters class files by their class name, independent of their location. */ class FilteringAnalyzerTest { @@ -23,7 +21,7 @@ class FilteringAnalyzerTest { @TempDir private lateinit var tempDir: File - private val classBytes = classBytes("FilteringAnalyzerTest.class") + private val classBytes = resourceBytes("FilteringAnalyzerTest.class") /** * The location of a class file must not influence whether it is included: class directories may contain further @@ -40,21 +38,25 @@ class FilteringAnalyzerTest { "com.teamscale.*, com.teamscale.report.jacoco.*, false", ) fun filtersByClassNameIndependentOfLocation(includes: String?, excludes: String?, expectedIncluded: Boolean) { - tempDir.resolve("build/classes/main/com/teamscale/report/jacoco/FilteringAnalyzerTest.class").write(classBytes) - tempDir.resolve("flat/FilteringAnalyzerTest.class").write(classBytes) - tempDir.resolve("app.jar").write(zip("BOOT-INF/classes/com/teamscale/report/jacoco/FilteringAnalyzerTest.class")) - tempDir.resolve("app.war").write(zip("WEB-INF/classes/com/teamscale/report/jacoco/FilteringAnalyzerTest.class")) - - val analyzedClasses = analyze(ClasspathWildcardIncludeFilter(includes, excludes)) - - assertThat(analyzedClasses).hasSize(if (expectedIncluded) 4 else 0) + val locations = mapOf( + "build/classes/main/com/teamscale/report/jacoco/FilteringAnalyzerTest.class" to classBytes, + "flat/FilteringAnalyzerTest.class" to classBytes, + "app.jar" to zip("BOOT-INF/classes/com/teamscale/report/jacoco/FilteringAnalyzerTest.class" to classBytes), + "app.war" to zip("WEB-INF/classes/com/teamscale/report/jacoco/FilteringAnalyzerTest.class" to classBytes), + ) + locations.forEach { (path, content) -> tempDir.resolve(path).write(content) } + val filter = ClasspathWildcardIncludeFilter(includes, excludes) + + val includedLocations = locations.keys.filter { analyze(filter, file = tempDir.resolve(it)).isNotEmpty() } + + assertThat(includedLocations).isEqualTo(if (expectedIncluded) locations.keys.toList() else emptyList()) } /** Like JaCoCo, nested classes are matched with their `$`. */ @Test fun matchesNestedClassesWithDollar() { tempDir.resolve("Outer.class").write(classBytes) - tempDir.resolve("Nested.class").write(classBytes("FilteringAnalyzerTest\$Nested.class")) + tempDir.resolve("Nested.class").write(resourceBytes("FilteringAnalyzerTest\$Nested.class")) assertThat(analyze(ClasspathWildcardIncludeFilter("com.teamscale.report.jacoco.FilteringAnalyzerTest$*", null))) .containsExactly("com/teamscale/report/jacoco/FilteringAnalyzerTest\$Nested") @@ -63,7 +65,7 @@ class FilteringAnalyzerTest { /** A JAR with a shell script in front, e.g. a Spring Boot executable JAR, must be analyzed like a plain JAR. */ @Test fun analyzesExecutableJars() { - tempDir.resolve("app.jar").write(classBytes("/com/teamscale/report/util/spring-boot-executable-example.jar")) + tempDir.resolve("app.jar").write(resourceBytes("/com/teamscale/report/util/spring-boot-executable-example.jar")) assertThat(analyze(ClasspathWildcardIncludeFilter("org.springframework.boot.loader.jar.*", null))) .contains("org/springframework/boot/loader/jar/Bytes") @@ -73,11 +75,7 @@ class FilteringAnalyzerTest { /** A class file too new for JaCoCo must be logged and skipped instead of failing the whole report. */ @Test fun skipsUnsupportedClassFiles() { - val futureClassBytes = classBytes.copyOf() - // Bytes 6 and 7 hold the class file's major version - futureClassBytes[6] = 0x7F - futureClassBytes[7] = 0x7F - tempDir.resolve("Future.class").write(futureClassBytes) + tempDir.resolve("Future.class").write(withUnsupportedVersion(classBytes)) tempDir.resolve("Supported.class").write(classBytes) val logger = Mockito.mock() @@ -86,6 +84,17 @@ class FilteringAnalyzerTest { Mockito.verify(logger).error(Mockito.contains("Unsupported class file"), Mockito.any(Throwable::class.java)) } + /** A class file too new for JaCoCo must be skipped without an error if it is excluded, as the error recommends. */ + @Test + fun skipsExcludedUnsupportedClassFilesWithoutError() { + tempDir.resolve("Future.class").write(withUnsupportedVersion(classBytes)) + val logger = Mockito.mock() + + assertThat(analyze(ClasspathWildcardIncludeFilter(null, "com.teamscale.*"), logger)).isEmpty() + Mockito.verify(logger).debug(Mockito.contains("Excluding class file")) + Mockito.verifyNoMoreInteractions(logger) + } + /** * A class file whose class name cannot be read must be logged and skipped instead of failing the whole report, * since it may be excluded. @@ -105,7 +114,7 @@ class FilteringAnalyzerTest { /** An archive that JaCoCo cannot read must be reported with its location, the cause and a hint. */ @Test fun reportsUnreadableArchives() { - val jar = zip("com/teamscale/report/jacoco/FilteringAnalyzerTest.class") + val jar = zip("com/teamscale/report/jacoco/FilteringAnalyzerTest.class" to classBytes) // Cuts the JAR in the middle of the class file's data tempDir.resolve("app.jar").write(jar.copyOf(jar.size / 2)) @@ -116,9 +125,13 @@ class FilteringAnalyzerTest { .hasCauseInstanceOf(EOFException::class.java) } - private fun analyze(filter: ClasspathWildcardIncludeFilter, logger: ILogger = Mockito.mock()): List { + private fun analyze( + filter: ClasspathWildcardIncludeFilter, + logger: ILogger = Mockito.mock(), + file: File = tempDir + ): List { val analyzedClasses = mutableListOf() - FilteringAnalyzer(ExecutionDataStore(), { analyzedClasses.add(it.name) }, filter, logger).analyzeAll(tempDir) + FilteringAnalyzer(ExecutionDataStore(), { analyzedClasses.add(it.name) }, filter, logger).analyzeAll(file) return analyzedClasses } @@ -127,16 +140,15 @@ class FilteringAnalyzerTest { writeBytes(content) } - private fun classBytes(name: String) = javaClass.getResourceAsStream(name)!!.use { it.readBytes() } + private fun resourceBytes(name: String) = javaClass.getResourceAsStream(name)!!.use { it.readBytes() } - private fun zip(entryName: String): ByteArray { - val output = ByteArrayOutputStream() - ZipOutputStream(output).use { zip -> - zip.putNextEntry(ZipEntry(entryName)) - zip.write(classBytes) - zip.closeEntry() - } - return output.toByteArray() + /** Returns a copy of the given class file with a major version that is too new for JaCoCo. */ + private fun withUnsupportedVersion(classBytes: ByteArray): ByteArray { + val unsupportedClassBytes = classBytes.copyOf() + // Bytes 6 and 7 hold the class file's major version + unsupportedClassBytes[6] = 0x7F + unsupportedClassBytes[7] = 0x7F + return unsupportedClassBytes } /** A nested class to test the matching of nested class names. */ diff --git a/report-generator/src/test/kotlin/com/teamscale/test/ZipTestUtils.kt b/report-generator/src/test/kotlin/com/teamscale/test/ZipTestUtils.kt new file mode 100644 index 000000000..6cabd3d5d --- /dev/null +++ b/report-generator/src/test/kotlin/com/teamscale/test/ZipTestUtils.kt @@ -0,0 +1,18 @@ +package com.teamscale.test + +import java.io.ByteArrayOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** Creates a ZIP archive (e.g. a JAR or WAR) with the given entries, each given by its name and content. */ +fun zip(vararg entries: Pair): ByteArray { + val output = ByteArrayOutputStream() + ZipOutputStream(output).use { zip -> + entries.forEach { (name, content) -> + zip.putNextEntry(ZipEntry(name)) + zip.write(content) + zip.closeEntry() + } + } + return output.toByteArray() +} From a9fd9a86e3b4749d141c50a9aa20ce54f1cd177f Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Tue, 27 Oct 2026 12:48:23 +0100 Subject: [PATCH 14/15] TS-48007 Test the class name filter of the testwise coverage AnalyzerCache and ProbesCache were only tested with a filter that includes all classes. ClasspathWildcardIncludeFilterTest is now parameterized, so that a failure names the pattern and the class name. Co-Authored-By: Claude Opus 5.5 --- .../jacoco/cache/AnalyzerCacheTest.kt | 27 ++++---- .../testwise/jacoco/cache/ProbesCacheTest.kt | 29 ++++++++ .../ClasspathWildcardIncludeFilterTest.kt | 69 ++++++++----------- 3 files changed, 69 insertions(+), 56 deletions(-) create mode 100644 report-generator/src/test/kotlin/com/teamscale/report/testwise/jacoco/cache/ProbesCacheTest.kt diff --git a/report-generator/src/test/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCacheTest.kt b/report-generator/src/test/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCacheTest.kt index 556794a6c..64038f0a0 100644 --- a/report-generator/src/test/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCacheTest.kt +++ b/report-generator/src/test/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCacheTest.kt @@ -3,6 +3,7 @@ package com.teamscale.report.testwise.jacoco.cache import com.teamscale.report.EDuplicateClassFileBehavior import com.teamscale.report.util.ClasspathWildcardIncludeFilter import com.teamscale.report.util.ILogger +import com.teamscale.test.zip import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy import org.jacoco.core.internal.data.CRC64 @@ -11,11 +12,8 @@ import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource import org.mockito.Mockito -import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException -import java.util.zip.ZipEntry -import java.util.zip.ZipOutputStream /** Tests for the [AnalyzerCache] class. */ class AnalyzerCacheTest { @@ -44,6 +42,17 @@ class AnalyzerCacheTest { assertThat(probesCache.containsClassId(CRC64.classId(classBytes))).isTrue() } + /** Excluded classes must not be added to the cache. */ + @Test + fun skipsExcludedClasses() { + tempDir.resolve("AnalyzerCacheTest.class").writeBytes(classBytes) + val excludingAnalyzer = AnalyzerCache(probesCache, ClasspathWildcardIncludeFilter(null, "com.teamscale.*"), logger) + + excludingAnalyzer.analyzeAll(tempDir) + + assertThat(probesCache.containsClassId(CRC64.classId(classBytes))).isFalse() + } + /** A nested JAR that cannot be read must be reported with its location, so that users can find it. */ @Test fun reportsLocationOfUnreadableNestedJar() { @@ -55,16 +64,4 @@ class AnalyzerCacheTest { .isInstanceOf(IOException::class.java) .hasMessageContaining("outer.jar@lib/inner.jar") } - - private fun zip(vararg entries: Pair): ByteArray { - val output = ByteArrayOutputStream() - ZipOutputStream(output).use { zip -> - entries.forEach { (name, content) -> - zip.putNextEntry(ZipEntry(name)) - zip.write(content) - zip.closeEntry() - } - } - return output.toByteArray() - } } diff --git a/report-generator/src/test/kotlin/com/teamscale/report/testwise/jacoco/cache/ProbesCacheTest.kt b/report-generator/src/test/kotlin/com/teamscale/report/testwise/jacoco/cache/ProbesCacheTest.kt new file mode 100644 index 000000000..cc6d70911 --- /dev/null +++ b/report-generator/src/test/kotlin/com/teamscale/report/testwise/jacoco/cache/ProbesCacheTest.kt @@ -0,0 +1,29 @@ +package com.teamscale.report.testwise.jacoco.cache + +import com.teamscale.report.EDuplicateClassFileBehavior +import com.teamscale.report.util.ClasspathWildcardIncludeFilter +import com.teamscale.report.util.ILogger +import org.assertj.core.api.Assertions.assertThat +import org.jacoco.core.data.ExecutionData +import org.junit.jupiter.api.Test +import org.mockito.Mockito + +/** Tests for the [ProbesCache] class. */ +class ProbesCacheTest { + + private val logger = Mockito.mock() + private val probesCache = ProbesCache(logger, EDuplicateClassFileBehavior.IGNORE) + + /** Coverage of classes that were not analyzed must only be reported as missing if the classes are included. */ + @Test + fun reportsMissingClassesOnlyIfIncluded() { + val filter = ClasspathWildcardIncludeFilter("com.company.*", null) + + assertThat(probesCache.getCoverage(ExecutionData(1, "com/company/Included", 1), filter)).isNull() + assertThat(probesCache.getCoverage(ExecutionData(2, "org/other/Excluded", 1), filter)).isNull() + probesCache.flushLogger() + + Mockito.verify(logger).warn(" - com.company.Included") + Mockito.verify(logger, Mockito.never()).warn(Mockito.contains("org.other.Excluded")) + } +} diff --git a/report-generator/src/test/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilterTest.kt b/report-generator/src/test/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilterTest.kt index d8bd07113..b16c0cee1 100644 --- a/report-generator/src/test/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilterTest.kt +++ b/report-generator/src/test/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilterTest.kt @@ -1,49 +1,36 @@ package com.teamscale.report.util import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource class ClasspathWildcardIncludeFilterTest { - /** Patterns must match the class name with and without leading `*`. */ - @Test - fun testIncludes() { - val filter = ClasspathWildcardIncludeFilter("com.company.*", null) - assertThat(filter.isIncluded("com/company/Foo")).isTrue() - assertThat(filter.isIncluded("com/company/sub/Foo")).isTrue() - assertThat(filter.isIncluded("org/company/Foo")).isFalse() - assertThat(filter.isIncluded("org/com/company/Foo")).isFalse() - - val filterWithLeadingStar = ClasspathWildcardIncludeFilter("*com.company.*", null) - assertThat(filterWithLeadingStar.isIncluded("com/company/Foo")).isTrue() - assertThat(filterWithLeadingStar.isIncluded("org/com/company/Foo")).isTrue() - } - - @Test - fun testExcludes() { - val filter = ClasspathWildcardIncludeFilter(null, "org.junit.*") - assertThat(filter.isIncluded("org/junit/jupiter/engine/Constants")).isFalse() - assertThat(filter.isIncluded("com/company/Foo")).isTrue() - - val filter2 = ClasspathWildcardIncludeFilter("com.company.*", "com.company.generated.*") - assertThat(filter2.isIncluded("com/company/Foo")).isTrue() - assertThat(filter2.isIncluded("com/company/generated/Foo")).isFalse() - } - - /** Like JaCoCo, nested classes keep their `$`. */ - @Test - fun testNestedClasses() { - assertThat(ClasspathWildcardIncludeFilter("com.company.Outer$*", null).isIncluded("com/company/Outer\$Inner")) - .isTrue() - assertThat(ClasspathWildcardIncludeFilter("com.company.Outer.Inner", null).isIncluded("com/company/Outer\$Inner")) - .isFalse() - } - - @Test - fun testMultiplePatterns() { - val filter = ClasspathWildcardIncludeFilter("com.*:de.*", null) - assertThat(filter.isIncluded("com/foo/Bar")).isTrue() - assertThat(filter.isIncluded("de/foo/Bar\$Goo")).isTrue() - assertThat(filter.isIncluded("org/foo/Bar")).isFalse() + /** Tests the include and exclude patterns against JVM internal class names. */ + @ParameterizedTest + @CsvSource( + // Patterns must match the class name with and without leading `*` + "com.company.*, , com/company/Foo, true", + "com.company.*, , com/company/sub/Foo, true", + "com.company.*, , org/company/Foo, false", + "com.company.*, , org/com/company/Foo, false", + "*com.company.*, , com/company/Foo, true", + "*com.company.*, , org/com/company/Foo, true", + // Excludes overrule includes + ", org.junit.*, org/junit/jupiter/engine/Constants, false", + ", org.junit.*, com/company/Foo, true", + "com.company.*, com.company.generated.*, com/company/Foo, true", + "com.company.*, com.company.generated.*, com/company/generated/Foo, false", + // Like JaCoCo, nested classes keep their `$` + "com.company.Outer$*, , com/company/Outer\$Inner, true", + "com.company.Outer.Inner, , com/company/Outer\$Inner, false", + // Multiple patterns are separated by colons + "com.*:de.*, , com/foo/Bar, true", + "com.*:de.*, , de/foo/Bar\$Goo, true", + "com.*:de.*, , org/foo/Bar, false", + ) + fun testIsIncluded(includes: String?, excludes: String?, internalClassName: String, expectedIncluded: Boolean) { + assertThat(ClasspathWildcardIncludeFilter(includes, excludes).isIncluded(internalClassName)) + .isEqualTo(expectedIncluded) } } From 844ce2d03cb8a6174f672cfb64a41073db939e90 Mon Sep 17 00:00:00 2001 From: Florian Dreier Date: Tue, 27 Oct 2026 12:59:01 +0100 Subject: [PATCH 15/15] TS-48007 Document the parameters of AnalyzerException Co-Authored-By: Claude Opus 5.5 --- .../com/teamscale/report/jacoco/FilteringAnalyzer.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt index b597c4379..bf2527fb4 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt @@ -135,7 +135,12 @@ open class FilteringAnalyzer( cause ) - /** Error of [analyzerError], which [analyzeAll] passes on unchanged to the locations that contain its location. */ + /** + * Error of [analyzerError], which [analyzeAll] passes on unchanged to the locations that contain its location. + * + * @param message The exception message. + * @param cause The error that occurred during the analysis. + */ private class AnalyzerException(message: String, cause: Exception) : IOException(message, cause) /**