blob: 9084932b2e52aaa79d01daea3a495bbd1f1c86fe [file]
// Copyright 2026 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.
// The evaluations page, which displays signals for
// package and module quality.
package frontend
import (
"bytes"
"context"
"fmt"
"go/ast"
"go/printer"
"go/token"
"math"
"strings"
"time"
"golang.org/x/mod/semver"
"golang.org/x/pkgsite/internal"
"golang.org/x/pkgsite/internal/godoc"
"golang.org/x/pkgsite/internal/godoc/dochtml"
"golang.org/x/pkgsite/internal/version"
)
type evalType struct {
Label string // displayed on the page
Description string // displayed from "?" icon
MaxScore int // number of bars
}
var (
licenseEval = &evalType{
Label: "License for this package or module",
Description: `The module license.
0: no license
1: non-redistributable license
2: redistributable license`,
MaxScore: 2,
}
moduleVersionEval = &evalType{
Label: "Module version",
Description: `Version of this module.
0: untagged
1: tagged, unstable (v0)
2: tagged, stable (v1 or higher)`,
MaxScore: 2,
}
docCoverageEval = &evalType{
Label: "Documentation coverage",
Description: `Score for documentation on package and exported symbols.
The score is 10% whether the package has a comment, 90% the fraction of exported
symbols that are commented. The format of the comments doesn't matter.
0: no comments
1: 1% - 20%
2: 21% - 40%
3: 41% - 60%
4: 61% - 80%
5: 81% - 100%`,
MaxScore: 5,
}
)
type eval struct {
Type *evalType
Value string // displayed on the page
Score int // number of colored bars
}
type evalsDetails struct {
Evals []eval
}
func fetchEvalsDetails(ctx context.Context, ds internal.DataSource, um *internal.UnitMeta) (*evalsDetails, error) {
u, err := ds.GetUnit(ctx, um, internal.WithLicenses|internal.WithDocsSource, internal.BuildContext{})
if err != nil {
return nil, err
}
licEval := eval{Type: licenseEval}
switch {
case len(u.LicenseContents) == 0:
licEval.Score = 0
licEval.Value = "no license"
case !u.IsRedistributable:
licEval.Score = 1
licEval.Value = "non-redistributable license"
default:
licEval.Score = 2
licEval.Value = "redistributable license"
}
modEval := eval{Type: moduleVersionEval}
versionType, err := version.ParseType(um.Version)
if err != nil {
return nil, err
}
switch {
case version.IsPseudo(um.Version) || !semver.IsValid(um.Version):
modEval.Score = 0
modEval.Value = "untagged"
case semver.Major(um.Version) == "v0" || versionType == version.TypePrerelease:
modEval.Score = 1
modEval.Value = "tagged, unstable"
default:
modEval.Score = 2
modEval.Value = "tagged, stable"
}
var docPkg *godoc.Package
if len(u.Documentation) > 0 && len(u.Documentation[0].Source) > 0 {
var err error
docPkg, err = godoc.DecodePackage(u.Documentation[0].Source)
if err != nil {
return nil, fmt.Errorf("decoding package: %w", err)
}
}
docEval := evalDocCoverage(summarizeDocumentation(docPkg))
return &evalsDetails{
Evals: []eval{licEval, modEval, docEval},
}, nil
}
// evalDocCoverage converts a docSummary into an evaluation.
// The fraction of exported symbols documented is combined
// with the presence of a package comment to produce a raw Score out of 100.
// The number of bars (docEval.Score) divides this range into groups of 20.
// Thus a 5% score is 1 bar, an 82% score is 5 bars, and so on.
//
// The presence of a package doc is worth packageDocFraction, and the fraction
// of documented symbols counts for the rest. For example, if packageDocFraction = 10%,
// then the raw score is:
//
// 10% * (1 if package doc else 0) + 90% * (fraction of doc symbols)
func evalDocCoverage(summary docSummary) eval {
const packageDocFraction = 0.10
docEval := eval{Type: docCoverageEval}
if !summary.packageHasDoc && summary.numHaveDoc == 0 {
docEval.Score = 0
docEval.Value = "no comments"
return docEval
}
var rawScore float64
if summary.packageHasDoc {
rawScore += packageDocFraction
}
f := 1.0
if summary.numExportedSymbols > 0 {
f = float64(summary.numHaveDoc) / float64(summary.numExportedSymbols)
}
rawScore += (1 - packageDocFraction) * f
// The score (number of bars) is basically the percentage divided by 20.
pct := int(math.Round(rawScore * 100))
if pct == 0 {
// We know rawScore > 0, so don't use a zero here. Reserve that for
// when there is really no documentation at all.
docEval.Score = 1
// Show percentage to one decimal; "0%" would be confusing.
// (Although we might still get "0.0%" sometimes.)
docEval.Value = fmt.Sprintf("%.1f%%", rawScore*100)
} else {
// Map a percentage from 1 to 100 to a score from 1 to 5,
// with 1-20 => 1, 21-40 => 2, etc.
docEval.Score = (pct-1)/20 + 1
docEval.Value = fmt.Sprintf("%d%%", pct)
}
return docEval
}
const (
day = 24 * time.Hour
month = 31 * day
year = 365 * day
)
// ageString formats a time.Duration into a human-readable age string.
func ageString(dur time.Duration) string {
pluralize := func(n time.Duration, s string) string {
if n == 1 {
return fmt.Sprintf("1 %s", s)
}
return fmt.Sprintf("%d %ss", n, s)
}
if dur < day {
return "less than a day"
}
if dur < month {
return pluralize(dur/day, "day")
}
if dur < year {
monthStr := pluralize(dur/month, "month")
days := (dur % month) / day
if days == 0 {
return monthStr
}
return monthStr + ", " + pluralize(days, "day")
}
yearStr := pluralize(dur/year, "year")
months := (dur % year) / month
if months == 0 {
return yearStr
}
return yearStr + ", " + pluralize(months, "month")
}
// docSummary is a summary of a package's documentation, intended
// for creating an evaluation.
type docSummary struct {
packageHasDoc bool
numExportedSymbols int // that need documentation
numHaveDoc int // number of exported symbols that need doc and have it
}
// summarizeDocumentation produces a docSummary for the given package.
func summarizeDocumentation(docPkg *godoc.Package) docSummary {
var summary docSummary
if docPkg == nil || len(docPkg.Files) == 0 {
return summary
}
var files []*ast.File
for _, f := range docPkg.Files {
if f == nil || f.AST == nil {
continue
}
if strings.HasSuffix(f.Name, "_test.go") {
continue
}
files = append(files, f.AST)
}
if len(files) == 0 {
return summary
}
// We don't insist that main packages have doc.
// TODO(jba): consider checking the documentation of main packages.
// They should at least have a package doc.
// On the other hand, many main packages have an extensive README.md but
// no package doc.
pkgName := files[0].Name.Name
if pkgName == "main" {
return summary
}
for _, file := range files {
if file.Doc != nil {
summary.packageHasDoc = true
}
}
CollectSymbols(files, func(_ *ast.Ident, has bool) {
summary.numExportedSymbols++
if has {
summary.numHaveDoc++
}
})
return summary
}
// collectInterfaceMethods walks files looking for top-level exported interface
// declarations. For every exported method in those interfaces (including methods
// embedded from other interfaces) that has documentation, it records a mapping
// from the method's name to its signature (e.g., "() string").
// This map is formatted identically to conventionalMethods.
func collectInterfaceMethods(files []*ast.File) map[interfaceMethod]bool {
// First pass: index all top-level interface declarations in the package by their
// type name (both exported and unexported). We need to index unexported interfaces
// as well because an exported interface may embed an unexported interface from the
// same package, and we must be able to look up its AST to collect those embedded methods.
ifaceMap := make(map[string]*ast.InterfaceType)
for _, file := range files {
for _, decl := range file.Decls {
gd, ok := decl.(*ast.GenDecl)
if !ok || gd.Tok != token.TYPE {
continue
}
for _, spec := range gd.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
if t, ok := ts.Type.(*ast.InterfaceType); ok {
ifaceMap[ts.Name.Name] = t
}
}
}
}
methods := make(map[interfaceMethod]bool)
// collect visits all methods defined in itype and adds any exported,
// documented methods to the methods map. When it encounters an embedded interface
// (or type constraint), it resolves the interface name in ifaceMap and recursively
// collects from the embedded interface. The visited map tracks interfaces currently
// being traversed to prevent infinite recursion in case of cyclic or repeated embedding.
var collect func(itype *ast.InterfaceType, visited map[string]bool)
collect = func(itype *ast.InterfaceType, visited map[string]bool) {
if itype == nil || itype.Methods == nil {
return
}
for _, field := range itype.Methods.List {
if len(field.Names) == 0 { // embedded interface or union
embeddedName := identName(field.Type)
if embeddedName != "" && !visited[embeddedName] {
if ei, ok := ifaceMap[embeddedName]; ok {
visited[embeddedName] = true
collect(ei, visited)
}
}
continue
}
ftype, ok := field.Type.(*ast.FuncType)
if !ok {
continue
}
if !hasComment(field.Doc) && !hasComment(field.Comment) {
continue
}
// There should only be one name, but just to play it safe, loop.
for _, name := range field.Names {
if name.IsExported() {
methods[interfaceMethod{name.Name, sigString(ftype)}] = true
}
}
}
}
// Second pass: iterate over all collected interfaces and initiate method collection
// only for top-level exported interfaces. Methods from unexported interfaces will
// only be included if they were embedded within one of these exported interfaces.
visited := make(map[string]bool)
for name, itype := range ifaceMap {
if ast.IsExported(name) && !visited[name] {
visited[name] = true
collect(itype, visited)
}
}
return methods
}
// CollectSymbols visits files looking for exported symbols that need
// documentation. It calls add(id, true) for a symbol if it has documentation,
// and add(id, false) if it does not, where id is the *ast.Ident of the symbol.
//
// This signature is overkill for production use, where we just want to count.
// But it's useful for tests, to compare sets of symbols.
//
// This function accepts various forms of documentation, such as trailing line
// comments or group-level comments, to capture a broad signal of documented
// API surface. It does not enforce standard Go documentation formatting
// (e.g., "Name does...").
func CollectSymbols(files []*ast.File, add func(id *ast.Ident, has bool)) {
for _, file := range files {
if file != nil && file.Doc != nil && dochtml.IsDeprecated(file.Doc.Text()) {
return
}
}
ifaceMethods := collectInterfaceMethods(files)
// specDoc finds the doc comment for a spec. Typically this will be doc itself,
// but if absent:
// - for consts and vars, we use the comment on the enclosing GenDecl (d.Doc)
// - for types, we only use the GenDecl comment if this is a single-spec declaration
// (e.g. `type T struct{}`),
// If both comments are absent, comment is a trailing line comment on the same
// line, which we generously accept.
specDoc := func(doc, comment *ast.CommentGroup, d *ast.GenDecl) *ast.CommentGroup {
if doc != nil {
return doc
}
if d.Doc != nil && (d.Tok != token.TYPE || len(d.Specs) == 1) {
return d.Doc
}
return comment
}
// Walk the top-level declarations of every file.
for _, file := range files {
for _, decl := range file.Decls {
switch d := decl.(type) {
case *ast.FuncDecl:
// If a method, both the method and the receiver must be exported.
if !d.Name.IsExported() {
continue
}
if d.Recv != nil {
recvName := recvTypeName(d.Recv)
if !ast.IsExported(recvName) {
continue
}
m := interfaceMethod{name: d.Name.Name, signature: sigString(d.Type)}
if conventionalMethods[m] || ifaceMethods[m] {
continue
}
}
add(d.Name, hasComment(d.Doc))
case *ast.GenDecl:
if d.Tok == token.IMPORT {
continue
}
for _, spec := range d.Specs {
switch s := spec.(type) {
case *ast.TypeSpec:
if s.Name.IsExported() {
doc := specDoc(s.Doc, s.Comment, d)
add(s.Name, hasComment(doc))
}
case *ast.ValueSpec:
for _, name := range s.Names {
if name.IsExported() {
doc := specDoc(s.Doc, s.Comment, d)
add(name, hasComment(doc))
}
}
}
}
}
}
}
}
// recvTypeName extracts the type name of the receiver from a receiver field list,
// handling pointer receivers and generic type instantiations (e.g., *T or T[P]).
// The receiver type is the first one in the list.
// It returns "" if it cannot find a receiver type.
func recvTypeName(recv *ast.FieldList) string {
if recv == nil || len(recv.List) == 0 {
return ""
}
return identName(recv.List[0].Type)
}
// identName extracts the name of an identifier from an expression,
// handling pointer expressions (*T) and generic type instantiations (T[P] or T[P1, P2]).
func identName(expr ast.Expr) string {
if ptr, ok := expr.(*ast.StarExpr); ok {
expr = ptr.X
}
switch x := expr.(type) {
case *ast.IndexExpr:
expr = x.X
case *ast.IndexListExpr:
expr = x.X
}
if ident, ok := expr.(*ast.Ident); ok {
return ident.Name
}
return ""
}
// hasComment reports whether the comment group actually contains a non-empty comment.
func hasComment(doc *ast.CommentGroup) bool {
return doc != nil && strings.TrimSpace(doc.Text()) != ""
}
// An interfaceMethod is a method from an interface.
type interfaceMethod struct {
name string
signature string
}
// conventionalMethods maps common, standard method names like String and Error
// to their signatures. These methods are often undocumented, and that's fine.
var conventionalMethods = map[interfaceMethod]bool{
{"String", "() string"}: true, // fmt.Stringer
{"Error", "() string"}: true, // error
{"Unwrap", "() error"}: true, // for errors.Unwrap
{"Len", "() int"}: true, // sort.Interface
{"Less", "(int, int) bool"}: true, // sort.Interface
{"Swap", "(int, int)"}: true, // sort.Interface
{"Read", "([]byte) (int, error)"}: true, // io.Reader
{"Close", "() error"}: true, // io.ReadCloser
{"Write", "([]byte) (int, error)"}: true, // io.Writer
{"MarshalJSON", "() ([]byte, error)"}: true, // json.Marshaler
{"UnmarshalJSON", "([]byte) error"}: true, // json.Unmarshaler
}
// sigString returns a string representation of a function signature
// without parameter or return names (e.g., "(int, int) bool").
func sigString(ft *ast.FuncType) string {
if ft == nil {
return ""
}
paramTypes := fieldListTypes(ft.Params)
resTypes := fieldListTypes(ft.Results)
var buf strings.Builder
buf.WriteString("(")
buf.WriteString(strings.Join(paramTypes, ", "))
buf.WriteString(")")
if len(resTypes) == 1 {
buf.WriteString(" ")
buf.WriteString(resTypes[0])
} else if len(resTypes) > 1 {
buf.WriteString(" (")
buf.WriteString(strings.Join(resTypes, ", "))
buf.WriteString(")")
}
return buf.String()
}
func fieldListTypes(fl *ast.FieldList) []string {
if fl == nil {
return nil
}
var types []string
for _, field := range fl.List {
n := len(field.Names)
if n == 0 {
n = 1
}
tstr := nodeString(field.Type)
for i := 0; i < n; i++ {
types = append(types, tstr)
}
}
return types
}
// nodeString returns a string for node.
func nodeString(node ast.Node) string {
var buf bytes.Buffer
printer.Fprint(&buf, token.NewFileSet(), node)
return buf.String()
}