Skip to content

Commit b7378ae

Browse files
committed
# Add @CopilotExperimental compile-time gate for experimental APIs
## Summary Introduces a compile-time enforcement mechanism that prevents accidental use of experimental APIs in the Java SDK. Consumer code that references experimental types or methods now fails to compile by default, with explicit opt-in via `-Acopilot.experimental.allowed=true`. ## Motivation Generated SDK APIs marked as `stability: "experimental"` in the schema were previously only documented via `@apiNote` in Javadoc — invisible to the compiler and easy to miss. This change makes the experimental contract enforceable at build time, giving consumers a clear signal and an explicit opt-in path. ## What changed ### New files | File | Purpose | |------|---------| | `java/src/main/java/com/github/copilot/CopilotExperimental.java` | Annotation: `@Documented`, `@Retention(CLASS)`, `@Target({TYPE, METHOD})` | | `java/src/main/java/com/github/copilot/CopilotExperimentalProcessor.java` | Annotation processor using Trees API + `TreePathScanner` | | `java/src/main/resources/META-INF/services/javax.annotation.processing.Processor` | Service registration for classpath-based discovery | | `java/src/test/java/com/github/copilot/CopilotExperimentalProcessorTest.java` | 3 tests proving fail-by-default and allow-when-opted-in | ### Modified files | File | Change | |------|--------| | `java/src/main/java/module-info.java` | Added `requires static jdk.compiler;` and `provides` directive | | `java/pom.xml` | Compiler plugin: `<proc>none</proc>` + `-Acopilot.experimental.allowed=true`; surefire args for `jdk.compiler` exports | | `java/scripts/codegen/java.ts` | Emits `@CopilotExperimental` + import at 3 codegen sites | | `java/src/generated/java/**` | Regenerated — experimental types/methods now annotated | | `java/README.md` | New "Using experimental APIs" section with examples | ## How it works ### Processor behavior 1. Runs with `@SupportedAnnotationTypes("*")` — processes all compilation units. 2. Checks option `copilot.experimental.allowed`: - If `"true"`: processor is a no-op (all experimental use permitted). - Otherwise: scans source trees for forbidden references. 3. Uses `com.sun.source.util.Trees` + `TreePathScanner` to visit: - `IdentifierTree` — direct type/variable references - `MemberSelectTree` — qualified access (`obj.method`) - `MemberReferenceTree` — method references (`Type::method`) - `NewClassTree` — constructor calls (`new ExperimentalType()`) 4. For each resolved element, checks if it or its enclosing type carries `@CopilotExperimental`. If so, emits `Diagnostic.Kind.ERROR`. ### Diagnostic message ``` error: Use of experimental API 'symbolName' is not allowed. Add compiler option -Acopilot.experimental.allowed=true to opt in. ``` ### Bootstrap handling The processor lives in the same module it protects. To avoid the bootstrap problem (javac trying to load the processor before it's compiled): - Main compile uses `<proc>none</proc>` — annotation processing is disabled during this module's own compilation. - Consumer builds discover the processor via `META-INF/services` (classpath) or `provides` directive (module path). ### Codegen integration The TypeScript codegen (`java/scripts/codegen/java.ts`) was updated at three sites where `stability === "experimental"` is detected: 1. **Session event classes** — `@CopilotExperimental` on the class 2. **RPC params/result records** — `@CopilotExperimental` on the class 3. **API wrapper methods** — `@CopilotExperimental` on individual methods Import (`com.github.copilot.CopilotExperimental`) is added to the generated file's import set only when needed. Existing `@apiNote` Javadoc is preserved. ## Key review points for a Java SME 1. **Trees API usage** — The processor uses only `com.sun.source.*` (exported from `jdk.compiler`), not `com.sun.tools.javac.*` internals. This is the standard supported API for source-level annotation processors. 2. **Module wiring** — `requires static jdk.compiler` means the dependency is compile-time only; the module works at runtime without `jdk.compiler` on the module graph. The `provides` directive enables modular service discovery. 3. **Surefire JVM args** — Tests that instantiate the processor directly need `--add-modules jdk.compiler --add-exports jdk.compiler/com.sun.source.util=ALL-UNNAMED` because surefire runs on the classpath (unnamed module) while `Trees` is in `jdk.compiler`. 4. **proc=none trade-off** — The SDK module itself never runs its own processor. Enforcement is consumer-facing only. This is intentional: the SDK must compile its own generated experimental code without error. 5. **Retention=CLASS** — The annotation is not available via reflection at runtime (`RUNTIME` is unnecessary), but is preserved in `.class` files so the processor can read it from compiled dependencies on the classpath. 6. **Enclosing-type propagation** — When a type is annotated, all member accesses through that type are treated as experimental, even if the member itself is not annotated. This covers field access, method calls, and constructor invocations on experimental types. ## Test coverage | Test | Asserts | |------|---------| | `failsByDefault_whenReferencingExperimentalType` | ERROR diagnostic with "experimental API" for type usage | | `failsByDefault_whenInvokingExperimentalMethod` | ERROR diagnostic for method-level annotation | | `passes_whenOptInFlagIsProvided` | Zero errors when `-Acopilot.experimental.allowed=true` is set | Tests use `javax.tools.JavaCompiler` with in-memory sources and explicit `setProcessors()` to avoid environment sensitivity. ## How to verify ```bash cd java mvn generate-sources -Pcodegen # regenerate with annotations mvn test -Dtest=CopilotExperimentalProcessorTest # run gate tests ```
1 parent a7af1d1 commit b7378ae

359 files changed

Lines changed: 1256 additions & 5 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

java/README.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,78 @@ Or run it directly from the repository:
130130
jbang https://github.com/github/copilot-sdk/blob/main/java/jbang-example.java
131131
```
132132

133+
## Using experimental APIs
134+
135+
Some SDK APIs are marked as experimental with `@CopilotExperimental`. These APIs may change or be removed in future versions without notice.
136+
137+
By default, referencing an experimental API from your code causes a **compile-time error**:
138+
139+
```
140+
error: Use of experimental API 'ExperimentalType' is not allowed.
141+
Add compiler option -Acopilot.experimental.allowed=true to opt in.
142+
```
143+
144+
To opt in and use experimental APIs, pass the annotation processor option `-Acopilot.experimental.allowed=true` to the Java compiler.
145+
146+
### Maven
147+
148+
```xml
149+
<plugin>
150+
<groupId>org.apache.maven.plugins</groupId>
151+
<artifactId>maven-compiler-plugin</artifactId>
152+
<configuration>
153+
<compilerArgs>
154+
<arg>-Acopilot.experimental.allowed=true</arg>
155+
</compilerArgs>
156+
</configuration>
157+
</plugin>
158+
```
159+
160+
### Gradle
161+
162+
```groovy
163+
tasks.withType(JavaCompile) {
164+
options.compilerArgs += ['-Acopilot.experimental.allowed=true']
165+
}
166+
```
167+
168+
### Example
169+
170+
```java
171+
import com.github.copilot.CopilotExperimental;
172+
173+
// This type is experimental — consumer code that references it
174+
// will fail to compile unless the opt-in flag is provided.
175+
@CopilotExperimental
176+
public class ExperimentalType {
177+
public void doSomething() {}
178+
}
179+
180+
// Consumer code — compiles only with -Acopilot.experimental.allowed=true
181+
import test.ExperimentalType;
182+
183+
public class Consumer {
184+
public void use() {
185+
ExperimentalType t = new ExperimentalType();
186+
t.doSomething();
187+
}
188+
}
189+
```
190+
191+
The gate also applies to individual methods annotated with `@CopilotExperimental` on otherwise stable types:
192+
193+
```java
194+
import com.github.copilot.CopilotExperimental;
195+
196+
public class StableType {
197+
@CopilotExperimental
198+
public static void experimentalMethod() {}
199+
}
200+
201+
// Calling experimentalMethod() fails compilation without the opt-in flag
202+
StableType.experimentalMethod();
203+
```
204+
133205
## Projects Using This SDK
134206

135207
| Project | Description |

java/pom.xml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,12 @@
164164
<groupId>org.apache.maven.plugins</groupId>
165165
<artifactId>maven-compiler-plugin</artifactId>
166166
<version>3.15.0</version>
167+
<configuration>
168+
<compilerArgs>
169+
<arg>-Acopilot.experimental.allowed=true</arg>
170+
</compilerArgs>
171+
<proc>none</proc>
172+
</configuration>
167173
</plugin>
168174
<plugin>
169175
<groupId>org.apache.maven.plugins</groupId>
@@ -508,7 +514,7 @@
508514
<jdk>[21,)</jdk>
509515
</activation>
510516
<properties>
511-
<surefire.jvm.args>-XX:+EnableDynamicAgentLoading</surefire.jvm.args>
517+
<surefire.jvm.args>-XX:+EnableDynamicAgentLoading --add-modules jdk.compiler --add-exports jdk.compiler/com.sun.source.util=ALL-UNNAMED --add-exports jdk.compiler/com.sun.source.tree=ALL-UNNAMED</surefire.jvm.args>
512518
</properties>
513519
</profile>
514520
<profile>

java/scripts/codegen/java.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,6 +1002,10 @@ async function generateEventVariantClass(
10021002
if (variant.deprecated) {
10031003
lines.push(`@Deprecated`);
10041004
}
1005+
if (variant.stability === "experimental") {
1006+
allImports.add("com.github.copilot.CopilotExperimental");
1007+
lines.push(`@CopilotExperimental`);
1008+
}
10051009
lines.push(`@JsonIgnoreProperties(ignoreUnknown = true)`);
10061010
lines.push(`@JsonInclude(JsonInclude.Include.NON_NULL)`);
10071011
lines.push(GENERATED_ANNOTATION);
@@ -1404,6 +1408,9 @@ async function generateRpcDataClass(
14041408
"javax.annotation.processing.Generated",
14051409
...imports,
14061410
]);
1411+
if (stability === "experimental") {
1412+
allImports.add("com.github.copilot.CopilotExperimental");
1413+
}
14071414
const sortedImports = [...allImports].sort();
14081415
for (const imp of sortedImports) {
14091416
lines.push(`import ${imp};`);
@@ -1426,6 +1433,9 @@ async function generateRpcDataClass(
14261433
if (deprecated) {
14271434
lines.push(`@Deprecated`);
14281435
}
1436+
if (stability === "experimental") {
1437+
lines.push(`@CopilotExperimental`);
1438+
}
14291439
lines.push(GENERATED_ANNOTATION);
14301440
lines.push(code);
14311441
lines.push("");
@@ -1577,7 +1587,7 @@ function generateApiMethod(
15771587
method: RpcMethodNode,
15781588
isSession: boolean,
15791589
sessionIdExpr: string
1580-
): { lines: string[]; needsMapper: boolean } {
1590+
): { lines: string[]; needsMapper: boolean; needsExperimentalImport: boolean } {
15811591
const resultClass = wrapperResultClassName(method);
15821592
const paramsClass = wrapperParamsClassName(method);
15831593
const hasSessionId = methodHasSessionId(method);
@@ -1606,6 +1616,9 @@ function generateApiMethod(
16061616
if (method.deprecated) {
16071617
lines.push(` @Deprecated`);
16081618
}
1619+
if (method.stability === "experimental") {
1620+
lines.push(` @CopilotExperimental`);
1621+
}
16091622

16101623
// Signature
16111624
if (hasExtraParams) {
@@ -1639,7 +1652,7 @@ function generateApiMethod(
16391652
lines.push(` }`);
16401653
lines.push(``);
16411654

1642-
return { lines, needsMapper };
1655+
return { lines, needsMapper, needsExperimentalImport: method.stability === "experimental" };
16431656
}
16441657

16451658
/**
@@ -1694,9 +1707,10 @@ async function generateNamespaceApiFile(
16941707
}
16951708
if (paramsClass) allImports.add(`${packageName}.${paramsClass}`);
16961709

1697-
const { lines, needsMapper: nm } = generateApiMethod(key, method, isSession, sessionIdExpr);
1710+
const { lines, needsMapper: nm, needsExperimentalImport } = generateApiMethod(key, method, isSession, sessionIdExpr);
16981711
methodLines.push(...lines);
16991712
if (nm) needsMapper = true;
1713+
if (needsExperimentalImport) allImports.add("com.github.copilot.CopilotExperimental");
17001714
}
17011715

17021716
// Build class body
@@ -1819,9 +1833,10 @@ async function generateRpcRootFile(
18191833
}
18201834
if (paramsClass) allImports.add(`${packageName}.${paramsClass}`);
18211835

1822-
const { lines, needsMapper: nm } = generateApiMethod(key, method, isSession, sessionIdExpr);
1836+
const { lines, needsMapper: nm, needsExperimentalImport } = generateApiMethod(key, method, isSession, sessionIdExpr);
18231837
methodLines.push(...lines);
18241838
if (nm) needsMapper = true;
1839+
if (needsExperimentalImport) allImports.add("com.github.copilot.CopilotExperimental");
18251840
}
18261841

18271842
// Build file content

java/src/generated/java/com/github/copilot/generated/rpc/AgentRegistrySpawnParams.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
1111
import com.fasterxml.jackson.annotation.JsonInclude;
1212
import com.fasterxml.jackson.annotation.JsonProperty;
13+
import com.github.copilot.CopilotExperimental;
1314
import javax.annotation.processing.Generated;
1415

1516
/**
@@ -18,6 +19,7 @@
1819
* @apiNote This method is experimental and may change in a future version.
1920
* @since 1.0.0
2021
*/
22+
@CopilotExperimental
2123
@javax.annotation.processing.Generated("copilot-sdk-codegen")
2224
@JsonInclude(JsonInclude.Include.NON_NULL)
2325
@JsonIgnoreProperties(ignoreUnknown = true)

java/src/generated/java/com/github/copilot/generated/rpc/CanvasActionInvokeParams.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
1111
import com.fasterxml.jackson.annotation.JsonInclude;
1212
import com.fasterxml.jackson.annotation.JsonProperty;
13+
import com.github.copilot.CopilotExperimental;
1314
import javax.annotation.processing.Generated;
1415

1516
/**
@@ -18,6 +19,7 @@
1819
* @apiNote This method is experimental and may change in a future version.
1920
* @since 1.0.0
2021
*/
22+
@CopilotExperimental
2123
@javax.annotation.processing.Generated("copilot-sdk-codegen")
2224
@JsonInclude(JsonInclude.Include.NON_NULL)
2325
@JsonIgnoreProperties(ignoreUnknown = true)

java/src/generated/java/com/github/copilot/generated/rpc/CanvasCloseParams.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
1111
import com.fasterxml.jackson.annotation.JsonInclude;
1212
import com.fasterxml.jackson.annotation.JsonProperty;
13+
import com.github.copilot.CopilotExperimental;
1314
import javax.annotation.processing.Generated;
1415

1516
/**
@@ -18,6 +19,7 @@
1819
* @apiNote This method is experimental and may change in a future version.
1920
* @since 1.0.0
2021
*/
22+
@CopilotExperimental
2123
@javax.annotation.processing.Generated("copilot-sdk-codegen")
2224
@JsonInclude(JsonInclude.Include.NON_NULL)
2325
@JsonIgnoreProperties(ignoreUnknown = true)

java/src/generated/java/com/github/copilot/generated/rpc/CanvasOpenParams.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
1111
import com.fasterxml.jackson.annotation.JsonInclude;
1212
import com.fasterxml.jackson.annotation.JsonProperty;
13+
import com.github.copilot.CopilotExperimental;
1314
import javax.annotation.processing.Generated;
1415

1516
/**
@@ -18,6 +19,7 @@
1819
* @apiNote This method is experimental and may change in a future version.
1920
* @since 1.0.0
2021
*/
22+
@CopilotExperimental
2123
@javax.annotation.processing.Generated("copilot-sdk-codegen")
2224
@JsonInclude(JsonInclude.Include.NON_NULL)
2325
@JsonIgnoreProperties(ignoreUnknown = true)

java/src/generated/java/com/github/copilot/generated/rpc/CanvasOpenResult.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
1111
import com.fasterxml.jackson.annotation.JsonInclude;
1212
import com.fasterxml.jackson.annotation.JsonProperty;
13+
import com.github.copilot.CopilotExperimental;
1314
import javax.annotation.processing.Generated;
1415

1516
/**
@@ -18,6 +19,7 @@
1819
* @apiNote This method is experimental and may change in a future version.
1920
* @since 1.0.0
2021
*/
22+
@CopilotExperimental
2123
@javax.annotation.processing.Generated("copilot-sdk-codegen")
2224
@JsonInclude(JsonInclude.Include.NON_NULL)
2325
@JsonIgnoreProperties(ignoreUnknown = true)

java/src/generated/java/com/github/copilot/generated/rpc/ServerAgentRegistryApi.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
package com.github.copilot.generated.rpc;
99

10+
import com.github.copilot.CopilotExperimental;
1011
import java.util.concurrent.CompletableFuture;
1112
import javax.annotation.processing.Generated;
1213

@@ -31,6 +32,7 @@ public final class ServerAgentRegistryApi {
3132
* @apiNote This method is experimental and may change in a future version.
3233
* @since 1.0.0
3334
*/
35+
@CopilotExperimental
3436
public CompletableFuture<AgentRegistrySpawnResult> spawn(AgentRegistrySpawnParams params) {
3537
return caller.invoke("agentRegistry.spawn", params, AgentRegistrySpawnResult.class);
3638
}

0 commit comments

Comments
 (0)