Files
tailscale/tool/listpkgs/listpkgs.go
Brad Fitzpatrick 7370c24eb4 tool/listpkgs: add --affected-by-tag
For paring back build tag variant CI runs' set of packages to test.

Updates tailscale/corp#28679

Change-Id: Iba46fd1f58c1eaee1f7888ef573bc8b14fa73208
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-02-25 11:41:33 -08:00

284 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// listpkgs prints the import paths that match the Go package patterns
// given on the command line and conditionally filters them in various ways.
package main
import (
"bufio"
"flag"
"fmt"
"go/build/constraint"
"log"
"os"
"slices"
"strings"
"sync"
"golang.org/x/tools/go/packages"
)
var (
ignore3p = flag.Bool("ignore-3p", false, "ignore third-party packages forked/vendored into Tailscale")
goos = flag.String("goos", "", "GOOS to use for loading packages (default: current OS)")
goarch = flag.String("goarch", "", "GOARCH to use for loading packages (default: current architecture)")
withTagsAllStr = flag.String("with-tags-all", "", "if non-empty, a comma-separated list of builds tags to require (a package will only be listed if it contains all of these build tags)")
withoutTagsAnyStr = flag.String("without-tags-any", "", "if non-empty, a comma-separated list of build constraints to exclude (a package will be omitted if it contains any of these build tags)")
shard = flag.String("shard", "", "if non-empty, a string of the form 'N/M' to only print packages in shard N of M (e.g. '1/3', '2/3', '3/3/' for different thirds of the list)")
affectedByTag = flag.String("affected-by-tag", "", "if non-empty, only list packages whose test binary would be affected by the presence or absence of this build tag")
)
func main() {
flag.Parse()
patterns := flag.Args()
if len(patterns) == 0 {
flag.Usage()
os.Exit(1)
}
cfg := &packages.Config{
Mode: packages.LoadFiles,
Env: os.Environ(),
}
if *affectedByTag != "" {
cfg.Mode |= packages.NeedImports
cfg.Tests = true
}
if *goos != "" {
cfg.Env = append(cfg.Env, "GOOS="+*goos)
}
if *goarch != "" {
cfg.Env = append(cfg.Env, "GOARCH="+*goarch)
}
pkgs, err := packages.Load(cfg, patterns...)
if err != nil {
log.Fatalf("loading packages: %v", err)
}
var withoutAny []string
if *withoutTagsAnyStr != "" {
withoutAny = strings.Split(*withoutTagsAnyStr, ",")
}
var withAll []string
if *withTagsAllStr != "" {
withAll = strings.Split(*withTagsAllStr, ",")
}
var affected map[string]bool // PkgPath → true
if *affectedByTag != "" {
affected = computeAffected(pkgs, *affectedByTag)
}
seen := map[string]bool{}
matches := 0
Pkg:
for _, pkg := range pkgs {
if pkg.PkgPath == "" { // malformed (shouldnt happen)
continue
}
if affected != nil {
// Skip synthetic packages created by Tests: true:
// - for-test variants like "foo [foo.test]" (ID != PkgPath)
// - test binary packages like "foo.test" (PkgPath ends in ".test")
if pkg.ID != pkg.PkgPath || strings.HasSuffix(pkg.PkgPath, ".test") {
continue
}
if !affected[pkg.PkgPath] {
continue
}
}
if seen[pkg.PkgPath] {
continue // suppress duplicates when patterns overlap
}
seen[pkg.PkgPath] = true
pkgPath := pkg.PkgPath
if *ignore3p && isThirdParty(pkgPath) {
continue
}
if withAll != nil {
for _, t := range withAll {
if !hasBuildTag(pkg, t) {
continue Pkg
}
}
}
for _, t := range withoutAny {
if hasBuildTag(pkg, t) {
continue Pkg
}
}
matches++
if *shard != "" {
var n, m int
if _, err := fmt.Sscanf(*shard, "%d/%d", &n, &m); err != nil || n < 1 || m < 1 {
log.Fatalf("invalid shard format %q; expected N/M", *shard)
}
if m > 0 && (matches-1)%m != n-1 {
continue // not in this shard
}
}
fmt.Println(pkgPath)
}
// If any package had errors (e.g. missing deps) report them via packages.PrintErrors.
// This mirrors `go list` behaviour when -e is *not* supplied.
if packages.PrintErrors(pkgs) > 0 {
os.Exit(1)
}
}
// computeAffected returns the set of package paths whose test binaries would
// differ with vs without the given build tag. It finds packages that directly
// mention the tag, then propagates transitively via reverse dependencies.
func computeAffected(pkgs []*packages.Package, tag string) map[string]bool {
// Build a map from package ID to package for quick lookup.
byID := make(map[string]*packages.Package, len(pkgs))
for _, pkg := range pkgs {
byID[pkg.ID] = pkg
}
// First pass: find directly affected package IDs.
directlyAffected := make(map[string]bool)
for _, pkg := range pkgs {
if hasBuildTag(pkg, tag) {
directlyAffected[pkg.ID] = true
}
}
// Build reverse dependency graph: importedID → []importingID.
reverseDeps := make(map[string][]string)
for _, pkg := range pkgs {
for _, imp := range pkg.Imports {
reverseDeps[imp.ID] = append(reverseDeps[imp.ID], pkg.ID)
}
}
// BFS from directly affected packages through reverse deps.
affectedIDs := make(map[string]bool)
queue := make([]string, 0, len(directlyAffected))
for id := range directlyAffected {
affectedIDs[id] = true
queue = append(queue, id)
}
for len(queue) > 0 {
id := queue[0]
queue = queue[1:]
for _, rdep := range reverseDeps[id] {
if !affectedIDs[rdep] {
affectedIDs[rdep] = true
queue = append(queue, rdep)
}
}
}
// Map affected IDs back to PkgPaths. For-test variants like
// "foo [foo.test]" share the same PkgPath as "foo", so the
// result naturally deduplicates.
affected := make(map[string]bool)
for id := range affectedIDs {
if pkg, ok := byID[id]; ok {
affected[pkg.PkgPath] = true
}
}
return affected
}
func isThirdParty(pkg string) bool {
return strings.HasPrefix(pkg, "tailscale.com/tempfork/")
}
// hasBuildTag reports whether any source file in pkg mentions `tag`
// in a //go:build constraint.
func hasBuildTag(pkg *packages.Package, tag string) bool {
all := slices.Concat(pkg.CompiledGoFiles, pkg.OtherFiles, pkg.IgnoredFiles)
suffix := "_" + tag + ".go"
for _, name := range all {
if strings.HasSuffix(name, suffix) {
return true
}
ok, err := fileMentionsTag(name, tag)
if err != nil {
log.Printf("reading %s: %v", name, err)
continue
}
if ok {
return true
}
}
return false
}
// tagSet is a set of build tags.
// The values are always true. We avoid non-std set types
// to make this faster to "go run" on empty caches.
type tagSet map[string]bool
var (
mu sync.Mutex
fileTags = map[string]tagSet{} // abs path -> set of build tags mentioned in file
)
func getFileTags(filename string) (tagSet, error) {
mu.Lock()
tags, ok := fileTags[filename]
mu.Unlock()
if ok {
return tags, nil
}
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
ts := make(tagSet)
s := bufio.NewScanner(f)
for s.Scan() {
line := s.Text()
if strings.TrimSpace(line) == "" {
continue // still in leading blank lines
}
if !strings.HasPrefix(line, "//") {
// hit real code done with header comments
// TODO(bradfitz): care about /* */ comments?
break
}
if !strings.HasPrefix(line, "//go:build") {
continue // some other comment
}
expr, err := constraint.Parse(line)
if err != nil {
return nil, fmt.Errorf("parsing %q: %w", line, err)
}
// Call Eval to populate ts with the tags mentioned in the expression.
// We don't care about the result, just the side effect of populating ts.
expr.Eval(func(tag string) bool {
ts[tag] = true
return true // arbitrary
})
}
if err := s.Err(); err != nil {
return nil, fmt.Errorf("reading %s: %w", filename, err)
}
mu.Lock()
defer mu.Unlock()
fileTags[filename] = ts
return ts, nil
}
func fileMentionsTag(filename, tag string) (bool, error) {
tags, err := getFileTags(filename)
if err != nil {
return false, err
}
return tags[tag], nil
}