Skip to content

Commit ce406ee

Browse files
edburnsCopilot
andcommitted
feat: add AllowCopilotExperimental opt-in
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7054c6e commit ce406ee

5 files changed

Lines changed: 130 additions & 19 deletions

File tree

java/README.md

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,34 @@ By default, referencing an experimental API from your code causes a **compile-ti
138138

139139
```
140140
error: Use of experimental API 'ExperimentalType' in field type is not allowed.
141-
Add compiler option -Acopilot.experimental.allowed=true to opt in.
141+
Add @AllowCopilotExperimental or compiler option -Acopilot.experimental.allowed=true to opt in.
142142
```
143143

144-
To opt in and use experimental APIs, pass the annotation processor option `-Acopilot.experimental.allowed=true` to the Java compiler.
144+
To opt in and use experimental APIs, either:
145+
146+
- annotate the consuming class, method, or constructor with `@AllowCopilotExperimental`, or
147+
- pass the annotation processor option `-Acopilot.experimental.allowed=true` to the Java compiler.
148+
149+
### In code
150+
151+
```java
152+
import com.github.copilot.AllowCopilotExperimental;
153+
import test.ExperimentalType;
154+
155+
@AllowCopilotExperimental
156+
public class Consumer {
157+
private ExperimentalType field;
158+
159+
public ExperimentalType getIt() {
160+
return field;
161+
}
162+
163+
@AllowCopilotExperimental
164+
public ExperimentalType echo(ExperimentalType value) {
165+
return value;
166+
}
167+
}
168+
```
145169

146170
### Maven
147171

@@ -184,11 +208,11 @@ The processor uses standard JSR 269 annotation processing APIs for maximum porta
184208

185209
| Usage pattern | Caught? | Workaround |
186210
|---|---|---|
187-
| `new ExperimentalType()` in a method body (no field/param declaration) || Assign to a typed field or variable declaration |
188-
| `ExperimentalType.staticMethod()` inline call || Store result in a typed variable |
189-
| Method reference `ExperimentalType::method` || Use explicit lambda with typed parameter |
190-
| Local variable with experimental type (var inference) || Use explicit type declaration at field level |
191-
| Cast to experimental type || Assign to a typed field |
211+
| `new ExperimentalType()` in a method body (no field/param declaration) || Use the compiler flag for a whole-compilation opt-in |
212+
| `ExperimentalType.staticMethod()` inline call || Use the compiler flag for a whole-compilation opt-in |
213+
| Method reference `ExperimentalType::method` || Use the compiler flag for a whole-compilation opt-in |
214+
| Local variable with experimental type (including `var` inference) || Move the usage into a declaration the processor can see, or use the compiler flag |
215+
| Cast to experimental type || Use the compiler flag for a whole-compilation opt-in |
192216

193217
In practice, these gaps rarely matter: any meaningful use of an experimental SDK type almost always appears in a field declaration, method signature, or type hierarchy — all of which are caught. A purely inline expression with no declaration footprint (e.g., `session.rpc().experimental.foo().join()`) is the only case that would slip through. See [ADR-004](docs/adr/adr-004-copilotexperimental.md) for the design rationale.
194218

@@ -214,7 +238,7 @@ public class Consumer {
214238
}
215239
```
216240

217-
The gate also applies to individual methods annotated with `@CopilotExperimental` on otherwise stable types. When a type-level annotation is present, all member accesses through that type are considered experimental.
241+
The gate also applies to individual methods annotated with `@CopilotExperimental` on otherwise stable types. When a type-level annotation is present, all member accesses through that type are considered experimental. `@AllowCopilotExperimental` mirrors the same declaration-level boundary: annotating a class opts in that class and its enclosed declarations, while annotating a method or constructor opts in just that executable signature.
218242

219243
## Projects Using This SDK
220244

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
package com.github.copilot;
6+
7+
import java.lang.annotation.Documented;
8+
import java.lang.annotation.ElementType;
9+
import java.lang.annotation.Retention;
10+
import java.lang.annotation.RetentionPolicy;
11+
import java.lang.annotation.Target;
12+
13+
/**
14+
* Opts a declaration into using {@link CopilotExperimental} APIs.
15+
*
16+
* <p>
17+
* Apply this annotation to a type to allow declaration-level references to
18+
* experimental APIs anywhere within that type, or apply it to a method or
19+
* constructor to allow experimental API usage in that executable's signature.
20+
* This is a code-level alternative to the compiler option
21+
* {@code -Acopilot.experimental.allowed=true}.
22+
*
23+
* <p>
24+
* This opt-in has the same declaration-level scope as the processor itself. It
25+
* does not affect expression-only usages inside method bodies that are not
26+
* visible to standard JSR 269 annotation processing.
27+
*
28+
* @since 1.0.0
29+
*/
30+
@Documented
31+
@Retention(RetentionPolicy.CLASS)
32+
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR})
33+
public @interface AllowCopilotExperimental {
34+
}

java/src/main/java/com/github/copilot/CopilotExperimental.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
*
1717
* <p>
1818
* By default, referencing an experimental API from consumer code causes a
19-
* compile-time error. To opt in, pass the compiler option:
19+
* compile-time error. To opt in, either annotate the consuming declaration with
20+
* {@link AllowCopilotExperimental} or pass the compiler option:
2021
*
2122
* <pre>
2223
* -Acopilot.experimental.allowed=true

java/src/main/java/com/github/copilot/CopilotExperimentalProcessor.java

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,6 @@
1818
import javax.lang.model.element.VariableElement;
1919
import javax.lang.model.type.DeclaredType;
2020
import javax.lang.model.type.TypeMirror;
21-
import javax.lang.model.util.Elements;
22-
import javax.lang.model.util.Types;
2321
import javax.tools.Diagnostic;
2422
import java.util.Set;
2523

@@ -30,7 +28,8 @@
3028
* Any declaration-level reference to a type or method annotated with
3129
* {@link CopilotExperimental} in consumer source code causes a compilation
3230
* error unless the compiler option {@code -Acopilot.experimental.allowed=true}
33-
* is provided.
31+
* is provided or the consuming declaration is annotated with
32+
* {@link AllowCopilotExperimental}.
3433
*
3534
* <p>
3635
* This processor uses only standard JSR 269 APIs ({@code javax.lang.model.*})
@@ -46,16 +45,12 @@
4645
public class CopilotExperimentalProcessor extends AbstractProcessor {
4746

4847
private boolean allowed;
49-
private Elements elementUtils;
50-
private Types typeUtils;
5148

5249
@Override
5350
public synchronized void init(ProcessingEnvironment processingEnv) {
5451
super.init(processingEnv);
5552
String value = processingEnv.getOptions().get("copilot.experimental.allowed");
5653
this.allowed = "true".equals(value);
57-
this.elementUtils = processingEnv.getElementUtils();
58-
this.typeUtils = processingEnv.getTypeUtils();
5954
}
6055

6156
@Override
@@ -71,8 +66,8 @@ public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment
7166

7267
private void checkElement(Element element) {
7368
// Skip elements that are themselves annotated @CopilotExperimental
74-
// (they are the definitions, not consumers)
75-
if (isExperimental(element)) {
69+
// (they are the definitions, not consumers), or that explicitly opt in.
70+
if (isExperimental(element) || isAllowListed(element)) {
7671
return;
7772
}
7873

@@ -148,11 +143,22 @@ private boolean isExperimental(Element element) {
148143
return enclosing != null && enclosing.getAnnotation(CopilotExperimental.class) != null;
149144
}
150145

146+
private boolean isAllowListed(Element element) {
147+
Element current = element;
148+
while (current != null) {
149+
if (current.getAnnotation(AllowCopilotExperimental.class) != null) {
150+
return true;
151+
}
152+
current = current.getEnclosingElement();
153+
}
154+
return false;
155+
}
156+
151157
private void reportError(Element experimentalElement, Element usageSite, String context) {
152158
Messager messager = processingEnv.getMessager();
153159
messager.printMessage(Diagnostic.Kind.ERROR,
154160
"Use of experimental API '" + experimentalElement.getSimpleName() + "' in " + context
155-
+ " is not allowed. Add compiler option -Acopilot.experimental.allowed=true to opt in.",
161+
+ " is not allowed. Add @AllowCopilotExperimental or compiler option -Acopilot.experimental.allowed=true to opt in.",
156162
usageSite);
157163
}
158164
}

java/src/test/java/com/github/copilot/CopilotExperimentalProcessorTest.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,30 @@ public class Consumer extends ExperimentalType {
6464
}
6565
""";
6666

67+
private static final String CLASS_ANNOTATED_CONSUMER = """
68+
package consumer;
69+
import com.github.copilot.AllowCopilotExperimental;
70+
import test.ExperimentalType;
71+
@AllowCopilotExperimental
72+
public class Consumer extends ExperimentalType {
73+
private ExperimentalType field;
74+
public ExperimentalType getIt() { return field; }
75+
public void setIt(ExperimentalType value) { this.field = value; }
76+
}
77+
""";
78+
79+
private static final String METHOD_ANNOTATED_CONSUMER = """
80+
package consumer;
81+
import com.github.copilot.AllowCopilotExperimental;
82+
import test.ExperimentalType;
83+
public class Consumer {
84+
@AllowCopilotExperimental
85+
public ExperimentalType getIt(ExperimentalType value) {
86+
return value;
87+
}
88+
}
89+
""";
90+
6791
@Test
6892
void failsByDefault_whenFieldOrSignatureUsesExperimentalType() {
6993
DiagnosticCollector<JavaFileObject> diagnostics = compile(
@@ -90,6 +114,28 @@ void failsByDefault_whenExtendingExperimentalType() {
90114
"Expected compile error for extending experimental type, got: " + diagnostics.getDiagnostics());
91115
}
92116

117+
@Test
118+
void passes_whenAllowAnnotationIsOnType() {
119+
DiagnosticCollector<JavaFileObject> diagnostics = compile(
120+
List.of(inMemorySource("test.ExperimentalType", EXPERIMENTAL_TYPE_SOURCE),
121+
inMemorySource("consumer.Consumer", CLASS_ANNOTATED_CONSUMER)),
122+
Collections.emptyList());
123+
124+
boolean hasError = diagnostics.getDiagnostics().stream().anyMatch(d -> d.getKind() == Diagnostic.Kind.ERROR);
125+
assertFalse(hasError, "Expected no errors with type-level opt-in, got: " + diagnostics.getDiagnostics());
126+
}
127+
128+
@Test
129+
void passes_whenAllowAnnotationIsOnMethod() {
130+
DiagnosticCollector<JavaFileObject> diagnostics = compile(
131+
List.of(inMemorySource("test.ExperimentalType", EXPERIMENTAL_TYPE_SOURCE),
132+
inMemorySource("consumer.Consumer", METHOD_ANNOTATED_CONSUMER)),
133+
Collections.emptyList());
134+
135+
boolean hasError = diagnostics.getDiagnostics().stream().anyMatch(d -> d.getKind() == Diagnostic.Kind.ERROR);
136+
assertFalse(hasError, "Expected no errors with method-level opt-in, got: " + diagnostics.getDiagnostics());
137+
}
138+
93139
@Test
94140
void passes_whenOptInFlagIsProvided() {
95141
DiagnosticCollector<JavaFileObject> diagnostics = compile(

0 commit comments

Comments
 (0)