| // 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" |
| "strings" |
| "time" |
| |
| "golang.org/x/mod/semver" |
| "golang.org/x/pkgsite/internal" |
| "golang.org/x/pkgsite/internal/godoc" |
| "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, |
| } |
| ) |
| |
| 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.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" |
| } |
| |
| return &evalsDetails{ |
| Evals: []eval{licEval, modEval}, |
| }, nil |
| } |
| |
| 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 |
| |
| // Empty package? Nothing to do. |
| if docPkg == nil || len(docPkg.Files) == 0 { |
| return summary |
| } |
| |
| // Collect non-test files. |
| 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) |
| } |
| |
| // No non-test files? Nothing to do. |
| if len(files) == 0 { |
| return summary |
| } |
| |
| // Main package? Nothing to do (we don't insist |
| // that mains 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 |
| } |
| |
| // Is there a package-level doc string? |
| for _, file := range files { |
| if file.Doc != nil { |
| summary.packageHasDoc = true |
| } |
| } |
| |
| // Count exported symbols with/without doc. |
| collectSymbols(files, func(_ string, has bool) { |
| summary.numExportedSymbols++ |
| if has { |
| summary.numHaveDoc++ |
| } |
| }) |
| |
| return summary |
| } |
| |
| // collectSymbols visits files looking for exported symbols that need |
| // documentation. It calls add(name, true) for a symbol if it has documentation, |
| // and add(name, false) if it does not. |
| // |
| // 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(name string, has bool)) { |
| |
| // specDoc finds the doc comment for a spec. Typically this will be doc itself, |
| // but if absent and this is a single-spec declaration (e.g. `type T struct{}`), |
| // d.Doc is the doc comment on the enclosing GenDecl. If both 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 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 |
| } |
| name := d.Name.Name |
| if d.Recv != nil { |
| recvName := recvTypeName(d.Recv) |
| if !ast.IsExported(recvName) { |
| continue |
| } |
| if isConventionalMethod(name, d.Type) { |
| continue |
| } |
| name = fmt.Sprintf("(%s).%s", recvName, name) |
| } |
| add(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.Name, hasComment(doc)) |
| } |
| case *ast.ValueSpec: |
| for _, name := range s.Names { |
| if name.IsExported() { |
| doc := specDoc(s.Doc, s.Comment, d) |
| add(name.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 "" |
| } |
| t := recv.List[0].Type |
| // Handle pointers. |
| if ptr, ok := t.(*ast.StarExpr); ok { |
| t = ptr.X |
| } |
| // Handle generics. |
| switch x := t.(type) { |
| case *ast.IndexExpr: |
| t = x.X |
| case *ast.IndexListExpr: |
| t = x.X |
| } |
| if ident, ok := t.(*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()) != "" |
| } |
| |
| // 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[string]string{ |
| "String": "() string", // fmt.Stringer |
| "Error": "() string", // error |
| "Unwrap": "() error", // for errors.Unwrap |
| "Len": "() int", // sort.Interface |
| "Less": "(int, int) bool", // sort.Interface |
| "Swap": "(int, int)", // sort.Interface |
| "Read": "([]byte) (int, error)", // io.Reader |
| "Close": "() error", // io.ReadCloser |
| "Write": "([]byte) (int, error)", // io.Writer |
| "MarshalJSON": "() ([]byte, error)", // json.Marshaler |
| "UnmarshalJSON": "([]byte) error", // json.Unmarshaler |
| } |
| |
| // isConventionalMethod reports whether a method with the given name and type (signature) |
| // is conventional, according to the above map. |
| func isConventionalMethod(name string, typ *ast.FuncType) bool { |
| sig, ok := conventionalMethods[name] |
| return ok && sigString(typ) == sig |
| } |
| |
| // 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() |
| } |
| |
| // 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 |
| } |