Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .github/workflows/rust-sdk-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,10 @@ jobs:
if: runner.os == 'Linux'
env:
BUNDLED_CLI_CACHE_DIR: ${{ github.workspace }}/rust/.bundled-cli-cache
run: cargo clippy --all-targets --features test-support -- --no-deps -D warnings -D clippy::unwrap_used -D clippy::disallowed_macros -D clippy::await_holding_invalid_type
# The `experimental` feature is enabled so examples and lints cover the
# gated experimental API surface (experimental methods are `pub` only
# under this feature — see the `experimental` feature in Cargo.toml).
run: cargo clippy --all-targets --features test-support,experimental -- --no-deps -D warnings -D clippy::unwrap_used -D clippy::disallowed_macros -D clippy::await_holding_invalid_type

- name: cargo doc
if: runner.os == 'Linux'
Expand Down Expand Up @@ -127,7 +130,11 @@ jobs:
# embed it. Tests exec against the setup-copilot CLI via
# COPILOT_CLI_PATH (the env override wins over the dev cache).
# The dedicated `bundle` job below exercises the embed pipeline.
run: cargo test --no-default-features --features test-support -- --test-threads=4 --nocapture
# `experimental` is enabled because the SDK's own integration tests
# exercise experimental RPC methods directly; the feature gate that
# hides them from external consumers is the Rust analog of C#
# `[Experimental]` / Java `@CopilotExperimental`.
run: cargo test --no-default-features --features test-support,experimental -- --test-threads=4 --nocapture

# Validates the bundled-CLI build path on all three supported
# platforms. While the regular `cargo test` job above also exercises
Expand Down
11 changes: 11 additions & 0 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ To use the SDK, you'll need:
go get github.com/github/copilot-sdk/go
```

## Detecting experimental API usage

The Go SDK ships a companion analyzer, `copilotexperimental`, that reports
references to experimental Copilot SDK APIs in consumer code. Install the tool
and point `go vet` at it:

```bash
go install github.com/github/copilot-sdk/go/copilotexperimental/cmd/copilotexperimental@latest
go vet -vettool=$(which copilotexperimental) ./...
```

## Run the Sample

Try the interactive chat sample (from the repo root):
Expand Down
33 changes: 33 additions & 0 deletions go/copilotexperimental/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# copilotexperimental

`copilotexperimental` is a `go vet`-compatible analyzer that reports references
to experimental Copilot SDK APIs in consumer code.

It detects exported symbols whose doc comments contain an `Experimental:`
marker, including functions, types, methods, and struct fields.

## Install

```bash
go install github.com/github/copilot-sdk/go/copilotexperimental/cmd/copilotexperimental@latest
```

## Run

```bash
go vet -vettool=$(which copilotexperimental) ./...
```

## Suppress one diagnostic

Add `//nolint:copilotexperimental` to the same line as the reference:

```go
_ = sdk.StartCanvas() //nolint:copilotexperimental
```

## golangci-lint

The analyzer can also run through golangci-lint's custom module plugin support.
Use the analyzer name `copilotexperimental`; the same
`//nolint:copilotexperimental` suppression directive applies there as well.
12 changes: 12 additions & 0 deletions go/copilotexperimental/cmd/copilotexperimental/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Command copilotexperimental runs the copilotexperimental analyzer.
package main

import (
"golang.org/x/tools/go/analysis/singlechecker"

"github.com/github/copilot-sdk/go/copilotexperimental"
)

func main() {
singlechecker.Main(copilotexperimental.Analyzer)
}
197 changes: 197 additions & 0 deletions go/copilotexperimental/experimental.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
// Package copilotexperimental provides a go/analysis analyzer that reports
// references to experimental Copilot SDK APIs.
package copilotexperimental

import (
"go/ast"
"go/token"
"strings"

"golang.org/x/tools/go/analysis"
)

const (
analyzerName = "copilotexperimental"
experimentalMarker = "Experimental:"
suppressionDirective = "nolint:copilotexperimental"
)

// Doc describes the analyzer.
const Doc = `report references to experimental Copilot SDK APIs

The analyzer marks declarations whose doc comments contain an "Experimental:"
marker and reports downstream references to those objects.

Suppress an individual diagnostic by adding //nolint:copilotexperimental to the
same line as the reference.`

type experimentalFact struct{}

func (*experimentalFact) AFact() {}

func (*experimentalFact) String() string { return "experimental" }

// Analyzer reports cross-package references to experimental Copilot SDK APIs.
var Analyzer = &analysis.Analyzer{
Name: analyzerName,
Doc: Doc,
Run: run,
FactTypes: []analysis.Fact{(*experimentalFact)(nil)},
}

func run(pass *analysis.Pass) (any, error) {
exportFacts(pass)
reportUses(pass)
return nil, nil
}

func exportFacts(pass *analysis.Pass) {
mark := func(id *ast.Ident) {
if id == nil {
return
}
if obj := pass.TypesInfo.Defs[id]; obj != nil {
pass.ExportObjectFact(obj, &experimentalFact{})
}
}

for _, file := range pass.Files {
for _, decl := range file.Decls {
switch decl := decl.(type) {
case *ast.FuncDecl:
if hasExperimentalMarker(decl.Doc) {
mark(decl.Name)
}
case *ast.GenDecl:
groupExperimental := len(decl.Specs) == 1 && hasExperimentalMarker(decl.Doc)
for _, spec := range decl.Specs {
switch spec := spec.(type) {
case *ast.TypeSpec:
if groupExperimental || hasExperimentalMarker(spec.Doc) {
mark(spec.Name)
}
markStructFields(pass, spec)
case *ast.ValueSpec:
if groupExperimental || hasExperimentalMarker(spec.Doc) {
for _, name := range spec.Names {
mark(name)
}
}
}
}
}
}
}
}

func markStructFields(pass *analysis.Pass, spec *ast.TypeSpec) {
structType, ok := spec.Type.(*ast.StructType)
if !ok || structType.Fields == nil {
return
}

for _, field := range structType.Fields.List {
if !hasExperimentalMarker(field.Doc, field.Comment) {
continue
}
for _, name := range field.Names {
if obj := pass.TypesInfo.Defs[name]; obj != nil {
pass.ExportObjectFact(obj, &experimentalFact{})
}
}
}
}

func reportUses(pass *analysis.Pass) {
for _, file := range pass.Files {
suppressions := collectSuppressions(pass, file)

ast.Inspect(file, func(node ast.Node) bool {
id, ok := node.(*ast.Ident)
if !ok || suppressions.contains(pass, id.Pos()) {
return true
}

obj := pass.TypesInfo.Uses[id]
if obj == nil || obj.Pkg() == nil || obj.Pkg() == pass.Pkg {
return true
}

var fact experimentalFact
if !pass.ImportObjectFact(obj, &fact) {
return true
}

pass.Reportf(
id.Pos(),
"use of experimental API '%s' — opt in with //%s",
obj.Name(),
suppressionDirective,
)
return true
})
}
}

func hasExperimentalMarker(groups ...*ast.CommentGroup) bool {
for _, group := range groups {
if group == nil {
continue
}
for _, line := range strings.Split(group.Text(), "\n") {
if strings.HasPrefix(strings.TrimSpace(line), experimentalMarker) {
return true
}
}
}
return false
}

type suppressionIndex map[int]struct{}

func collectSuppressions(pass *analysis.Pass, file *ast.File) suppressionIndex {
lines := make(suppressionIndex)
for _, group := range file.Comments {
for _, comment := range group.List {
if hasSuppressionDirective(comment.Text) {
line := pass.Fset.PositionFor(comment.Slash, false).Line
lines[line] = struct{}{}
}
}
}
return lines
}

func (index suppressionIndex) contains(pass *analysis.Pass, pos token.Pos) bool {
line := pass.Fset.PositionFor(pos, false).Line
_, ok := index[line]
return ok
}

func hasSuppressionDirective(text string) bool {
text = normalizeCommentText(text)
if !strings.HasPrefix(text, "nolint:") {
return false
}

directives := strings.TrimSpace(strings.TrimPrefix(text, "nolint:"))
if directives == "" {
return false
}

field := strings.Fields(directives)[0]
for _, directive := range strings.Split(field, ",") {
if strings.TrimSpace(directive) == analyzerName {
return true
}
}
return false
}

func normalizeCommentText(text string) string {
text = strings.TrimSpace(text)
text = strings.TrimPrefix(text, "//")
text = strings.TrimPrefix(text, "/*")
text = strings.TrimSuffix(text, "*/")
return strings.TrimSpace(text)
}
13 changes: 13 additions & 0 deletions go/copilotexperimental/experimental_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package copilotexperimental_test

import (
"testing"

"golang.org/x/tools/go/analysis/analysistest"

"github.com/github/copilot-sdk/go/copilotexperimental"
)

func TestAnalyzer(t *testing.T) {
analysistest.Run(t, analysistest.TestData(), copilotexperimental.Analyzer, "sdk", "consumer")
}
10 changes: 10 additions & 0 deletions go/copilotexperimental/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module github.com/github/copilot-sdk/go/copilotexperimental

go 1.24

require golang.org/x/tools v0.28.0
Comment thread
stephentoub marked this conversation as resolved.

require (
golang.org/x/mod v0.22.0 // indirect
golang.org/x/sync v0.10.0 // indirect
)
8 changes: 8 additions & 0 deletions go/copilotexperimental/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
24 changes: 24 additions & 0 deletions go/copilotexperimental/testdata/src/consumer/consumer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package consumer

import "sdk"

func useStable() {
_ = sdk.StableGreeting("world")
client := &sdk.Client{Name: "ok"}
client.Connect()
}

func useExperimental() {
_ = sdk.StartCanvas() // want `experimental API 'StartCanvas'`
var options sdk.CanvasOptions // want `experimental API 'CanvasOptions'`
options.Title = "x"
_ = options

client := &sdk.Client{}
client.EnableMCPApps = true // want `experimental API 'EnableMCPApps'`
client.EnableExperimentalMode() // want `experimental API 'EnableExperimentalMode'`
}

func optedIn() {
_ = sdk.StartCanvas() //nolint:copilotexperimental
}
38 changes: 38 additions & 0 deletions go/copilotexperimental/testdata/src/sdk/sdk.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Package sdk is a miniature stand-in for the generated Copilot SDK surface.
package sdk

// StableGreeting is a stable API.
func StableGreeting(name string) string {
return "Hello, " + name
}

// StartCanvas starts an experimental canvas session.
//
// Experimental: StartCanvas is an experimental API and may change or be removed.
func StartCanvas() string { // want StartCanvas:"experimental"
return "canvas"
}

// CanvasOptions configures a canvas.
//
// Experimental: CanvasOptions is part of an experimental API and may change or be removed.
type CanvasOptions struct { // want CanvasOptions:"experimental"
Title string
}

// Client is a stable client.
type Client struct {
// Name is a stable field.
Name string

// Experimental: EnableMCPApps is part of an experimental wire-protocol surface and may change or be removed.
EnableMCPApps bool // want EnableMCPApps:"experimental"
}

// Connect is a stable method.
func (c *Client) Connect() {}

// EnableExperimentalMode enables an experimental mode.
//
// Experimental: EnableExperimentalMode is an experimental API and may change or be removed.
func (c *Client) EnableExperimentalMode() {} // want EnableExperimentalMode:"experimental"
Loading
Loading