Repository: btraceio/btrace (2026-07-13T09:46:49Z)

Issue:
[3.0] Fix Gradle extension annotation discovery for io.btrace descriptors
## Context

The Gradle extension plugin still contains active ASM descriptor strings for org/openjdk/btrace/core/extensions. 3.0-style extensions use io/btrace, so automatic service, permission, and metadata discovery can silently fail unless users configure services explicitly.

## Scope

- Replace active pre-3.0 descriptor literals with 3.0 descriptors.
- Centralize descriptor constants for partitioning, discovery, permission scanning, and validation.
- Retain legacy recognition only if intentionally supported and covered by migration tests.
- Add a TestKit project using package ExtensionDescriptor and service ServiceDescriptor without explicit services configuration.

## Likely files

- btrace-gradle-plugin/src/main/groovy/io/btrace/gradle/BTraceExtensionPlugin.groovy
- btrace-gradle-plugin/src/main/groovy/io/btrace/gradle/SingleSourceApiPartition.groovy
- btrace-gradle-plugin/src/test/java/io/btrace/gradle/BTraceExtensionPluginTest.java

## Acceptance criteria

- [ ] Automatic discovery works for a 3.0-style extension.
- [ ] Detected service names and permissions are present in generated metadata.
- [ ] Unannotated classes are not treated as services.
- [ ] No active pre-3.0 descriptor remains outside explicit compatibility constants/tests.


Discussion:

Patch:
diff --git a/btrace-gradle-plugin/src/main/groovy/io/btrace/gradle/BTraceDescriptors.groovy b/btrace-gradle-plugin/src/main/groovy/io/btrace/gradle/BTraceDescriptors.groovy
new file mode 100644
index 000000000..d67f0ef00
--- /dev/null
+++ b/btrace-gradle-plugin/src/main/groovy/io/btrace/gradle/BTraceDescriptors.groovy
@@ -0,0 +1,37 @@
+package io.btrace.gradle
+
+import groovy.transform.CompileStatic
+import java.lang.reflect.Array
+import java.util.List
+import java.util.Locale
+
+/** ASM descriptors used by every BTrace extension annotation discovery path. */
+@CompileStatic
+final class BTraceDescriptors {
+    static final String SERVICE_DESCRIPTOR =
+        'Lio/btrace/core/extensions/ServiceDescriptor;'
+    static final String EXTENSION_DESCRIPTOR =
+        'Lio/btrace/core/extensions/ExtensionDescriptor;'
+    static final String EXTERNAL_TYPE =
+        'Lio/btrace/core/extensions/ExternalType;'
+
+    private BTraceDescriptors() {}
+
+    static boolean isServiceDescriptor(String descriptor) {
+        return SERVICE_DESCRIPTOR == descriptor
+    }
+
+    static boolean isExtensionDescriptor(String descriptor) {
+        return EXTENSION_DESCRIPTOR == descriptor
+    }
+
+    static String enumConstantName(Object value) {
+        Object constant = null
+        if (value instanceof List && ((List) value).size() >= 2) {
+            constant = ((List) value).get(1)
+        } else if (value != null && value.getClass().isArray() && Array.getLength(value) >= 2) {
+            constant = Array.get(value, 1)
+        }
+        return constant != null ? String.valueOf(constant).toUpperCase(Locale.ROOT) : null
+    }
+}
diff --git a/btrace-gradle-plugin/src/main/groovy/io/btrace/gradle/BTraceExtensionPlugin.groovy b/btrace-gradle-plugin/src/main/groovy/io/btrace/gradle/BTraceExtensionPlugin.groovy
index ec4669e2d..e88320146 100644
--- a/btrace-gradle-plugin/src/main/groovy/io/btrace/gradle/BTraceExtensionPlugin.groovy
+++ b/btrace-gradle-plugin/src/main/groovy/io/btrace/gradle/BTraceExtensionPlugin.groovy
@@ -234,7 +234,6 @@ class BTraceExtensionPlugin implements Plugin<Project> {
                     }
                 }
                 // Enforce: at most one package-level @ExtensionDescriptor across the project
-                def extDescDesc = 'Lio/btrace/core/extensions/ExtensionDescriptor;'
                 int extDescCount = 0
                 def scanForPkgDescriptors = { File classesDir ->
                     if (classesDir == null || !classesDir.exists()) return
@@ -248,8 +247,8 @@ class BTraceExtensionPlugin implements Plugin<Project> {
                         def cn = new ClassNode()
                         cr.accept(cn, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG)
                         boolean has = false
-                        (cn.visibleAnnotations ?: []).each { has |= (it.desc == extDescDesc) }
-                        (cn.invisibleAnnotations ?: []).each { has |= (it.desc == extDescDesc) }
+                        (cn.visibleAnnotations ?: []).each { has |= BTraceDescriptors.isExtensionDescriptor(it.desc) }
+                        (cn.invisibleAnnotations ?: []).each { has |= BTraceDescriptors.isExtensionDescriptor(it.desc) }
                         if (has) extDescCount++
                     }
                 }
@@ -277,8 +276,8 @@ class BTraceExtensionPlugin implements Plugin<Project> {
                             cr.accept(cn, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG)
                             def isInterface = (cn.access & Opcodes.ACC_INTERFACE) != 0
                             def hasMarker = false
-                            (cn.visibleAnnotations ?: []).each { hasMarker |= (it.desc == 'Lio/btrace/core/extensions/ServiceDescriptor;') }
-                            (cn.invisibleAnnotations ?: []).each { hasMarker |= (it.desc == 'Lio/btrace/core/extensions/ServiceDescriptor;') }
+                            (cn.visibleAnnotations ?: []).each { hasMarker |= BTraceDescriptors.isServiceDescriptor(it.desc) }
+                            (cn.invisibleAnnotations ?: []).each { hasMarker |= BTraceDescriptors.isServiceDescriptor(it.desc) }
                             if (hasMarker && isInterface) detected.add(fq)
                         }
                     }
@@ -354,15 +353,14 @@ class BTraceExtensionPlugin implements Plugin<Project> {
                         // Collect ServiceDescriptor.permissions
                         def collectServicePerms = { AnnotationNode an ->
                             if (an == null) return
-                            if (an.desc == 'Lio/btrace/core/extensions/ServiceDescriptor;') {
+                            if (BTraceDescriptors.isServiceDescriptor(an.desc)) {
                                 def vals = an.values ?: []
                                 for (int i = 0; i < vals.size(); i += 2) {
                                     if (vals[i] == 'permissions') {
                                         def arr = vals[i+1] as List
                                         arr?.each { ev ->
-                                            if (ev instanceof List && ev.size() >= 2) {
-                                                permAnnoSet.add(String.valueOf(ev[1]))
-                                            }
+                                            def permission = BTraceDescriptors.enumConstantName(ev)
+                                            if (permission != null) permAnnoSet.add(permission)
                                         }
                                     }
                                 }
@@ -672,24 +670,14 @@ class BTraceExtensionPlugin implements Plugin<Project> {
                     mergedPerms.addAll(scannedPerms)
                     mergedPerms.addAll(extension.requiredPermissions)
 
-                    // Emit concise info about permissions for traceability
-                    if (extension.scanPermissions) {
-                        project.logger.lifecycle("[BTRACE-EXT] permissions: scanned=${scannedPerms} merged=${mergedPerms}")
-                    } else {
-                        project.logger.lifecycle("[BTRACE-EXT] permissions: merged=${mergedPerms} (scan disabled)")
-                    }
-
-                    // Cross-check: ensure manifest permissions cover descriptor-declared requirements
+                    // Discover descriptor permissions and ensure the manifest covers them.
                     try {
                         def cp = new LinkedHashSet<File>()
                         cp.addAll(authoredClassesDirs())
                         try { cp.addAll(authoredCompileClasspath()) } catch (Throwable ignore) {}
                         URLClassLoader cl = new URLClassLoader(cp.collect { it.toURI().toURL() } as URL[], (ClassLoader) null)
                         def enumConstName = { Object enumConst ->
-                            if (enumConst instanceof List && enumConst.size() >= 2) {
-                                return String.valueOf(enumConst[1]).toUpperCase(Locale.ROOT)
-                            }
-                            return null
+                            return BTraceDescriptors.enumConstantName(enumConst)
                         }
                         Set<String> annotatedPerms = [] as Set
                         explicitServiceTypes().each { String fqcn ->
@@ -701,7 +689,7 @@ class BTraceExtensionPlugin implements Plugin<Project> {
                                 def cn = new ClassNode()
                                 cr.accept(cn, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG)
                                 (cn.visibleAnnotations ?: []).each { an ->
-                                    if (an.desc == 'Lio/btrace/core/extensions/ServiceDescriptor;') {
+                                    if (BTraceDescriptors.isServiceDescriptor(an.desc)) {
                                         def vals = an.values ?: []
                                         for (int i = 0; i < vals.size(); i += 2) {
                                             if (vals[i] == 'permissions') {
@@ -715,7 +703,7 @@ class BTraceExtensionPlugin implements Plugin<Project> {
                                     }
                                 }
                                 (cn.invisibleAnnotations ?: []).each { an ->
-                                    if (an.desc == 'Lio/btrace/core/extensions/ServiceDescriptor;') {
+                                    if (BTraceDescriptors.isServiceDescriptor(an.desc)) {
                                         def vals = an.values ?: []
                                         for (int i = 0; i < vals.size(); i += 2) {
                                             if (vals[i] == 'permissions') {
@@ -741,7 +729,7 @@ class BTraceExtensionPlugin implements Plugin<Project> {
                                 def cr2 = new ClassReader(bytes2)
                                 def cn2 = new ClassNode(); cr2.accept(cn2, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG)
                                 (cn2.visibleAnnotations ?: []).each { an ->
-                                    if (an.desc == 'Lio/btrace/core/extensions/ExtensionDescriptor;') {
+                                    if (BTraceDescriptors.isExtensionDescriptor(an.desc)) {
                                         def vals = an.values ?: []
                                         for (int i = 0; i < vals.size(); i += 2) {
                                             if (vals[i] == 'permissions') {
@@ -755,7 +743,7 @@ class BTraceExtensionPlugin implements Plugin<Project> {
                                     }
                                 }
                                 (cn2.invisibleAnnotations ?: []).each { an ->
-                                    if (an.desc == 'Lio/btrace/core/extensions/ExtensionDescriptor;') {
+                                    if (BTraceDescriptors.isExtensionDescriptor(an.desc)) {
                                         def vals = an.values ?: []
                                         for (int i = 0; i < vals.size(); i += 2) {
                                             if (vals[i] == 'permissions') {
@@ -770,6 +758,9 @@ class BTraceExtensionPlugin implements Plugin<Project> {
                                 }
                             }
                         }
+                        if (extension.scanPermissions) {
+                            mergedPerms.addAll(annotatedPerms)
+                        }
                         Set<String> manifestPerms = mergedPerms.collect { it.toString().toUpperCase(Locale.ROOT) } as Set
                         Set<String> missing = annotatedPerms.findAll { !manifestPerms.contains(it) } as Set
                         if (!missing.isEmpty()) {
@@ -781,6 +772,13 @@ class BTraceExtensionPlugin implements Plugin<Project> {
                         project.logger.warn("[BTRACE-EXT] permission alignment check failed to run: ${t.message}")
                     }
 
+                    // Emit concise info about permissions for traceability after descriptor merging.
+                    if (extension.scanPermissions) {
+                        project.logger.lifecycle("[BTRACE-EXT] permissions: scanned=${scannedPerms} merged=${mergedPerms}")
+                    } else {
+                        project.logger.lifecycle("[BTRACE-EXT] permissions: merged=${mergedPerms} (scan disabled)")
+                    }
+
                     def shadedPkgs = extension.shadedPackages.collect { k, v -> "$k->$v" }.join(',')
                     def servicesStr = extension.services.join(',')
                     def requiresStr = extension.requiresExtensions.join(',')
@@ -1099,8 +1097,8 @@ class BTraceExtensionPlugin implements Plugin<Project> {
                                 def cn = new ClassNode(); cr.accept(cn, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG)
                                 boolean isInterface = (cn.access & Opcodes.ACC_INTERFACE) != 0
                                 boolean hasMarker = false
-                                (cn.visibleAnnotations ?: []).each { hasMarker |= (it.desc == 'Lio/btrace/core/extensions/ServiceDescriptor;') }
-                                (cn.invisibleAnnotations ?: []).each { hasMarker |= (it.desc == 'Lio/btrace/core/extensions/ServiceDescriptor;') }
+                                (cn.visibleAnnotations ?: []).each { hasMarker |= BTraceDescriptors.isServiceDescriptor(it.desc) }
+                                (cn.invisibleAnnotations ?: []).each { hasMarker |= BTraceDescriptors.isServiceDescriptor(it.desc) }
                                 if (hasMarker && isInterface) services.add(fq)
                             }
                         }
diff --git a/btrace-gradle-plugin/src/main/groovy/io/btrace/gradle/SingleSourceApiPartition.groovy b/btrace-gradle-plugin/src/main/groovy/io/btrace/gradle/SingleSourceApiPartition.groovy
index 4397c1115..b099a1e09 100644
--- a/btrace-gradle-plugin/src/main/groovy/io/btrace/gradle/SingleSourceApiPartition.groovy
+++ b/btrace-gradle-plugin/src/main/groovy/io/btrace/gradle/SingleSourceApiPartition.groovy
@@ -32,10 +32,6 @@ import org.objectweb.asm.tree.MethodNode
  */
 @CompileStatic
 final class SingleSourceApiPartition {
-    private static final String SERVICE_DESCRIPTOR_DESC =
-        'Lio/btrace/core/extensions/ServiceDescriptor;'
-    private static final String EXTERNAL_TYPE_DESC =
-        'Lio/btrace/core/extensions/ExternalType;'
     private static final Pattern EXCLUDED_GENERATED =
         Pattern.compile('.*(\\$Ext|\\.btrace\\.shim\\.).*')
 
@@ -44,7 +40,7 @@ final class SingleSourceApiPartition {
     static Set<String> detectServiceTypes(Collection<File> classDirs) {
         Set<String> detected = new LinkedHashSet<>()
         indexClasses(classDirs).each { String fqcn, IndexedClass indexed ->
-            if (indexed.interfaceType && indexed.hasAnnotation(SERVICE_DESCRIPTOR_DESC)) {
+            if (indexed.interfaceType && indexed.hasAnnotation(BTraceDescriptors.SERVICE_DESCRIPTOR)) {
                 detected.add(fqcn)
             }
         }
@@ -130,7 +126,7 @@ final class SingleSourceApiPartition {
         index.each { String fqcn, IndexedClass indexed ->
             if (exportedTypes.contains(fqcn)) {
                 includes.add(indexed.relativeClassPath)
-                if (indexed.hasAnnotation(EXTERNAL_TYPE_DESC)) {
+                if (indexed.hasAnnotation(BTraceDescriptors.EXTERNAL_TYPE)) {
                     IndexedClass adapter = index.get(fqcn + '$Ext')
                     if (adapter != null) {
                         includes.add(adapter.relativeClassPath)
diff --git a/docs/BTraceTutorial.md b/docs/BTraceTutorial.md
index 35810df6d..2fa00bdd5 100644
--- a/docs/BTraceTutorial.md
+++ b/docs/BTraceTutorial.md
@@ -1161,7 +1161,7 @@ public class MyProbe {
 }
 ```
 
-The plugin generates the service descriptor manifest, computes the required permission set, and produces a distributable ZIP that installs under `$BTRACE_HOME/extensions/`. Always list your service interface(s) explicitly in `services = [...]` as shown above — the plugin's own auto-detection fallback (for projects that omit `services`) still scans for the pre-3.0 `org.openjdk.btrace.core.extensions.ServiceDescriptor` annotation, not the current `io.btrace.core.extensions` package, so it never fires against a 3.0-style extension in this checkout. See the [hands-on lab](tutorials/06-write-your-own-extension.md) for the full, verified story on that and other plugin gotchas, and the [BTrace Extension Development Guide](BTraceExtensionDevelopmentGuide.md) for detailed coverage of: classloader isolation, permission declarations, `@ExternalType` adapters, fat-agent embedding, and the full API design rules documented in [ExtensionInterfaceRules.md](ExtensionInterfaceRules.md).
+The plugin generates the service descriptor manifest, computes the required permission set, and produces a distributable ZIP that installs under `$BTRACE_HOME/extensions/`. Service interfaces annotated with `io.btrace.core.extensions.ServiceDescriptor` are discovered automatically when `services = [...]` is omitted; an explicit list remains useful when the interface cannot be annotated. See the [hands-on lab](tutorials/06-write-your-own-extension.md) for the full, verified workflow, and the [BTrace Extension Development Guide](BTraceExtensionDevelopmentGuide.md) for detailed coverage of classloader isolation, permission declarations, `@ExternalType` adapters, fat-agent embedding, and the API design rules documented in [ExtensionInterfaceRules.md](ExtensionInterfaceRules.md).
 
 ---
 
diff --git a/docs/tutorials/06-write-your-own-extension.md b/docs/tutorials/06-write-your-own-extension.md
index 2e29a7b8f..885bd1bb7 100644
--- a/docs/tutorials/06-write-your-own-extension.md
+++ b/docs/tutorials/06-write-your-own-extension.md
@@ -351,15 +351,10 @@ rm -rf "$BTRACE_HOME/extensions/order-counter" ~/.btrace/permissions.properties
 - **`Shadow plugin ('com.gradleup.shadow') must be applied`** — the plugin tries to auto-apply
   Shadow and only fails loudly if that can't resolve (e.g. offline, or the portal is unreachable).
   Add `id 'com.gradleup.shadow'` to your `plugins { }` block explicitly and re-run.
-- **You left out `services = [...]` and nothing got exported** — the plugin *can* auto-detect
-  service interfaces from `@ServiceDescriptor` without you listing them, but as of this checkout
-  that detection (`SingleSourceApiPartition.SERVICE_DESCRIPTOR_DESC` and the equivalent checks in
-  `BTraceExtensionPlugin.groovy`) matches the annotation's *old* package name
-  (`org.openjdk.btrace.core.extensions.ServiceDescriptor`), not the current
-  `io.btrace.core.extensions.ServiceDescriptor` your API actually uses. In other words:
-  auto-detection is currently dead code for this codebase's own annotations. Always declare
-  `services` explicitly in `btraceExtension { }`, exactly as this tutorial and the real
-  `btrace-metrics` module (`btrace-extensions/btrace-metrics/build.gradle`) both do.
+- **You left out `services = [...]` and nothing got exported** — automatic discovery only exports
+  interfaces annotated with `io.btrace.core.extensions.ServiceDescriptor`. Confirm the annotation
+  is on the compiled service interface (not only its implementation). An explicit `services` list
+  remains available when an extension cannot annotate the interface it exports.
 - **`Could not find io.btrace:btrace-core:<your-project-version>`** — the plugin auto-registers
   the `@ExternalType` annotation processor, and outside the BTrace monorepo (no sibling
   `:btrace-core` project) it resolves that processor as


Test patch:
diff --git a/btrace-gradle-plugin/src/test/java/io/btrace/gradle/BTraceExtensionPluginTest.java b/btrace-gradle-plugin/src/test/java/io/btrace/gradle/BTraceExtensionPluginTest.java
index c59f09760..5a1827bd5 100644
--- a/btrace-gradle-plugin/src/test/java/io/btrace/gradle/BTraceExtensionPluginTest.java
+++ b/btrace-gradle-plugin/src/test/java/io/btrace/gradle/BTraceExtensionPluginTest.java
@@ -11,6 +11,7 @@
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.Set;
 import java.util.jar.JarFile;
 import org.gradle.testkit.runner.BuildResult;
 import org.gradle.testkit.runner.GradleRunner;
@@ -95,6 +96,40 @@ void buildApiJarKeepsResourcesAndExcludesInternalShims() throws IOException {
         }
     }
 
+    @Test
+    @DisplayName("3.0 descriptors discover services and permissions without explicit configuration")
+    void descriptorsDiscoverServicesAndPermissions() throws IOException {
+        writeExtensionProject("", false);
+
+        BuildResult result = createRunner().withArguments(":ext:buildApiJar").build();
+
+        assertEquals(TaskOutcome.SUCCESS, result.task(":ext:buildApiJar").getOutcome());
+        File apiJar = projectDir.resolve("ext/build/libs/ext-1.0-api.jar").toFile();
+        try (JarFile jar = new JarFile(apiJar)) {
+            String services =
+                    jar.getManifest()
+                            .getMainAttributes()
+                            .getValue("BTrace-Extension-Services");
+            String permissions =
+                    jar.getManifest()
+                            .getMainAttributes()
+                            .getValue("BTrace-Extension-Permissions");
+            assertEquals("com.example.api.PublicService", services);
+            assertEquals(Set.of("MEMORY_INFO", "THREAD_INFO"), Set.of(permissions.split(",")));
+            assertFalse(services.contains("com.example.impl.HiddenInternal"));
+            assertFalse(services.contains("com.example.api.PublicValue"));
+            assertNotNull(jar.getEntry("com/example/api/PublicService.class"));
+            assertNotNull(jar.getEntry("com/example/api/PublicValue.class"));
+            assertFalse(
+                    jar.stream()
+                            .anyMatch(
+                                    entry ->
+                                            entry.getName()
+                                                    .equals(
+                                                            "com/example/impl/HiddenInternal.class")));
+        }
+    }
+
     @Test
     @DisplayName("published API source and javadoc artifacts stay API-only")
     void apiPublicationsStayApiOnly() throws IOException {
@@ -262,7 +297,7 @@ void updateRegistryCatalogUsesForkBasedPr() throws IOException {
 
     private void writeStubCoreProject() throws IOException {
         Path dir = projectDir.resolve("btrace-core");
-        Files.createDirectories(dir.resolve("src/main/java/org/openjdk/btrace/core/extensions"));
+        Files.createDirectories(dir.resolve("src/main/java/io/btrace/core/extensions"));
         writeFile(
                 dir.resolve("build.gradle"),
                 "plugins { id 'java-library' }\n"
@@ -270,7 +305,7 @@ private void writeStubCoreProject() throws IOException {
                         + "version = '1.0'\n");
         writeFile(
                 dir.resolve(
-                        "src/main/java/org/openjdk/btrace/core/extensions/ServiceDescriptor.java"),
+                        "src/main/java/io/btrace/core/extensions/ServiceDescriptor.java"),
                 "package io.btrace.core.extensions;\n"
                         + "import java.lang.annotation.ElementType;\n"
                         + "import java.lang.annotation.Retention;\n"
@@ -279,11 +314,11 @@ private void writeStubCoreProject() throws IOException {
                         + "@Retention(RetentionPolicy.RUNTIME)\n"
                         + "@Target({ElementType.TYPE, ElementType.METHOD})\n"
                         + "public @interface ServiceDescriptor {\n"
-                        + "  String[] permissions() default {};\n"
+                        + "  Permission[] permissions() default {};\n"
                         + "}\n");
         writeFile(
                 dir.resolve(
-                        "src/main/java/org/openjdk/btrace/core/extensions/ExtensionDescriptor.java"),
+                        "src/main/java/io/btrace/core/extensions/ExtensionDescriptor.java"),
                 "package io.btrace.core.extensions;\n"
                         + "import java.lang.annotation.ElementType;\n"
                         + "import java.lang.annotation.Retention;\n"
@@ -292,11 +327,18 @@ private void writeStubCoreProject() throws IOException {
                         + "@Retention(RetentionPolicy.RUNTIME)\n"
                         + "@Target(ElementType.PACKAGE)\n"
                         + "public @interface ExtensionDescriptor {\n"
-                        + "  String[] permissions() default {};\n"
+                        + "  String name();\n"
+                        + "  String version();\n"
+                        + "  String description() default \"\";\n"
+                        + "  Permission[] permissions() default {};\n"
                         + "}\n");
+        writeFile(
+                dir.resolve("src/main/java/io/btrace/core/extensions/Permission.java"),
+                "package io.btrace.core.extensions;\n"
+                        + "public enum Permission { NONE, MEMORY_INFO, THREAD_INFO }\n");
         writeFile(
                 dir.resolve(
-                        "src/main/java/org/openjdk/btrace/core/extensions/ExternalType.java"),
+                        "src/main/java/io/btrace/core/extensions/ExternalType.java"),
                 "package io.btrace.core.extensions;\n"
                         + "import java.lang.annotation.ElementType;\n"
                         + "import java.lang.annotation.Retention;\n"
@@ -311,7 +353,7 @@ private void writeStubCoreProject() throws IOException {
 
     private void writeStubProcessorProject() throws IOException {
         Path dir = projectDir.resolve("btrace-extension-processor");
-        Files.createDirectories(dir.resolve("src/main/java/org/openjdk/btrace/processor"));
+        Files.createDirectories(dir.resolve("src/main/java/io/btrace/processor"));
         writeFile(
                 dir.resolve("build.gradle"),
                 "plugins { id 'java-library' }\n"
@@ -319,7 +361,7 @@ private void writeStubProcessorProject() throws IOException {
                         + "version = '1.0'\n");
         writeFile(
                 dir.resolve(
-                        "src/main/java/org/openjdk/btrace/processor/PlaceholderProcessor.java"),
+                        "src/main/java/io/btrace/processor/PlaceholderProcessor.java"),
                 "package io.btrace.processor;\n"
                         + "public final class PlaceholderProcessor {}\n");
     }
@@ -329,6 +371,11 @@ private void writeExtensionProject() throws IOException {
     }
 
     private void writeExtensionProject(String extraBuildLogic) throws IOException {
+        writeExtensionProject(extraBuildLogic, true);
+    }
+
+    private void writeExtensionProject(String extraBuildLogic, boolean explicitServices)
+            throws IOException {
         Path dir = projectDir.resolve("ext");
         Files.createDirectories(dir.resolve("src/main/java/com/example/api"));
         Files.createDirectories(dir.resolve("src/main/java/com/example/impl"));
@@ -349,11 +396,22 @@ private void writeExtensionProject(String extraBuildLogic) throws IOException {
                         + "  id = 'test.ext'\n"
                         + "  name = 'Test Extension'\n"
                         + "  description = 'Fixture for extension plugin tests'\n"
-                        + "  services = ['com.example.api.PublicService']\n"
-                        + "  requiredPermissions = ['NONE']\n"
-                        + "  scanPermissions = false\n"
+                        + (explicitServices
+                                ? "  services = ['com.example.api.PublicService']\n"
+                                        + "  requiredPermissions = ['NONE']\n"
+                                        + "  scanPermissions = false\n"
+                                : "")
                         + "}\n"
                         + extraBuildLogic);
+        if (!explicitServices) {
+            writeFile(
+                    dir.resolve("src/main/java/com/example/api/package-info.java"),
+                    "@io.btrace.core.extensions.ExtensionDescriptor(\n"
+                            + "    name = \"test.ext\",\n"
+                            + "    version = \"1.0\",\n"
+                            + "    permissions = {io.btrace.core.extensions.Permission.THREAD_INFO})\n"
+                            + "package com.example.api;\n");
+        }
         writeFile(
                 dir.resolve("src/main/java/com/example/api/PublicValue.java"),
                 "package com.example.api;\n"
@@ -366,6 +424,11 @@ private void writeExtensionProject(String extraBuildLogic) throws IOException {
         writeFile(
                 dir.resolve("src/main/java/com/example/api/PublicService.java"),
                 "package com.example.api;\n"
+                        + (explicitServices
+                                ? ""
+                                : "import io.btrace.core.extensions.Permission;\n"
+                                        + "import io.btrace.core.extensions.ServiceDescriptor;\n"
+                                        + "@ServiceDescriptor(permissions = {Permission.MEMORY_INFO})\n")
                         + "public interface PublicService {\n"
                         + "  PublicValue value();\n"
                         + "}\n");
@@ -424,7 +487,7 @@ private void writeBuildSrcShadowPlugin() throws IOException {
                 "package com.github.jengelman.gradle.plugins.shadow\n"
                         + "import org.gradle.api.tasks.Internal\n"
                         + "import org.gradle.api.tasks.bundling.Jar\n"
-                        + "class ShadowJar extends Jar {\n"
+                        + "abstract class ShadowJar extends Jar {\n"
                         + "  @Internal Object configurations\n"
                         + "  void relocate(String from, String to) {}\n"
                         + "  void minimize() {}\n"