Skip to content

Commit 03e9162

Browse files
noahdietzjba
authored andcommitted
apidiff: add module comparison support
Change-Id: I11fa1aac30c8e2131bd0dd948fdcaf15332f8deb Reviewed-on: https://go-review.googlesource.com/c/exp/+/495635 Reviewed-by: Jonathan Amsterdam <[email protected]> Run-TryBot: Jonathan Amsterdam <[email protected]> TryBot-Result: Gopher Robot <[email protected]>
1 parent f3d0a9c commit 03e9162

File tree

5 files changed

+565
-41
lines changed

5 files changed

+565
-41
lines changed

apidiff/README.md

+116-10
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
1-
# Checking Go Package API Compatibility
1+
# Checking Go API Compatibility
22

3-
The `apidiff` tool in this directory determines whether two versions of the same
4-
package are compatible. The goal is to help the developer make an informed
5-
choice of semantic version after they have changed the code of their module.
3+
The `apidiff` tool in this directory determines whether two examples of a
4+
package or module are compatible. The goal is to help the developer make an
5+
informed choice of semantic version after they have changed the code of their
6+
module.
67

78
`apidiff` reports two kinds of changes: incompatible ones, which require
89
incrementing the major part of the semantic version, and compatible ones, which
910
require a minor version increment. If no API changes are reported but there are
1011
code changes that could affect client code, then the patch version should
1112
be incremented.
1213

13-
Because `apidiff` ignores package import paths, it may be used to display API
14-
differences between any two packages, not just different versions of the same
15-
package.
16-
17-
The current version of `apidiff` compares only packages, not modules.
18-
14+
`apidiff` may be used to display API differences between any two packages or
15+
modules, not just different versions of the same thing. It does this by ignoring
16+
the package import paths when directly comparing two packages, and
17+
by ignoring module paths when comparing two modules. That is to say, when
18+
comparing two modules, the package import paths **do** matter, but are compared
19+
_relative_ to their respective module root.
1920

2021
## Compatibility Desiderata
2122

@@ -222,6 +223,111 @@ element types correspond.
222223

223224
We can now present the definition of compatibility used by `apidiff`.
224225

226+
### Module Compatibility
227+
228+
> A new module is compatible with an old one if:
229+
>1. Each package present in the old module also appears in the new module,
230+
> with matching import paths relative to their respective module root, and
231+
>2. Each package present in both modules fulfills Package Compatibility as
232+
> defined below.
233+
>
234+
>Otherwise the modules are incompatible.
235+
236+
If a package is converted into a nested module of the original module then
237+
comparing two versions of the module, before and after nested module creation,
238+
will produce an incompatible package removal message. This removal message does
239+
not necessarily mean that client code will need to change. If the package API
240+
retains Package Compatibility after nested module creation, then only the
241+
`go.mod` of the client code will need to change. Take the following example:
242+
243+
```
244+
./
245+
go.mod
246+
go.sum
247+
foo.go
248+
bar/bar.go
249+
```
250+
251+
Where `go.mod` is:
252+
253+
```
254+
module example.com/foo
255+
256+
go 1.20
257+
```
258+
259+
Where `bar/bar.go` is:
260+
261+
```
262+
package bar
263+
264+
var V int
265+
```
266+
267+
And `foo.go` is:
268+
269+
```
270+
package foo
271+
272+
import "example.com/foo/bar"
273+
274+
_ = bar.V
275+
```
276+
277+
Creating a nested module with the package `bar` while retaining Package
278+
Compatibility is _code_ compatible, because the import path of the package does
279+
not change:
280+
281+
```
282+
./
283+
go.mod
284+
go.sum
285+
foo.go
286+
bar/
287+
bar.go
288+
go.mod
289+
go.sum
290+
```
291+
292+
Where `bar/go.mod` is:
293+
```
294+
module example.com/foo/bar
295+
296+
go 1.20
297+
```
298+
299+
And the top-level `go.mod` becomes:
300+
```
301+
module example.com/foo
302+
303+
go 1.20
304+
305+
// New dependency on nested module.
306+
require example.com/foo/bar v1.0.0
307+
```
308+
309+
If during nested module creation either Package Compatibility is broken, like so
310+
in `bar/bar.go`:
311+
312+
```
313+
package bar
314+
315+
// Changed from V to T.
316+
var T int
317+
```
318+
319+
Or the nested module uses a name other than the original package's import path,
320+
like so in `bar/go.mod`:
321+
322+
```
323+
// Completely different module name
324+
module example.com/qux
325+
326+
go 1.20
327+
```
328+
329+
Then the move is backwards incompatible for client code.
330+
225331
### Package Compatibility
226332

227333
> A new package is compatible with an old one if:

apidiff/apidiff.go

+59-2
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ import (
1515
"go/constant"
1616
"go/token"
1717
"go/types"
18+
"strings"
1819
)
1920

2021
// Changes reports on the differences between the APIs of the old and new packages.
2122
// It classifies each difference as either compatible or incompatible (breaking.) For
22-
// a detailed discussion of what constitutes an incompatible change, see the package
23-
// documentation.
23+
// a detailed discussion of what constitutes an incompatible change, see the README.
2424
func Changes(old, new *types.Package) Report {
2525
d := newDiffer(old, new)
2626
d.checkPackage()
@@ -34,6 +34,63 @@ func Changes(old, new *types.Package) Report {
3434
return r
3535
}
3636

37+
// ModuleChanges reports on the differences between the APIs of the old and new
38+
// modules. It classifies each difference as either compatible or incompatible
39+
// (breaking). This includes the addition and removal of entire packages. For a
40+
// detailed discussion of what constitutes an incompatible change, see the README.
41+
func ModuleChanges(old, new *Module) Report {
42+
var r Report
43+
44+
oldPkgs := make(map[string]*types.Package)
45+
for _, p := range old.Packages {
46+
oldPkgs[old.relativePath(p)] = p
47+
}
48+
49+
newPkgs := make(map[string]*types.Package)
50+
for _, p := range new.Packages {
51+
newPkgs[new.relativePath(p)] = p
52+
}
53+
54+
for n, op := range oldPkgs {
55+
if np, ok := newPkgs[n]; ok {
56+
// shared package, compare surfaces
57+
rr := Changes(op, np)
58+
r.Changes = append(r.Changes, rr.Changes...)
59+
} else {
60+
// old package was removed
61+
r.Changes = append(r.Changes, packageChange(op, "removed", false))
62+
}
63+
}
64+
65+
for n, np := range newPkgs {
66+
if _, ok := oldPkgs[n]; !ok {
67+
// new package was added
68+
r.Changes = append(r.Changes, packageChange(np, "added", true))
69+
}
70+
}
71+
72+
return r
73+
}
74+
75+
func packageChange(p *types.Package, change string, compatible bool) Change {
76+
return Change{
77+
Message: fmt.Sprintf("package %s: %s", p.Path(), change),
78+
Compatible: compatible,
79+
}
80+
}
81+
82+
// Module is a convenience type for representing a Go module with a path and a
83+
// slice of Packages contained within.
84+
type Module struct {
85+
Path string
86+
Packages []*types.Package
87+
}
88+
89+
// relativePath computes the module-relative package path of the given Package.
90+
func (m *Module) relativePath(p *types.Package) string {
91+
return strings.TrimPrefix(p.Path(), m.Path)
92+
}
93+
3794
type differ struct {
3895
old, new *types.Package
3996
// Correspondences between named types.

apidiff/apidiff_test.go

+91-4
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,71 @@ import (
1414

1515
"github.com/google/go-cmp/cmp"
1616
"golang.org/x/tools/go/packages"
17+
"golang.org/x/tools/go/packages/packagestest"
1718
)
1819

20+
func TestModuleChanges(t *testing.T) {
21+
packagestest.TestAll(t, testModuleChanges)
22+
}
23+
24+
func testModuleChanges(t *testing.T, x packagestest.Exporter) {
25+
e := packagestest.Export(t, x, []packagestest.Module{
26+
{
27+
Name: "example.com/moda",
28+
Files: map[string]any{
29+
"foo/foo.go": "package foo\n\nconst Version = 1",
30+
"foo/baz/baz.go": "package baz",
31+
},
32+
},
33+
{
34+
Name: "example.com/modb",
35+
Files: map[string]any{
36+
"foo/foo.go": "package foo\n\nconst Version = 2\nconst Other = 1",
37+
"bar/bar.go": "package bar",
38+
},
39+
},
40+
})
41+
defer e.Cleanup()
42+
43+
a, err := loadModule(t, e.Config, "example.com/moda")
44+
if err != nil {
45+
t.Fatal(err)
46+
}
47+
b, err := loadModule(t, e.Config, "example.com/modb")
48+
if err != nil {
49+
t.Fatal(err)
50+
}
51+
report := ModuleChanges(a, b)
52+
if len(report.Changes) == 0 {
53+
t.Fatal("expected some changes, but got none")
54+
}
55+
wanti := []string{
56+
"Version: value changed from 1 to 2",
57+
"package example.com/moda/foo/baz: removed",
58+
}
59+
sort.Strings(wanti)
60+
61+
got := report.messages(false)
62+
sort.Strings(got)
63+
64+
if diff := cmp.Diff(wanti, got); diff != "" {
65+
t.Errorf("incompatibles: mismatch (-want, +got)\n%s", diff)
66+
}
67+
68+
wantc := []string{
69+
"Other: added",
70+
"package example.com/modb/bar: added",
71+
}
72+
sort.Strings(wantc)
73+
74+
got = report.messages(true)
75+
sort.Strings(got)
76+
77+
if diff := cmp.Diff(wantc, got); diff != "" {
78+
t.Errorf("compatibles: mismatch (-want, +got)\n%s", diff)
79+
}
80+
}
81+
1982
func TestChanges(t *testing.T) {
2083
dir, err := os.MkdirTemp("", "apidiff_test")
2184
if err != nil {
@@ -27,11 +90,11 @@ func TestChanges(t *testing.T) {
2790
sort.Strings(wanti)
2891
sort.Strings(wantc)
2992

30-
oldpkg, err := load(t, "apidiff/old", dir)
93+
oldpkg, err := loadPackage(t, "apidiff/old", dir)
3194
if err != nil {
3295
t.Fatal(err)
3396
}
34-
newpkg, err := load(t, "apidiff/new", dir)
97+
newpkg, err := loadPackage(t, "apidiff/new", dir)
3598
if err != nil {
3699
t.Fatal(err)
37100
}
@@ -116,7 +179,31 @@ func splitIntoPackages(t *testing.T, dir string) (incompatibles, compatibles []s
116179
return
117180
}
118181

119-
func load(t *testing.T, importPath, goPath string) (*packages.Package, error) {
182+
// Copied from cmd/apidiff/main.go.
183+
func loadModule(t *testing.T, cfg *packages.Config, modulePath string) (*Module, error) {
184+
needsGoPackages(t)
185+
186+
cfg.Mode = cfg.Mode | packages.LoadTypes
187+
loaded, err := packages.Load(cfg, fmt.Sprintf("%s/...", modulePath))
188+
if err != nil {
189+
return nil, err
190+
}
191+
if len(loaded) == 0 {
192+
return nil, fmt.Errorf("found no packages for module %s", modulePath)
193+
}
194+
var tpkgs []*types.Package
195+
for _, p := range loaded {
196+
if len(p.Errors) > 0 {
197+
// TODO: use errors.Join once Go 1.21 is released.
198+
return nil, p.Errors[0]
199+
}
200+
tpkgs = append(tpkgs, p.Types)
201+
}
202+
203+
return &Module{Path: modulePath, Packages: tpkgs}, nil
204+
}
205+
206+
func loadPackage(t *testing.T, importPath, goPath string) (*packages.Package, error) {
120207
needsGoPackages(t)
121208

122209
cfg := &packages.Config{
@@ -137,7 +224,7 @@ func load(t *testing.T, importPath, goPath string) (*packages.Package, error) {
137224
}
138225

139226
func TestExportedFields(t *testing.T) {
140-
pkg, err := load(t, "golang.org/x/exp/apidiff/testdata/exported_fields", "")
227+
pkg, err := loadPackage(t, "golang.org/x/exp/apidiff/testdata/exported_fields", "")
141228
if err != nil {
142229
t.Fatal(err)
143230
}

0 commit comments

Comments
 (0)