blob: 30943dbf28a590aa000bff61f644525d698dac5e [file] [log] [blame]
// Copyright 2019 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"bytes"
"fmt"
"io"
"strings"
"golang.org/x/exp/apidiff"
"golang.org/x/mod/module"
"golang.org/x/mod/semver"
"golang.org/x/tools/go/packages"
)
// report describes the differences in the public API between two versions
// of a module.
type report struct {
// modulePath is the name of the module.
modulePath string
// baseVersion is the "old" version of the module to compare against.
// It may be empty if there is no base version (for example, if this is
// the first release).
baseVersion string
// releaseVersion is the version of the module to release, either
// proposed with -version or inferred with suggestVersion.
releaseVersion string
// releaseVersionInferred is true if the release version was suggested
// (not specified with -version).
releaseVersionInferred bool
// tagPrefix is the prefix for VCS tags for this module. For example,
// if the module is defined in "foo/bar/v2/go.mod", tagPrefix will be
// "foo/bar/".
tagPrefix string
// packages is a list of package reports, describing the differences
// for individual packages, sorted by package path.
packages []packageReport
// diagnostics is a list of problems unrelated to the module API.
// For example, if go.mod is missing some requirements, that will be
// reported here.
diagnostics []string
// versionInvalid explains why the proposed or suggested version is not valid.
versionInvalid *versionMessage
// haveCompatibleChanges is true if there are any backward-compatible
// changes in non-internal packages.
haveCompatibleChanges bool
// haveIncompatibleChanges is true if there are any backward-incompatible
// changes in non-internal packages.
haveIncompatibleChanges bool
// haveBaseErrors is true if there were errors loading packages
// in the base version.
haveBaseErrors bool
// haveReleaseErrors is true if there were errors loading packages
// in the release version.
haveReleaseErrors bool
}
// Text formats and writes a report to w. The report lists errors, compatible
// changes, and incompatible changes in each package. If releaseVersion is set,
// it states whether releaseVersion is valid (and why). If releaseVersion is not
// set, it suggests a new version.
func (r *report) Text(w io.Writer) error {
buf := &bytes.Buffer{}
for _, p := range r.packages {
if err := p.Text(buf); err != nil {
return err
}
}
if len(r.diagnostics) > 0 {
for _, d := range r.diagnostics {
fmt.Fprintln(buf, d)
}
} else if r.versionInvalid != nil {
fmt.Fprintln(buf, r.versionInvalid)
} else if r.releaseVersionInferred {
if r.tagPrefix == "" {
fmt.Fprintf(buf, "Suggested version: %s\n", r.releaseVersion)
} else {
fmt.Fprintf(buf, "Suggested version: %[1]s (with tag %[2]s%[1]s)\n", r.releaseVersion, r.tagPrefix)
}
} else {
if r.tagPrefix == "" {
fmt.Fprintf(buf, "%s is a valid semantic version for this release.\n", r.releaseVersion)
} else {
fmt.Fprintf(buf, "%[1]s (with tag %[2]s%[1]s) is a valid semantic version for this release\n", r.releaseVersion, r.tagPrefix)
}
}
if r.versionInvalid == nil && r.haveBaseErrors {
fmt.Fprintln(buf, "Errors were found in the base version. Some API changes may be omitted.")
}
_, err := io.Copy(w, buf)
return err
}
func (r *report) addPackage(p packageReport) {
r.packages = append(r.packages, p)
if len(p.baseErrors) == 0 && len(p.releaseErrors) == 0 {
// Only count compatible and incompatible changes if there were no errors.
// When there are errors, definitions may be missing, and fixes may appear
// incompatible when they are not. Changes will still be reported, but
// they won't affect version validation or suggestions.
for _, c := range p.Changes {
if !c.Compatible && len(p.releaseErrors) == 0 {
r.haveIncompatibleChanges = true
} else if c.Compatible && len(p.baseErrors) == 0 && len(p.releaseErrors) == 0 {
r.haveCompatibleChanges = true
}
}
}
if len(p.baseErrors) > 0 {
r.haveBaseErrors = true
}
if len(p.releaseErrors) > 0 {
r.haveReleaseErrors = true
}
}
// validateVersion checks whether r.releaseVersion is valid.
// If r.releaseVersion is not valid, an error is returned explaining why.
// r.releaseVersion must be set.
func (r *report) validateVersion() {
if r.releaseVersion == "" {
panic("validateVersion called without version")
}
setNotValid := func(format string, args ...interface{}) {
r.versionInvalid = &versionMessage{
message: fmt.Sprintf("%s is not a valid semantic version for this release.", r.releaseVersion),
reason: fmt.Sprintf(format, args...),
}
}
if r.haveReleaseErrors {
if r.haveReleaseErrors {
setNotValid("Errors were found in one or more packages.")
return
}
}
// TODO(jayconrod): link to documentation for all of these errors.
// Check that the major version matches the module path.
_, suffix, ok := module.SplitPathVersion(r.modulePath)
if !ok {
setNotValid("%s: could not find version suffix in module path", r.modulePath)
return
}
if suffix != "" {
if suffix[0] != '/' && suffix[0] != '.' {
setNotValid("%s: unknown module path version suffix: %q", r.modulePath, suffix)
return
}
pathMajor := suffix[1:]
major := semver.Major(r.releaseVersion)
if pathMajor != major {
setNotValid(`The major version %s does not match the major version suffix
in the module path: %s`, major, r.modulePath)
return
}
} else if major := semver.Major(r.releaseVersion); major != "v0" && major != "v1" {
setNotValid(`The module path does not end with the major version suffix /%s,
which is required for major versions v2 or greater.`, major)
return
}
// Check that compatible / incompatible changes are consistent.
if semver.Major(r.baseVersion) == "v0" {
return
}
if r.haveIncompatibleChanges {
setNotValid("There are incompatible changes.")
return
}
if r.haveCompatibleChanges && semver.MajorMinor(r.baseVersion) == semver.MajorMinor(r.releaseVersion) {
setNotValid(`There are compatible changes, but the minor version is not incremented
over the base version (%s).`, r.baseVersion)
return
}
}
// suggestVersion suggests a new version consistent with observed changes.
func (r *report) suggestVersion() {
setNotValid := func(format string, args ...interface{}) {
r.versionInvalid = &versionMessage{
message: "Cannot suggest a release version.",
reason: fmt.Sprintf(format, args...),
}
}
setVersion := func(v string) {
r.releaseVersion = v
r.releaseVersionInferred = true
}
if r.haveReleaseErrors || r.haveBaseErrors {
setNotValid("Errors were found.")
return
}
var major, minor, patch, pre string
if r.baseVersion != "none" {
var err error
major, minor, patch, pre, _, err = parseVersion(r.baseVersion)
if err != nil {
panic(fmt.Sprintf("could not parse base version: %v", err))
}
}
if r.haveIncompatibleChanges && r.baseVersion != "none" && pre == "" && major != "0" {
setNotValid("Incompatible changes were detected.")
return
// TODO(jayconrod): briefly explain how to prepare major version releases
// and link to documentation.
}
if r.baseVersion == "none" {
if _, pathMajor, ok := module.SplitPathVersion(r.modulePath); !ok {
panic(fmt.Sprintf("could not parse module path %q", r.modulePath))
} else if pathMajor == "" {
setVersion("v0.1.0")
} else {
setVersion(pathMajor[1:] + ".0.0")
}
return
}
if pre != "" {
// suggest non-prerelease version
} else if r.haveCompatibleChanges || (r.haveIncompatibleChanges && major == "0") {
minor = incDecimal(minor)
patch = "0"
} else {
patch = incDecimal(patch)
}
setVersion(fmt.Sprintf("v%s.%s.%s", major, minor, patch))
}
// isSuccessful returns true the module appears to be safe to release at the
// proposed or suggested version.
func (r *report) isSuccessful() bool {
return len(r.diagnostics) == 0 && r.versionInvalid == nil
}
type versionMessage struct {
message, reason string
}
func (m versionMessage) String() string {
return m.message + "\n" + m.reason + "\n"
}
// incDecimal returns the decimal string incremented by 1.
func incDecimal(decimal string) string {
// Scan right to left turning 9s to 0s until you find a digit to increment.
digits := []byte(decimal)
i := len(digits) - 1
for ; i >= 0 && digits[i] == '9'; i-- {
digits[i] = '0'
}
if i >= 0 {
digits[i]++
} else {
// digits is all zeros
digits[0] = '1'
digits = append(digits, '0')
}
return string(digits)
}
type packageReport struct {
apidiff.Report
path string
baseErrors, releaseErrors []packages.Error
}
func (p *packageReport) Text(w io.Writer) error {
if len(p.Changes) == 0 && len(p.baseErrors) == 0 && len(p.releaseErrors) == 0 {
return nil
}
buf := &bytes.Buffer{}
fmt.Fprintf(buf, "%s\n%s\n", p.path, strings.Repeat("-", len(p.path)))
if len(p.baseErrors) > 0 {
fmt.Fprintf(buf, "errors in base version:\n")
for _, e := range p.baseErrors {
fmt.Fprintf(buf, "\t%v\n", e)
}
buf.WriteByte('\n')
}
if len(p.releaseErrors) > 0 {
fmt.Fprintf(buf, "errors in release version:\n")
for _, e := range p.releaseErrors {
fmt.Fprintf(buf, "\t%v\n", e)
}
buf.WriteByte('\n')
}
if len(p.Changes) > 0 {
if err := p.Report.Text(buf); err != nil {
return err
}
buf.WriteByte('\n')
}
_, err := io.Copy(w, buf)
return err
}
// parseVersion returns the major, minor, and patch numbers, prerelease text,
// and metadata for a given version.
//
// TODO(jayconrod): extend semver to do this and delete this function.
func parseVersion(vers string) (major, minor, patch, pre, meta string, err error) {
if !strings.HasPrefix(vers, "v") {
return "", "", "", "", "", fmt.Errorf("version %q does not start with 'v'", vers)
}
base := vers[1:]
if i := strings.IndexByte(base, '+'); i >= 0 {
meta = base[i+1:]
base = base[:i]
}
if i := strings.IndexByte(base, '-'); i >= 0 {
pre = base[i+1:]
base = base[:i]
}
parts := strings.Split(base, ".")
if len(parts) != 3 {
return "", "", "", "", "", fmt.Errorf("version %q should have three numbers", vers)
}
major, minor, patch = parts[0], parts[1], parts[2]
return major, minor, patch, pre, meta, nil
}