From 64682587103d7d1fc90c3f1abbe252fb18fdaf2f Mon Sep 17 00:00:00 2001 From: Bryan Cox Date: Mon, 10 Aug 2026 12:32:36 -0400 Subject: [PATCH 1/6] chore(hack/tools): vendor golang.org/x/tools analysistest packages Promote golang.org/x/tools from indirect to direct in hack/tools and vendor the analysistest, checker, diff, testenv, and txtar packages needed by the hypershiftlinter analyzer test suites. Co-Authored-By: Claude Opus 4.6 --- hack/tools/go.mod | 2 +- .../go/analysis/analysistest/analysistest.go | 794 ++++++++++++++++++ .../x/tools/go/analysis/checker/checker.go | 653 ++++++++++++++ .../x/tools/go/analysis/checker/print.go | 88 ++ .../x/tools/go/analysis/internal/internal.go | 15 + .../tools/internal/analysis/driverutil/fix.go | 466 ++++++++++ .../internal/analysis/driverutil/print.go | 162 ++++ .../internal/analysis/driverutil/readfile.go | 43 + .../tools/internal/analysis/driverutil/url.go | 33 + .../analysis/driverutil/validatefix.go | 118 +++ .../x/tools/internal/astutil/free/free.go | 418 +++++++++ .../golang.org/x/tools/internal/diff/diff.go | 177 ++++ .../x/tools/internal/diff/lcs/common.go | 179 ++++ .../x/tools/internal/diff/lcs/doc.go | 156 ++++ .../x/tools/internal/diff/lcs/git.sh | 33 + .../x/tools/internal/diff/lcs/labels.go | 55 ++ .../x/tools/internal/diff/lcs/old.go | 475 +++++++++++ .../x/tools/internal/diff/lcs/sequence.go | 70 ++ .../golang.org/x/tools/internal/diff/merge.go | 81 ++ .../golang.org/x/tools/internal/diff/ndiff.go | 118 +++ .../x/tools/internal/diff/unified.go | 314 +++++++ .../x/tools/internal/testenv/exec.go | 192 +++++ .../x/tools/internal/testenv/testenv.go | 595 +++++++++++++ .../tools/internal/testenv/testenv_notunix.go | 13 + .../x/tools/internal/testenv/testenv_unix.go | 13 + .../golang.org/x/tools/txtar/archive.go | 143 ++++ .../vendor/golang.org/x/tools/txtar/fs.go | 257 ++++++ hack/tools/vendor/modules.txt | 9 + 28 files changed, 5671 insertions(+), 1 deletion(-) create mode 100644 hack/tools/vendor/golang.org/x/tools/go/analysis/analysistest/analysistest.go create mode 100644 hack/tools/vendor/golang.org/x/tools/go/analysis/checker/checker.go create mode 100644 hack/tools/vendor/golang.org/x/tools/go/analysis/checker/print.go create mode 100644 hack/tools/vendor/golang.org/x/tools/go/analysis/internal/internal.go create mode 100644 hack/tools/vendor/golang.org/x/tools/internal/analysis/driverutil/fix.go create mode 100644 hack/tools/vendor/golang.org/x/tools/internal/analysis/driverutil/print.go create mode 100644 hack/tools/vendor/golang.org/x/tools/internal/analysis/driverutil/readfile.go create mode 100644 hack/tools/vendor/golang.org/x/tools/internal/analysis/driverutil/url.go create mode 100644 hack/tools/vendor/golang.org/x/tools/internal/analysis/driverutil/validatefix.go create mode 100644 hack/tools/vendor/golang.org/x/tools/internal/astutil/free/free.go create mode 100644 hack/tools/vendor/golang.org/x/tools/internal/diff/diff.go create mode 100644 hack/tools/vendor/golang.org/x/tools/internal/diff/lcs/common.go create mode 100644 hack/tools/vendor/golang.org/x/tools/internal/diff/lcs/doc.go create mode 100644 hack/tools/vendor/golang.org/x/tools/internal/diff/lcs/git.sh create mode 100644 hack/tools/vendor/golang.org/x/tools/internal/diff/lcs/labels.go create mode 100644 hack/tools/vendor/golang.org/x/tools/internal/diff/lcs/old.go create mode 100644 hack/tools/vendor/golang.org/x/tools/internal/diff/lcs/sequence.go create mode 100644 hack/tools/vendor/golang.org/x/tools/internal/diff/merge.go create mode 100644 hack/tools/vendor/golang.org/x/tools/internal/diff/ndiff.go create mode 100644 hack/tools/vendor/golang.org/x/tools/internal/diff/unified.go create mode 100644 hack/tools/vendor/golang.org/x/tools/internal/testenv/exec.go create mode 100644 hack/tools/vendor/golang.org/x/tools/internal/testenv/testenv.go create mode 100644 hack/tools/vendor/golang.org/x/tools/internal/testenv/testenv_notunix.go create mode 100644 hack/tools/vendor/golang.org/x/tools/internal/testenv/testenv_unix.go create mode 100644 hack/tools/vendor/golang.org/x/tools/txtar/archive.go create mode 100644 hack/tools/vendor/golang.org/x/tools/txtar/fs.go diff --git a/hack/tools/go.mod b/hack/tools/go.mod index 7274c68b2225..ccdaf50e8468 100644 --- a/hack/tools/go.mod +++ b/hack/tools/go.mod @@ -13,6 +13,7 @@ require ( github.com/openshift/api/tools v0.0.0-20250915151906-94481d71bb6f go.uber.org/mock v0.6.0 golang.org/x/mod v0.35.0 + golang.org/x/tools v0.44.0 gotest.tools/gotestsum v1.13.0 honnef.co/go/tools v0.7.0 k8s.io/apiextensions-apiserver v0.34.2 @@ -300,7 +301,6 @@ require ( golang.org/x/term v0.42.0 // indirect golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.44.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/grpc v1.82.1 // indirect diff --git a/hack/tools/vendor/golang.org/x/tools/go/analysis/analysistest/analysistest.go b/hack/tools/vendor/golang.org/x/tools/go/analysis/analysistest/analysistest.go new file mode 100644 index 000000000000..ef339a4d003b --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/go/analysis/analysistest/analysistest.go @@ -0,0 +1,794 @@ +// Copyright 2018 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 analysistest provides utilities for testing analyzers. +package analysistest + +import ( + "bytes" + "fmt" + "go/ast" + "go/format" + "go/token" + "go/types" + "log" + "maps" + "os" + "path/filepath" + "regexp" + "runtime" + "slices" + "sort" + "strconv" + "strings" + "testing" + "text/scanner" + + "golang.org/x/tools/go/analysis" + "golang.org/x/tools/go/analysis/checker" + "golang.org/x/tools/go/analysis/internal" + "golang.org/x/tools/go/packages" + "golang.org/x/tools/internal/analysis/driverutil" + "golang.org/x/tools/internal/diff" + "golang.org/x/tools/internal/testenv" + "golang.org/x/tools/txtar" +) + +// WriteFiles is a helper function that creates a temporary directory +// and populates it with a GOPATH-style project using filemap (which +// maps file names to contents). On success it returns the name of the +// directory and a cleanup function to delete it. +// +// TODO(adonovan): provide a newer version that accepts a testing.T, +// calls T.TempDir, and calls T.Fatal on any error, avoiding the need +// to return cleanup or err: +// +// func WriteFilesToTmp(t *testing.T filemap map[string]string) string +func WriteFiles(filemap map[string]string) (dir string, cleanup func(), err error) { + gopath, err := os.MkdirTemp("", "analysistest") + if err != nil { + return "", nil, err + } + cleanup = func() { os.RemoveAll(gopath) } + + for name, content := range filemap { + filename := filepath.Join(gopath, "src", name) + os.MkdirAll(filepath.Dir(filename), 0777) // ignore error + if err := os.WriteFile(filename, []byte(content), 0666); err != nil { + cleanup() + return "", nil, err + } + } + return gopath, cleanup, nil +} + +// TestData returns the effective filename of +// the program's "testdata" directory. +// This function may be overridden by projects using +// an alternative build system (such as Blaze) that +// does not run a test in its package directory. +var TestData = func() string { + testdata, err := filepath.Abs("testdata") + if err != nil { + log.Fatal(err) + } + return testdata +} + +// Testing is an abstraction of a *testing.T. +type Testing interface { + Errorf(format string, args ...any) +} + +// RunWithSuggestedFixes behaves like Run, but additionally applies +// suggested fixes and verifies their output. +// +// It uses golden files, placed alongside each source file, to express +// the desired output: the expected transformation of file example.go +// is specified in file example.go.golden. +// +// Golden files may be of two forms: a plain Go source file, or a +// txtar archive. +// +// A plain Go source file indicates the expected result of applying +// all suggested fixes to the original file. +// +// A txtar archive specifies, in each section, the expected result of +// applying all suggested fixes of a given message to the original +// file; the name of the archive section is the fix's message. In this +// way, the various alternative fixes offered by a single diagnostic +// can be tested independently. Here's an example: +// +// -- turn into single negation -- +// package pkg +// +// func fn(b1, b2 bool) { +// if !b1 { // want `negating a boolean twice` +// println() +// } +// } +// +// -- remove double negation -- +// package pkg +// +// func fn(b1, b2 bool) { +// if b1 { // want `negating a boolean twice` +// println() +// } +// } +// +// # Conflicts +// +// Regardless of the form of the golden file, it is possible for +// multiple fixes to conflict, either because they overlap, or are +// close enough together that the particular diff algorithm cannot +// separate them. +// +// RunWithSuggestedFixes uses a simple three-way merge to accumulate +// fixes, similar to a git merge. The merge algorithm may be able to +// coalesce identical edits, for example duplicate imports of the same +// package. (Bear in mind that this is an editorial decision. In +// general, coalescing identical edits may not be correct: consider +// two statements that increment the same counter.) +// +// If there are conflicts, the test fails. In any case, the +// non-conflicting edits will be compared against the expected output. +// In this situation, we recommend that you increase the textual +// separation between conflicting parts or, if that fails, split +// your tests into smaller parts. +// +// If a diagnostic offers multiple fixes for the same problem, they +// are almost certain to conflict, so in this case you should define +// the expected output using a multi-section txtar file as described +// above. +func RunWithSuggestedFixes(t Testing, dir string, a *analysis.Analyzer, patterns ...string) []*Result { + results := Run(t, dir, a, patterns...) + + // If the immediate caller of RunWithSuggestedFixes is in + // x/tools, we apply stricter checks as required by gopls. + inTools := false + { + var pcs [1]uintptr + n := runtime.Callers(1, pcs[:]) + frames := runtime.CallersFrames(pcs[:n]) + fr, _ := frames.Next() + if fr.Func != nil && strings.HasPrefix(fr.Func.Name(), "golang.org/x/tools/") { + inTools = true + } + } + + generated := make(map[*token.File]bool) + + // Process each result (package) separately, matching up the suggested + // fixes into a diff, which we will compare to the .golden file. We have + // to do this per-result in case a file appears in two packages, such as in + // packages with tests, where mypkg/a.go will appear in both mypkg and + // mypkg.test. In that case, the analyzer may suggest the same set of + // changes to a.go for each package. If we merge all the results, those + // changes get doubly applied, which will cause conflicts or mismatches. + // Validating the results separately means as long as the two analyses + // don't produce conflicting suggestions for a single file, everything + // should match up. + for _, result := range results { + act := result.Action + + // Compute set of generated files. + for _, file := range internal.ActionPass(act).Files { + // Memoize, since there may be many actions + // for the same package (list of files). + tokFile := act.Package.Fset.File(file.Pos()) + if _, seen := generated[tokFile]; !seen { + generated[tokFile] = ast.IsGenerated(file) + } + } + + // For each fix, split its edits by file and convert to diff form. + var ( + // fixEdits: message -> fixes -> filename -> edits + // + // TODO(adonovan): this mapping assumes fix.Messages + // are unique across analyzers, whereas they are only + // unique within a given Diagnostic. + fixEdits = make(map[string][]map[string][]diff.Edit) + allFilenames = make(map[string]bool) + ) + for _, diag := range act.Diagnostics { + // Fixes are validated upon creation in Pass.Report. + fixloop: + for _, fix := range diag.SuggestedFixes { + // Assert that lazy fixes have a Category (#65578, #65087). + if inTools && len(fix.TextEdits) == 0 && diag.Category == "" { + t.Errorf("missing Diagnostic.Category for SuggestedFix without TextEdits (gopls requires the category for the name of the fix command") + } + + // Skip any fix that edits a generated file. + for _, edit := range fix.TextEdits { + file := act.Package.Fset.File(edit.Pos) + if generated[file] { + continue fixloop + } + } + + // Convert edits to diff form. + // Group fixes by message and file. + edits := make(map[string][]diff.Edit) + for _, edit := range fix.TextEdits { + file := act.Package.Fset.File(edit.Pos) + allFilenames[file.Name()] = true + edits[file.Name()] = append(edits[file.Name()], diff.Edit{ + Start: file.Offset(edit.Pos), + End: file.Offset(edit.End), + New: string(edit.NewText), + }) + } + fixEdits[fix.Message] = append(fixEdits[fix.Message], edits) + } + } + + merge := func(file, message string, x, y []diff.Edit) []diff.Edit { + z, ok := diff.Merge(x, y) + if !ok { + t.Errorf("in file %s, conflict applying fix %q", file, message) + return x // discard y + } + return z + } + + // Because the checking is driven by original + // filenames, there is no way to express that a fix + // (e.g. extract declaration) creates a new file. + for _, filename := range slices.Sorted(maps.Keys(allFilenames)) { + // Read the original file. + content, err := os.ReadFile(filename) + if err != nil { + t.Errorf("error reading %s: %v", filename, err) + continue + } + + // check checks that the accumulated edits applied + // to the original content yield the wanted content. + check := func(prefix string, accumulated []diff.Edit, want []byte) { + if err := applyDiffsAndCompare(result.Pass.Pkg, filename, content, want, accumulated); err != nil { + t.Errorf("%s: %s", prefix, err) + } + } + + // Read the golden file. It may have one of two forms: + // (1) A txtar archive with one section per fix title, + // including all fixes of just that title. + // (2) The expected output for file.Name after all (?) fixes are applied. + // This form requires that no diagnostic has multiple fixes. + ar, err := txtar.ParseFile(filename + ".golden") + if err != nil { + t.Errorf("error reading %s.golden: %v", filename, err) + continue + } + if len(ar.Files) > 0 { + // Form #1: one archive section per kind of suggested fix. + if len(ar.Comment) > 0 { + // Disallow the combination of comment and archive sections. + t.Errorf("%s.golden has leading comment; we don't know what to do with it", filename) + continue + } + + // Each archive section is named for a fix.Message. + // Accumulate the parts of the fix that apply to the current file, + // using a simple three-way merge, discarding conflicts, + // then apply the merged edits and compare to the archive section. + for _, section := range ar.Files { + message, want := section.Name, section.Data + var accumulated []diff.Edit + for _, fix := range fixEdits[message] { + accumulated = merge(filename, message, accumulated, fix[filename]) + } + check(fmt.Sprintf("all fixes of message %q", message), accumulated, want) + } + + } else { + // Form #2: all suggested fixes are represented by a single file. + want := ar.Comment + var accumulated []diff.Edit + for _, message := range slices.Sorted(maps.Keys(fixEdits)) { + for _, fix := range fixEdits[message] { + accumulated = merge(filename, message, accumulated, fix[filename]) + } + } + check("all fixes", accumulated, want) + } + } + } + + return results +} + +// applyDiffsAndCompare applies edits to original and compares the results against +// want after formatting both. fileName is use solely for error reporting. +func applyDiffsAndCompare(pkg *types.Package, filename string, original, want []byte, edits []diff.Edit) error { + // Relativize filename, for tidier errors. + if cwd, err := os.Getwd(); err == nil { + if rel, err := filepath.Rel(cwd, filename); err == nil { + filename = rel + } + } + + if len(edits) == 0 { + return fmt.Errorf("%s: no edits", filename) + } + fixedBytes, err := diff.ApplyBytes(original, edits) + if err != nil { + return fmt.Errorf("%s: error applying fixes: %v (see possible explanations at RunWithSuggestedFixes)", filename, err) + } + fixed, err := driverutil.FormatSourceRemoveImports(pkg, fixedBytes) + if err != nil { + return fmt.Errorf("%s: error formatting resulting source: %v\n%s", filename, err, fixedBytes) + } + + want, err = format.Source(want) + if err != nil { + return fmt.Errorf("%s.golden: error formatting golden file: %v\n%s", filename, err, fixed) + } + + // Keep error reporting logic below consistent with + // TestScript in ../internal/checker/fix_test.go! + + unified := func(xlabel, ylabel string, x, y []byte) string { + x = append(slices.Clip(bytes.TrimSpace(x)), '\n') + y = append(slices.Clip(bytes.TrimSpace(y)), '\n') + return diff.Unified(xlabel, ylabel, string(x), string(y)) + } + + if diff := unified(filename+" (fixed)", filename+" (want)", fixed, want); diff != "" { + return fmt.Errorf("unexpected %s content:\n"+ + "-- original --\n%s\n"+ + "-- fixed --\n%s\n"+ + "-- want --\n%s\n"+ + "-- diff original fixed --\n%s\n"+ + "-- diff fixed want --\n%s", + filename, + original, + fixed, + want, + unified(filename+" (original)", filename+" (fixed)", original, fixed), + diff) + } + return nil +} + +// Run applies an analysis to the packages denoted by the "go list" patterns. +// +// It loads the packages from the specified +// directory using golang.org/x/tools/go/packages, runs the analysis on +// them, and checks that each analysis emits the expected diagnostics +// and facts specified by the contents of '// want ...' comments in the +// package's source files. It treats a comment of the form +// "//...// want..." or "/*...// want... */" as if it starts at 'want'. +// +// If the directory contains a go.mod file, Run treats it as the root of the +// Go module in which to work. Otherwise, Run treats it as the root of a +// GOPATH-style tree, with package contained in the src subdirectory. +// +// An expectation of a Diagnostic is specified by a string literal +// containing a regular expression that must match the diagnostic +// message. For example: +// +// fmt.Printf("%s", 1) // want `cannot provide int 1 to %s` +// +// An expectation of a Fact associated with an object is specified by +// 'name:"pattern"', where name is the name of the object, which must be +// declared on the same line as the comment, and pattern is a regular +// expression that must match the string representation of the fact, +// fmt.Sprint(fact). For example: +// +// func panicf(format string, args interface{}) { // want panicf:"printfWrapper" +// +// Package facts are specified by the name "package" and appear on +// line 1 of the first source file of the package. +// +// A single 'want' comment may contain a mixture of diagnostic and fact +// expectations, including multiple facts about the same object: +// +// // want "diag" "diag2" x:"fact1" x:"fact2" y:"fact3" +// +// Unexpected diagnostics and facts, and unmatched expectations, are +// reported as errors to the Testing. +// +// Run reports an error to the Testing if loading or analysis failed. +// Run also returns a Result for each package for which analysis was +// attempted, even if unsuccessful. It is safe for a test to ignore all +// the results, but a test may use it to perform additional checks. +func Run(t Testing, dir string, a *analysis.Analyzer, patterns ...string) []*Result { + if t, ok := t.(testing.TB); ok { + testenv.NeedsGoPackages(t) + } + + pkgs, err := loadPackages(dir, patterns...) + if err != nil { + t.Errorf("loading %s: %v", patterns, err) + return nil + } + + // Print parse and type errors to the test log. + // (Do not print them to stderr, which would pollute + // the log in cases where the tests pass.) + if t, ok := t.(testing.TB); ok && !a.RunDespiteErrors { + packages.Visit(pkgs, nil, func(pkg *packages.Package) { + for _, err := range pkg.Errors { + t.Log(err) + } + }) + } + + res, err := checker.Analyze([]*analysis.Analyzer{a}, pkgs, nil) + if err != nil { + t.Errorf("Analyze: %v", err) + return nil + } + + var results []*Result + for _, act := range res.Roots { + if act.Err != nil { + t.Errorf("error analyzing %s: %v", act, act.Err) + } else { + check(t, dir, act) + } + + // Compute legacy map of facts relating to this package. + facts := make(map[types.Object][]analysis.Fact) + for _, objFact := range act.AllObjectFacts() { + if obj := objFact.Object; obj.Pkg() == act.Package.Types { + facts[obj] = append(facts[obj], objFact.Fact) + } + } + for _, pkgFact := range act.AllPackageFacts() { + if pkgFact.Package == act.Package.Types { + facts[nil] = append(facts[nil], pkgFact.Fact) + } + } + + // Construct the legacy result. + results = append(results, &Result{ + Pass: internal.ActionPass(act), // may be nil + Diagnostics: act.Diagnostics, + Facts: facts, + Result: act.Result, + Err: act.Err, + Action: act, + }) + } + return results +} + +// A Result holds the result of applying an analyzer to a package. +// +// Facts contains only facts associated with the package and its objects. +// +// This internal type was inadvertently and regrettably exposed +// through a public type alias. It is essentially redundant with +// [checker.Action], but must be retained for compatibility. Clients may +// access the public fields of the Pass but must not invoke any of +// its "verbs", since the pass is already complete. +type Result struct { + Action *checker.Action + + // legacy fields (do not use) + Facts map[types.Object][]analysis.Fact // nil key => package fact + Pass *analysis.Pass // nil => action not executed + Diagnostics []analysis.Diagnostic // see Action.Diagnostics + Result any // see Action.Result + Err error // see Action.Err +} + +// loadPackages uses go/packages to load a specified packages (from source, with +// dependencies) from dir, which is the root of a GOPATH-style project tree. +// loadPackages returns an error if any package had an error, or the pattern +// matched no packages. +func loadPackages(dir string, patterns ...string) ([]*packages.Package, error) { + env := []string{"GOPATH=" + dir, "GO111MODULE=off", "GOWORK=off"} // GOPATH mode + + // Undocumented module mode. Will be replaced by something better. + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + gowork := filepath.Join(dir, "go.work") + if _, err := os.Stat(gowork); err != nil { + gowork = "off" + } + + env = []string{"GO111MODULE=on", "GOPROXY=off", "GOWORK=" + gowork} // module mode + } + + // packages.Load loads the real standard library, not a minimal + // fake version, which would be more efficient, especially if we + // have many small tests that import, say, net/http. + // However there is no easy way to make go/packages to consume + // a list of packages we generate and then do the parsing and + // typechecking, though this feature seems to be a recurring need. + + mode := packages.NeedName | packages.NeedFiles | packages.NeedCompiledGoFiles | packages.NeedImports | + packages.NeedTypes | packages.NeedTypesSizes | packages.NeedSyntax | packages.NeedTypesInfo | + packages.NeedDeps | packages.NeedModule + cfg := &packages.Config{ + Mode: mode, + Dir: dir, + Tests: true, + Env: append(os.Environ(), env...), + } + pkgs, err := packages.Load(cfg, patterns...) + if err != nil { + return nil, err + } + + // If any named package couldn't be loaded at all + // (e.g. the Name field is unset), fail fast. + for _, pkg := range pkgs { + if pkg.Name == "" { + return nil, fmt.Errorf("failed to load %q: Errors=%v", + pkg.PkgPath, pkg.Errors) + } + } + + if len(pkgs) == 0 { + return nil, fmt.Errorf("no packages matched %s", patterns) + } + return pkgs, nil +} + +// check inspects an analysis pass on which the analysis has already +// been run, and verifies that all reported diagnostics and facts match +// specified by the contents of "// want ..." comments in the package's +// source files, which must have been parsed with comments enabled. +func check(t Testing, gopath string, act *checker.Action) { + type key struct { + file string + line int + } + + want := make(map[key][]expectation) + + // processComment parses expectations out of comments. + processComment := func(filename string, linenum int, text string) { + text = strings.TrimSpace(text) + + // Any comment starting with "want" is treated + // as an expectation, even without following whitespace. + if rest, ok := strings.CutPrefix(text, "want"); ok { + lineDelta, expects, err := parseExpectations(rest) + if err != nil { + t.Errorf("%s:%d: in 'want' comment: %s", filename, linenum, err) + return + } + if expects != nil { + want[key{filename, linenum + lineDelta}] = expects + } + } + } + + // Extract 'want' comments from parsed Go files. + for _, f := range act.Package.Syntax { + for _, cgroup := range f.Comments { + for _, c := range cgroup.List { + + text := strings.TrimPrefix(c.Text, "//") + if text == c.Text { // not a //-comment. + text = strings.TrimPrefix(text, "/*") + text = strings.TrimSuffix(text, "*/") + } + + // Hack: treat a comment of the form "//...// want..." + // or "/*...// want... */ + // as if it starts at 'want'. + // This allows us to add comments on comments, + // as required when testing the buildtag analyzer. + if i := strings.Index(text, "// want"); i >= 0 { + text = text[i+len("// "):] + } + + // It's tempting to compute the filename + // once outside the loop, but it's + // incorrect because it can change due + // to //line directives. + posn := act.Package.Fset.Position(c.Pos()) + filename := sanitize(gopath, posn.Filename) + processComment(filename, posn.Line, text) + } + } + } + + // Extract 'want' comments from non-Go files. + // TODO(adonovan): we may need to handle //line directives. + files := act.Package.OtherFiles + + // Hack: these analyzers need to extract expectations from + // all configurations, so include the files are usually + // ignored. (This was previously a hack in the respective + // analyzers' tests.) + switch act.Analyzer.Name { + case "buildtag", "directive", "plusbuild": + files = slices.Concat(files, act.Package.IgnoredFiles) + } + + for _, filename := range files { + data, err := os.ReadFile(filename) + if err != nil { + t.Errorf("can't read '// want' comments from %s: %v", filename, err) + continue + } + filename := sanitize(gopath, filename) + linenum := 0 + for line := range strings.SplitSeq(string(data), "\n") { + linenum++ + + // Hack: treat a comment of the form "//...// want..." + // or "/*...// want... */ + // as if it starts at 'want'. + // This allows us to add comments on comments, + // as required when testing the buildtag analyzer. + if i := strings.Index(line, "// want"); i >= 0 { + line = line[i:] + } + + if i := strings.Index(line, "//"); i >= 0 { + line = line[i+len("//"):] + processComment(filename, linenum, line) + } + } + } + + checkMessage := func(posn token.Position, kind, name, message string) { + posn.Filename = sanitize(gopath, posn.Filename) + k := key{posn.Filename, posn.Line} + expects := want[k] + var unmatched []string + for i, exp := range expects { + if exp.kind == kind && exp.name == name { + if exp.rx.MatchString(message) { + // matched: remove the expectation. + expects[i] = expects[len(expects)-1] + expects = expects[:len(expects)-1] + want[k] = expects + return + } + unmatched = append(unmatched, fmt.Sprintf("%#q", exp.rx)) + } + } + if unmatched == nil { + t.Errorf("%v: unexpected %s: %v", posn, kind, message) + } else { + t.Errorf("%v: %s %q does not match pattern %s", + posn, kind, message, strings.Join(unmatched, " or ")) + } + } + + // Check the diagnostics match expectations. + for _, f := range act.Diagnostics { + // TODO(matloob): Support ranges in analysistest. + posn := act.Package.Fset.Position(f.Pos) + checkMessage(posn, "diagnostic", "", f.Message) + } + + // Check the facts match expectations. + // We check only facts relating to the current package. + // + // We report errors in lexical order for determinism. + // (It's only deterministic within each file, not across files, + // because go/packages does not guarantee file.Pos is ascending + // across the files of a single compilation unit.) + + // package facts: reported at start of first file + for _, pkgFact := range act.AllPackageFacts() { + if pkgFact.Package == act.Package.Types { + posn := act.Package.Fset.Position(act.Package.Syntax[0].Pos()) + posn.Line, posn.Column = 1, 1 + checkMessage(posn, "fact", "package", fmt.Sprint(pkgFact)) + } + } + + // object facts: reported at line of object declaration + objFacts := act.AllObjectFacts() + sort.Slice(objFacts, func(i, j int) bool { + return objFacts[i].Object.Pos() < objFacts[j].Object.Pos() + }) + for _, objFact := range objFacts { + if obj := objFact.Object; obj.Pkg() == act.Package.Types { + posn := act.Package.Fset.Position(obj.Pos()) + checkMessage(posn, "fact", obj.Name(), fmt.Sprint(objFact.Fact)) + } + } + + // Reject surplus expectations. + // + // Sometimes an Analyzer reports two similar diagnostics on a + // line with only one expectation. The reader may be confused by + // the error message. + // TODO(adonovan): print a better error: + // "got 2 diagnostics here; each one needs its own expectation". + var surplus []string + for key, expects := range want { + for _, exp := range expects { + err := fmt.Sprintf("%s:%d: no %s was reported matching %#q", key.file, key.line, exp.kind, exp.rx) + surplus = append(surplus, err) + } + } + sort.Strings(surplus) + for _, err := range surplus { + t.Errorf("%s", err) + } +} + +type expectation struct { + kind string // either "fact" or "diagnostic" + name string // name of object to which fact belongs, or "package" ("fact" only) + rx *regexp.Regexp +} + +func (ex expectation) String() string { + return fmt.Sprintf("%s %s:%q", ex.kind, ex.name, ex.rx) // for debugging +} + +// parseExpectations parses the content of a "// want ..." comment +// and returns the expectations, a mixture of diagnostics ("rx") and +// facts (name:"rx"). +func parseExpectations(text string) (lineDelta int, expects []expectation, err error) { + var scanErr string + sc := new(scanner.Scanner).Init(strings.NewReader(text)) + sc.Error = func(s *scanner.Scanner, msg string) { + scanErr = msg // e.g. bad string escape + } + sc.Mode = scanner.ScanIdents | scanner.ScanStrings | scanner.ScanRawStrings | scanner.ScanInts + + scanRegexp := func(tok rune) (*regexp.Regexp, error) { + if tok != scanner.String && tok != scanner.RawString { + return nil, fmt.Errorf("got %s, want regular expression", + scanner.TokenString(tok)) + } + pattern, _ := strconv.Unquote(sc.TokenText()) // can't fail + return regexp.Compile(pattern) + } + + for { + tok := sc.Scan() + switch tok { + case '+': + tok = sc.Scan() + if tok != scanner.Int { + return 0, nil, fmt.Errorf("got +%s, want +Int", scanner.TokenString(tok)) + } + lineDelta, _ = strconv.Atoi(sc.TokenText()) + case scanner.String, scanner.RawString: + rx, err := scanRegexp(tok) + if err != nil { + return 0, nil, err + } + expects = append(expects, expectation{"diagnostic", "", rx}) + + case scanner.Ident: + name := sc.TokenText() + tok = sc.Scan() + if tok != ':' { + return 0, nil, fmt.Errorf("got %s after %s, want ':'", + scanner.TokenString(tok), name) + } + tok = sc.Scan() + rx, err := scanRegexp(tok) + if err != nil { + return 0, nil, err + } + expects = append(expects, expectation{"fact", name, rx}) + + case scanner.EOF: + if scanErr != "" { + return 0, nil, fmt.Errorf("%s", scanErr) + } + return lineDelta, expects, nil + + default: + return 0, nil, fmt.Errorf("unexpected %s", scanner.TokenString(tok)) + } + } +} + +// sanitize removes the GOPATH portion of the filename, +// typically a gnarly /tmp directory, and returns the rest. +func sanitize(gopath, filename string) string { + prefix := gopath + string(os.PathSeparator) + "src" + string(os.PathSeparator) + return filepath.ToSlash(strings.TrimPrefix(filename, prefix)) +} diff --git a/hack/tools/vendor/golang.org/x/tools/go/analysis/checker/checker.go b/hack/tools/vendor/golang.org/x/tools/go/analysis/checker/checker.go new file mode 100644 index 000000000000..bb69dacc1b9b --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/go/analysis/checker/checker.go @@ -0,0 +1,653 @@ +// Copyright 2024 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 checker provides an analysis driver based on the +// [golang.org/x/tools/go/packages] representation of a set of +// packages and all their dependencies, as produced by +// [packages.Load]. +// +// It is the core of multichecker (the multi-analyzer driver), +// singlechecker (the single-analyzer driver often used to provide a +// convenient command alongside each analyzer), and analysistest, the +// test driver. +// +// By contrast, the 'go vet' command is based on unitchecker, an +// analysis driver that uses separate analysis--analogous to separate +// compilation--with file-based intermediate results. Like separate +// compilation, it is more scalable, especially for incremental +// analysis of large code bases. Commands based on multichecker and +// singlechecker are capable of detecting when they are being invoked +// by "go vet -vettool=exe" and instead dispatching to unitchecker. +// +// Programs built using this package will, in general, not be usable +// in that way. This package is intended only for use in applications +// that invoke the analysis driver as a subroutine, and need to insert +// additional steps before or after the analysis. +// +// See the Example of how to build a complete analysis driver program. +package checker + +import ( + "bytes" + "encoding/gob" + "fmt" + "go/types" + "io" + "iter" + "log" + "os" + "reflect" + "sort" + "strings" + "sync" + "time" + + "golang.org/x/tools/go/analysis" + "golang.org/x/tools/go/analysis/internal" + "golang.org/x/tools/go/packages" + "golang.org/x/tools/internal/analysis/driverutil" +) + +// Options specifies options that control the analysis driver. +type Options struct { + // These options correspond to existing flags exposed by multichecker: + Sequential bool // disable parallelism + SanityCheck bool // check fact encoding is ok and deterministic + FactLog io.Writer // if non-nil, log each exported fact to it + + // TODO(adonovan): expose ReadFile so that an Overlay specified + // in the [packages.Config] can be communicated via + // Pass.ReadFile to each Analyzer. + readFile driverutil.ReadFileFunc +} + +// Graph holds the results of a round of analysis, including the graph +// of requested actions (analyzers applied to packages) plus any +// dependent actions that it was necessary to compute. +type Graph struct { + // Roots contains the roots of the action graph. + // Each node (a, p) in the action graph represents the + // application of one analyzer a to one package p. + // (A node thus corresponds to one analysis.Pass instance.) + // Roots holds one action per element of the product + // of the analyzers × packages arguments to Analyze, + // in unspecified order. + // + // Each element of Action.Deps represents an edge in the + // action graph: a dependency from one action to another. + // An edge of the form (a, p) -> (a, p2) indicates that the + // analysis of package p requires information ("facts") from + // the same analyzer applied to one of p's dependencies, p2. + // An edge of the form (a, p) -> (a2, p) indicates that the + // analysis of package p requires information ("results") + // from a different analyzer a2 applied to the same package. + // These two kind of edges are called "vertical" and "horizontal", + // respectively. + Roots []*Action +} + +// All returns an iterator over the action graph in depth-first postorder. +// +// Example: +// +// for act := range graph.All() { +// ... +// } +func (g *Graph) All() iter.Seq[*Action] { + return func(yield func(*Action) bool) { + forEach(g.Roots, func(act *Action) error { + if !yield(act) { + return io.EOF // any error will do + } + return nil + }) // ignore error + } +} + +// An Action represents one unit of analysis work by the driver: the +// application of one analysis to one package. It provides the inputs +// to and records the outputs of a single analysis.Pass. +// +// Actions form a DAG, both within a package (as different analyzers +// are applied, either in sequence or parallel), and across packages +// (as dependencies are analyzed). +type Action struct { + Analyzer *analysis.Analyzer + Package *packages.Package + IsRoot bool // whether this is a root node of the graph + Deps []*Action + Result any // computed result of Analyzer.run, if any (and if IsRoot) + Err error // error result of Analyzer.run + Diagnostics []analysis.Diagnostic + Duration time.Duration // execution time of this step + + opts *Options + once sync.Once + pass *analysis.Pass + objectFacts map[objectFactKey]analysis.Fact + packageFacts map[packageFactKey]analysis.Fact +} + +func (act *Action) String() string { + return fmt.Sprintf("%s@%s", act.Analyzer, act.Package) +} + +// Analyze runs the specified analyzers on the initial packages. +// +// The initial packages and all dependencies must have been loaded +// using the [packages.LoadAllSyntax] flag, Analyze may need to run +// some analyzer (those that consume and produce facts) on +// dependencies too. +// +// On success, it returns a Graph of actions whose Roots hold one +// item per (a, p) in the cross-product of analyzers and pkgs. +// +// If opts is nil, it is equivalent to new(Options). +func Analyze(analyzers []*analysis.Analyzer, pkgs []*packages.Package, opts *Options) (*Graph, error) { + if opts == nil { + opts = new(Options) + } + + if err := analysis.Validate(analyzers); err != nil { + return nil, err + } + + // Construct the action graph. + // + // Each graph node (action) is one unit of analysis. + // Edges express package-to-package (vertical) dependencies, + // and analysis-to-analysis (horizontal) dependencies. + type key struct { + a *analysis.Analyzer + pkg *packages.Package + } + actions := make(map[key]*Action) + + var mkAction func(a *analysis.Analyzer, pkg *packages.Package) *Action + mkAction = func(a *analysis.Analyzer, pkg *packages.Package) *Action { + k := key{a, pkg} + act, ok := actions[k] + if !ok { + act = &Action{Analyzer: a, Package: pkg, opts: opts} + + // Add a dependency on each required analyzers. + for _, req := range a.Requires { + act.Deps = append(act.Deps, mkAction(req, pkg)) + } + + // An analysis that consumes/produces facts + // must run on the package's dependencies too. + if len(a.FactTypes) > 0 { + paths := make([]string, 0, len(pkg.Imports)) + for path := range pkg.Imports { + paths = append(paths, path) + } + sort.Strings(paths) // for determinism + for _, path := range paths { + dep := mkAction(a, pkg.Imports[path]) + act.Deps = append(act.Deps, dep) + } + } + + actions[k] = act + } + return act + } + + // Build nodes for initial packages. + var roots []*Action + for _, a := range analyzers { + for _, pkg := range pkgs { + root := mkAction(a, pkg) + root.IsRoot = true + roots = append(roots, root) + } + } + + // Execute the graph in parallel. + execAll(roots) + + // Ensure that only root Results are visible to caller. + // (The others are considered temporary intermediaries.) + // TODO(adonovan): opt: clear them earlier, so we can + // release large data structures like SSA sooner. + for _, act := range actions { + if !act.IsRoot { + act.Result = nil + } + } + + return &Graph{Roots: roots}, nil +} + +func init() { + // Allow analysistest to access Action.pass, + // for the legacy analysistest.Result data type, + // and for internal/checker.ApplyFixes to access pass.ReadFile. + internal.ActionPass = func(x any) *analysis.Pass { return x.(*Action).pass } +} + +type objectFactKey struct { + obj types.Object + typ reflect.Type +} + +type packageFactKey struct { + pkg *types.Package + typ reflect.Type +} + +func execAll(actions []*Action) { + var wg sync.WaitGroup + for _, act := range actions { + wg.Add(1) + work := func(act *Action) { + act.exec() + wg.Done() + } + if act.opts.Sequential { + work(act) + } else { + go work(act) + } + } + wg.Wait() +} + +func (act *Action) exec() { act.once.Do(act.execOnce) } + +func (act *Action) execOnce() { + // Analyze dependencies. + execAll(act.Deps) + + // Record time spent in this node but not its dependencies. + // In parallel mode, due to GC/scheduler contention, the + // time is 5x higher than in sequential mode, even with a + // semaphore limiting the number of threads here. + // So use -debug=tp. + t0 := time.Now() + defer func() { act.Duration = time.Since(t0) }() + + // Report an error if any dependency failed. + var failed []string + for _, dep := range act.Deps { + if dep.Err != nil { + failed = append(failed, dep.String()) + } + } + if failed != nil { + sort.Strings(failed) + act.Err = fmt.Errorf("failed prerequisites: %s", strings.Join(failed, ", ")) + return + } + + // Plumb the output values of the dependencies + // into the inputs of this action. Also facts. + inputs := make(map[*analysis.Analyzer]any) + act.objectFacts = make(map[objectFactKey]analysis.Fact) + act.packageFacts = make(map[packageFactKey]analysis.Fact) + for _, dep := range act.Deps { + if dep.Package == act.Package { + // Same package, different analysis (horizontal edge): + // in-memory outputs of prerequisite analyzers + // become inputs to this analysis pass. + inputs[dep.Analyzer] = dep.Result + } else if dep.Analyzer == act.Analyzer { // (always true) + // Same analysis, different package (vertical edge): + // serialized facts produced by prerequisite analysis + // become available to this analysis pass. + inheritFacts(act, dep) + } + } + + // Quick (nonexhaustive) check that the correct go/packages mode bits were used. + // (If there were errors, all bets are off.) + if pkg := act.Package; pkg.Errors == nil { + if pkg.Name == "" || pkg.PkgPath == "" || pkg.Types == nil || pkg.Fset == nil || pkg.TypesSizes == nil { + panic("packages must be loaded with packages.LoadSyntax mode") + } + } + + module := &analysis.Module{} // possibly empty (non nil) in go/analysis drivers. + if mod := act.Package.Module; mod != nil { + module = analysisModuleFromPackagesModule(mod) + } + + // Run the analysis. + pass := &analysis.Pass{ + Analyzer: act.Analyzer, + Fset: act.Package.Fset, + Files: act.Package.Syntax, + OtherFiles: act.Package.OtherFiles, + IgnoredFiles: act.Package.IgnoredFiles, + Pkg: act.Package.Types, + TypesInfo: act.Package.TypesInfo, + TypesSizes: act.Package.TypesSizes, + TypeErrors: act.Package.TypeErrors, + Module: module, + + ResultOf: inputs, + Report: func(d analysis.Diagnostic) { + // Assert that SuggestedFixes are well formed. + if err := driverutil.ValidateFixes(act.Package.Fset, act.Analyzer, d.SuggestedFixes); err != nil { + panic(err) + } + act.Diagnostics = append(act.Diagnostics, d) + }, + ImportObjectFact: act.ObjectFact, + ExportObjectFact: act.exportObjectFact, + ImportPackageFact: act.PackageFact, + ExportPackageFact: act.exportPackageFact, + AllObjectFacts: act.AllObjectFacts, + AllPackageFacts: act.AllPackageFacts, + } + readFile := os.ReadFile + if act.opts.readFile != nil { + readFile = act.opts.readFile + } + pass.ReadFile = driverutil.CheckedReadFile(pass, readFile) + act.pass = pass + + act.Result, act.Err = func() (any, error) { + if act.Package.IllTyped && !pass.Analyzer.RunDespiteErrors { + return nil, fmt.Errorf("analysis skipped due to errors in package") + } + + result, err := pass.Analyzer.Run(pass) + if err != nil { + return nil, err + } + + // correct result type? + if got, want := reflect.TypeOf(result), pass.Analyzer.ResultType; got != want { + return nil, fmt.Errorf( + "internal error: on package %s, analyzer %s returned a result of type %v, but declared ResultType %v", + pass.Pkg.Path(), pass.Analyzer, got, want) + } + + // resolve diagnostic URLs + for i := range act.Diagnostics { + url, err := driverutil.ResolveURL(act.Analyzer, act.Diagnostics[i]) + if err != nil { + return nil, err + } + act.Diagnostics[i].URL = url + } + return result, nil + }() + + // Help detect (disallowed) calls after Run. + pass.ExportObjectFact = nil + pass.ExportPackageFact = nil +} + +// inheritFacts populates act.facts with +// those it obtains from its dependency, dep. +func inheritFacts(act, dep *Action) { + for key, fact := range dep.objectFacts { + // Filter out facts related to objects + // that are irrelevant downstream + // (equivalently: not in the compiler export data). + if !exportedFrom(key.obj, dep.Package.Types) { + if false { + log.Printf("%v: discarding %T fact from %s for %s: %s", act, fact, dep, key.obj, fact) + } + continue + } + + // Optionally serialize/deserialize fact + // to verify that it works across address spaces. + if act.opts.SanityCheck { + encodedFact, err := codeFact(fact) + if err != nil { + log.Panicf("internal error: encoding of %T fact failed in %v", fact, act) + } + fact = encodedFact + } + + if false { + log.Printf("%v: inherited %T fact for %s: %s", act, fact, key.obj, fact) + } + act.objectFacts[key] = fact + } + + for key, fact := range dep.packageFacts { + // TODO: filter out facts that belong to + // packages not mentioned in the export data + // to prevent side channels. + // + // The Pass.All{Object,Package}Facts accessors expose too much: + // all facts, of all types, for all dependencies in the action + // graph. Not only does the representation grow quadratically, + // but it violates the separate compilation paradigm, allowing + // analysis implementations to communicate with indirect + // dependencies that are not mentioned in the export data. + // + // It's not clear how to fix this short of a rather expensive + // filtering step after each action that enumerates all the + // objects that would appear in export data, and deletes + // facts associated with objects not in this set. + + // Optionally serialize/deserialize fact + // to verify that it works across address spaces + // and is deterministic. + if act.opts.SanityCheck { + encodedFact, err := codeFact(fact) + if err != nil { + log.Panicf("internal error: encoding of %T fact failed in %v", fact, act) + } + fact = encodedFact + } + + if false { + log.Printf("%v: inherited %T fact for %s: %s", act, fact, key.pkg.Path(), fact) + } + act.packageFacts[key] = fact + } +} + +// codeFact encodes then decodes a fact, +// just to exercise that logic. +func codeFact(fact analysis.Fact) (analysis.Fact, error) { + // We encode facts one at a time. + // A real modular driver would emit all facts + // into one encoder to improve gob efficiency. + var buf bytes.Buffer + if err := gob.NewEncoder(&buf).Encode(fact); err != nil { + return nil, err + } + + // Encode it twice and assert that we get the same bits. + // This helps detect nondeterministic Gob encoding (e.g. of maps). + var buf2 bytes.Buffer + if err := gob.NewEncoder(&buf2).Encode(fact); err != nil { + return nil, err + } + if !bytes.Equal(buf.Bytes(), buf2.Bytes()) { + return nil, fmt.Errorf("encoding of %T fact is nondeterministic", fact) + } + + new := reflect.New(reflect.TypeOf(fact).Elem()).Interface().(analysis.Fact) + if err := gob.NewDecoder(&buf).Decode(new); err != nil { + return nil, err + } + return new, nil +} + +// exportedFrom reports whether obj may be visible to a package that imports pkg. +// This includes not just the exported members of pkg, but also unexported +// constants, types, fields, and methods, perhaps belonging to other packages, +// that find there way into the API. +// This is an overapproximation of the more accurate approach used by +// gc export data, which walks the type graph, but it's much simpler. +// +// TODO(adonovan): do more accurate filtering by walking the type graph. +func exportedFrom(obj types.Object, pkg *types.Package) bool { + switch obj := obj.(type) { + case *types.Func: + return obj.Exported() && obj.Pkg() == pkg || + obj.Signature().Recv() != nil + case *types.Var: + if obj.IsField() { + return true + } + // we can't filter more aggressively than this because we need + // to consider function parameters exported, but have no way + // of telling apart function parameters from local variables. + return obj.Pkg() == pkg + case *types.TypeName, *types.Const: + return true + } + return false // Nil, Builtin, Label, or PkgName +} + +// ObjectFact retrieves a fact associated with obj, +// and returns true if one was found. +// Given a value ptr of type *T, where *T satisfies Fact, +// ObjectFact copies the value to *ptr. +// +// See documentation at ImportObjectFact field of [analysis.Pass]. +func (act *Action) ObjectFact(obj types.Object, ptr analysis.Fact) bool { + if obj == nil { + panic("nil object") + } + key := objectFactKey{obj, factType(ptr)} + if v, ok := act.objectFacts[key]; ok { + reflect.ValueOf(ptr).Elem().Set(reflect.ValueOf(v).Elem()) + return true + } + return false +} + +// exportObjectFact implements Pass.ExportObjectFact. +func (act *Action) exportObjectFact(obj types.Object, fact analysis.Fact) { + if act.pass.ExportObjectFact == nil { + log.Panicf("%s: Pass.ExportObjectFact(%s, %T) called after Run", act, obj, fact) + } + + if obj.Pkg() != act.Package.Types { + log.Panicf("internal error: in analysis %s of package %s: Fact.Set(%s, %T): can't set facts on objects belonging another package", + act.Analyzer, act.Package, obj, fact) + } + + key := objectFactKey{obj, factType(fact)} + act.objectFacts[key] = fact // clobber any existing entry + if log := act.opts.FactLog; log != nil { + objstr := types.ObjectString(obj, (*types.Package).Name) + fmt.Fprintf(log, "%s: object %s has fact %s\n", + act.Package.Fset.Position(obj.Pos()), objstr, fact) + } +} + +// AllObjectFacts returns a new slice containing all object facts of +// the analysis's FactTypes in unspecified order. +// +// See documentation at AllObjectFacts field of [analysis.Pass]. +func (act *Action) AllObjectFacts() []analysis.ObjectFact { + facts := make([]analysis.ObjectFact, 0, len(act.objectFacts)) + for k, fact := range act.objectFacts { + facts = append(facts, analysis.ObjectFact{Object: k.obj, Fact: fact}) + } + return facts +} + +// PackageFact retrieves a fact associated with package pkg, +// which must be this package or one of its dependencies. +// +// See documentation at ImportObjectFact field of [analysis.Pass]. +func (act *Action) PackageFact(pkg *types.Package, ptr analysis.Fact) bool { + if pkg == nil { + panic("nil package") + } + key := packageFactKey{pkg, factType(ptr)} + if v, ok := act.packageFacts[key]; ok { + reflect.ValueOf(ptr).Elem().Set(reflect.ValueOf(v).Elem()) + return true + } + return false +} + +// exportPackageFact implements Pass.ExportPackageFact. +func (act *Action) exportPackageFact(fact analysis.Fact) { + if act.pass.ExportPackageFact == nil { + log.Panicf("%s: Pass.ExportPackageFact(%T) called after Run", act, fact) + } + + key := packageFactKey{act.pass.Pkg, factType(fact)} + act.packageFacts[key] = fact // clobber any existing entry + if log := act.opts.FactLog; log != nil { + fmt.Fprintf(log, "%s: package %s has fact %s\n", + act.Package.Fset.Position(act.pass.Files[0].Pos()), act.pass.Pkg.Path(), fact) + } +} + +func factType(fact analysis.Fact) reflect.Type { + t := reflect.TypeOf(fact) + if t.Kind() != reflect.Pointer { + log.Fatalf("invalid Fact type: got %T, want pointer", fact) + } + return t +} + +// AllPackageFacts returns a new slice containing all package +// facts of the analysis's FactTypes in unspecified order. +// +// See documentation at AllPackageFacts field of [analysis.Pass]. +func (act *Action) AllPackageFacts() []analysis.PackageFact { + facts := make([]analysis.PackageFact, 0, len(act.packageFacts)) + for k, fact := range act.packageFacts { + facts = append(facts, analysis.PackageFact{Package: k.pkg, Fact: fact}) + } + return facts +} + +// forEach is a utility function for traversing the action graph. It +// applies function f to each action in the graph reachable from +// roots, in depth-first postorder. If any call to f returns an error, +// the traversal is aborted and ForEach returns the error. +func forEach(roots []*Action, f func(*Action) error) error { + seen := make(map[*Action]bool) + var visitAll func(actions []*Action) error + visitAll = func(actions []*Action) error { + for _, act := range actions { + if !seen[act] { + seen[act] = true + if err := visitAll(act.Deps); err != nil { + return err + } + if err := f(act); err != nil { + return err + } + } + } + return nil + } + return visitAll(roots) +} + +func analysisModuleFromPackagesModule(mod *packages.Module) *analysis.Module { + if mod == nil { + return nil + } + + var modErr *analysis.ModuleError + if mod.Error != nil { + modErr = &analysis.ModuleError{ + Err: mod.Error.Err, + } + } + + return &analysis.Module{ + Path: mod.Path, + Version: mod.Version, + Replace: analysisModuleFromPackagesModule(mod.Replace), + Time: mod.Time, + Main: mod.Main, + Indirect: mod.Indirect, + Dir: mod.Dir, + GoMod: mod.GoMod, + GoVersion: mod.GoVersion, + Error: modErr, + } +} diff --git a/hack/tools/vendor/golang.org/x/tools/go/analysis/checker/print.go b/hack/tools/vendor/golang.org/x/tools/go/analysis/checker/print.go new file mode 100644 index 000000000000..83ea2fbd9408 --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/go/analysis/checker/print.go @@ -0,0 +1,88 @@ +// Copyright 2024 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 checker + +// This file defines helpers for printing analysis results. +// They should all be pure functions. + +import ( + "bytes" + "fmt" + "go/token" + "io" + + "golang.org/x/tools/go/analysis" + "golang.org/x/tools/internal/analysis/driverutil" +) + +// PrintText emits diagnostics as plain text to w. +// +// If contextLines is nonnegative, it also prints the +// offending line, plus that many lines of context +// before and after the line. +func (g *Graph) PrintText(w io.Writer, contextLines int) error { + return writeTextDiagnostics(w, g.Roots, contextLines) +} + +func writeTextDiagnostics(w io.Writer, roots []*Action, contextLines int) error { + // De-duplicate diagnostics by position (not token.Pos) to + // avoid double-reporting in source files that belong to + // multiple packages, such as foo and foo.test. + // (We cannot assume that such repeated files were parsed + // only once and use syntax nodes as the key.) + type key struct { + pos token.Position + end token.Position + *analysis.Analyzer + message string + } + seen := make(map[key]bool) + + // TODO(adonovan): opt: plumb errors back from PrintPlain and avoid buffer. + buf := new(bytes.Buffer) + forEach(roots, func(act *Action) error { + if act.Err != nil { + fmt.Fprintf(w, "%s: %v\n", act.Analyzer.Name, act.Err) + } else if act.IsRoot { + for _, diag := range act.Diagnostics { + // We don't display Analyzer.Name/diag.Category + // as most users don't care. + + posn := act.Package.Fset.Position(diag.Pos) + end := act.Package.Fset.Position(diag.End) + k := key{posn, end, act.Analyzer, diag.Message} + if seen[k] { + continue // duplicate + } + seen[k] = true + + driverutil.PrintPlain(buf, act.Package.Fset, contextLines, diag) + } + } + return nil + }) + _, err := w.Write(buf.Bytes()) + return err +} + +// PrintJSON emits diagnostics in JSON form to w. +// Diagnostics are shown only for the root nodes, +// but errors (if any) are shown for all dependencies. +func (g *Graph) PrintJSON(w io.Writer) error { + return writeJSONDiagnostics(w, g.Roots) +} + +func writeJSONDiagnostics(w io.Writer, roots []*Action) error { + tree := make(driverutil.JSONTree) + forEach(roots, func(act *Action) error { + var diags []analysis.Diagnostic + if act.IsRoot { + diags = act.Diagnostics + } + tree.Add(act.Package.Fset, act.Package.ID, act.Analyzer.Name, diags, act.Err) + return nil + }) + return tree.Print(w) +} diff --git a/hack/tools/vendor/golang.org/x/tools/go/analysis/internal/internal.go b/hack/tools/vendor/golang.org/x/tools/go/analysis/internal/internal.go new file mode 100644 index 000000000000..67f0d65acf26 --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/go/analysis/internal/internal.go @@ -0,0 +1,15 @@ +// Copyright 2023 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 internal + +import "golang.org/x/tools/go/analysis" + +// This function is set by the checker package to provide +// backdoor access to the private Pass field +// of the *checker.Action type, for use by analysistest. +// +// It may return nil, for example if the action was not +// executed because of a failed dependent. +var ActionPass func(action any) *analysis.Pass diff --git a/hack/tools/vendor/golang.org/x/tools/internal/analysis/driverutil/fix.go b/hack/tools/vendor/golang.org/x/tools/internal/analysis/driverutil/fix.go new file mode 100644 index 000000000000..4bda3f76bb31 --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/internal/analysis/driverutil/fix.go @@ -0,0 +1,466 @@ +// Copyright 2018 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 driverutil defines implementation helper functions for +// analysis drivers such as unitchecker, {single,multi}checker, and +// analysistest. +package driverutil + +// This file defines the -fix logic common to unitchecker and +// {single,multi}checker. + +import ( + "bytes" + "fmt" + "go/ast" + "go/parser" + "go/printer" + "go/token" + "go/types" + "log" + "maps" + "os" + "sort" + "strconv" + + "golang.org/x/tools/go/analysis" + "golang.org/x/tools/go/ast/astutil" + "golang.org/x/tools/internal/astutil/free" + "golang.org/x/tools/internal/diff" +) + +// FixAction abstracts a checker action (running one analyzer on one +// package) for the purposes of applying its diagnostics' fixes. +type FixAction struct { + Name string // e.g. "analyzer@package" + Pkg *types.Package // (for import removal) + Files []*ast.File + FileSet *token.FileSet + ReadFileFunc ReadFileFunc + Diagnostics []analysis.Diagnostic +} + +// ApplyFixes attempts to apply the first suggested fix associated +// with each diagnostic reported by the specified actions. +// All fixes must have been validated by [ValidateFixes]. +// +// Each fix is treated as an independent change; fixes are merged in +// an arbitrary deterministic order as if by a three-way diff tool +// such as the UNIX diff3 command or 'git merge'. Any fix that cannot be +// cleanly merged is discarded, in which case the final summary tells +// the user to re-run the tool. +// TODO(adonovan): make the checker tool re-run the analysis itself. +// +// When the same file is analyzed as a member of both a primary +// package "p" and a test-augmented package "p [p.test]", there may be +// duplicate diagnostics and fixes. One set of fixes will be applied +// and the other will be discarded; but re-running the tool may then +// show zero fixes, which may cause the confused user to wonder what +// happened to the other ones. +// TODO(adonovan): consider pre-filtering completely identical fixes. +// +// A common reason for overlapping fixes is duplicate additions of the +// same import. The merge algorithm may often cleanly resolve such +// fixes, coalescing identical edits, but the merge may sometimes be +// confused by nearby changes. +// +// Even when merging succeeds, there is no guarantee that the +// composition of the two fixes is semantically correct. Coalescing +// identical edits is appropriate for imports, but not for, say, +// increments to a counter variable; the correct resolution in that +// case might be to increment it twice. +// +// Or consider two fixes that each delete the penultimate reference to +// a local variable: each fix is sound individually, and they may be +// textually distant from each other, but when both are applied, the +// program is no longer valid because it has an unreferenced local +// variable. (ApplyFixes solves the analogous problem for imports by +// eliminating imports whose name is unreferenced in the remainder of +// the fixed file.) +// +// Merging depends on both the order of fixes and they order of edits +// within them. For example, if three fixes add import "a" twice and +// import "b" once, the two imports of "a" may be combined if they +// appear in order [a, a, b], or not if they appear as [a, b, a]. +// TODO(adonovan): investigate an algebraic approach to imports; +// that is, for fixes to Go source files, convert changes within the +// import(...) portion of the file into semantic edits, compose those +// edits algebraically, then convert the result back to edits. +// +// applyFixes returns success if all fixes are valid, could be cleanly +// merged, and the corresponding files were successfully updated. +// +// If printDiff (from the -diff flag) is set, instead of updating the +// files it display the final patch composed of all the cleanly merged +// fixes. (It is tempting to factor printDiff as just a variant of +// writeFile that is provided the old and new content, but it's hard +// to generate a good summary that way.) +// +// TODO(adonovan): handle file-system level aliases such as symbolic +// links using robustio.FileID. +func ApplyFixes(actions []FixAction, writeFile func(filename string, content []byte) error, printDiff, verbose bool) error { + generated := make(map[*token.File]bool) + + // Select fixes to apply. + // + // If there are several for a given Diagnostic, choose the first. + // Preserve the order of iteration, for determinism. + type fixact struct { + fix *analysis.SuggestedFix + act FixAction + } + var fixes []*fixact + for _, act := range actions { + for _, file := range act.Files { + tokFile := act.FileSet.File(file.FileStart) + // Memoize, since there may be many actions + // for the same package (list of files). + if _, seen := generated[tokFile]; !seen { + generated[tokFile] = ast.IsGenerated(file) + } + } + + for _, diag := range act.Diagnostics { + for i := range diag.SuggestedFixes { + fix := &diag.SuggestedFixes[i] + if i == 0 { + fixes = append(fixes, &fixact{fix, act}) + } else { + // TODO(adonovan): abstract the logger. + log.Printf("%s: ignoring alternative fix %q", act.Name, fix.Message) + } + } + } + } + + // Read file content on demand, from the virtual + // file system that fed the analyzer (see #62292). + // + // This cache assumes that all successful reads for the same + // file name return the same content. + // (It is tempting to group fixes by package and do the + // merge/apply/format steps one package at a time, but + // packages are not disjoint, due to test variants, so this + // would not really address the issue.) + baselineContent := make(map[string][]byte) + getBaseline := func(readFile ReadFileFunc, filename string) ([]byte, error) { + content, ok := baselineContent[filename] + if !ok { + var err error + content, err = readFile(filename) + if err != nil { + return nil, err + } + baselineContent[filename] = content + } + return content, nil + } + + // Apply each fix, updating the current state + // only if the entire fix can be cleanly merged. + var ( + accumulatedEdits = make(map[string][]diff.Edit) + filePkgs = make(map[string]*types.Package) // maps each file to an arbitrary package that includes it + + goodFixes = 0 // number of fixes cleanly applied + skippedFixes = 0 // number of fixes skipped (because e.g. edits a generated file) + ) +fixloop: + for _, fixact := range fixes { + // Skip a fix if any of its edits touch a generated file. + for _, edit := range fixact.fix.TextEdits { + file := fixact.act.FileSet.File(edit.Pos) + if generated[file] { + skippedFixes++ + continue fixloop + } + } + + // Convert analysis.TextEdits to diff.Edits, grouped by file. + // Precondition: a prior call to validateFix succeeded. + fileEdits := make(map[string][]diff.Edit) + for _, edit := range fixact.fix.TextEdits { + file := fixact.act.FileSet.File(edit.Pos) + + filePkgs[file.Name()] = fixact.act.Pkg + + baseline, err := getBaseline(fixact.act.ReadFileFunc, file.Name()) + if err != nil { + log.Printf("skipping fix to file %s: %v", file.Name(), err) + continue fixloop + } + + // We choose to treat size mismatch as a serious error, + // as it indicates a concurrent write to at least one file, + // and possibly others (consider a git checkout, for example). + if file.Size() != len(baseline) { + return fmt.Errorf("concurrent file modification detected in file %s (size changed from %d -> %d bytes); aborting fix", + file.Name(), file.Size(), len(baseline)) + } + + fileEdits[file.Name()] = append(fileEdits[file.Name()], diff.Edit{ + Start: file.Offset(edit.Pos), + End: file.Offset(edit.End), + New: string(edit.NewText), + }) + } + + // Apply each set of edits by merging atop + // the previous accumulated state. + after := make(map[string][]diff.Edit) + for file, edits := range fileEdits { + if prev := accumulatedEdits[file]; len(prev) > 0 { + merged, ok := diff.Merge(prev, edits) + if !ok { + // debugging + if false { + log.Printf("%s: fix %s conflicts", fixact.act.Name, fixact.fix.Message) + } + continue fixloop // conflict + } + edits = merged + } + after[file] = edits + } + + // The entire fix applied cleanly; commit it. + goodFixes++ + maps.Copy(accumulatedEdits, after) + // debugging + if false { + log.Printf("%s: fix %s applied", fixact.act.Name, fixact.fix.Message) + } + } + badFixes := len(fixes) - goodFixes - skippedFixes // number of fixes that could not be applied + + // Show diff or update files to final state. + var files []string + for file := range accumulatedEdits { + files = append(files, file) + } + sort.Strings(files) // for deterministic -diff + var filesUpdated, totalFiles int + for _, file := range files { + edits := accumulatedEdits[file] + if len(edits) == 0 { + continue // the diffs annihilated (a miracle?) + } + + // Apply accumulated fixes. + baseline := baselineContent[file] // (cache hit) + final, err := diff.ApplyBytes(baseline, edits) + if err != nil { + log.Fatalf("internal error in diff.ApplyBytes: %v", err) + } + + // Attempt to format each file. + if formatted, err := FormatSourceRemoveImports(filePkgs[file], final); err == nil { + final = formatted + } + + if printDiff { + // Since we formatted the file, we need to recompute the diff. + unified := diff.Unified(file+" (old)", file+" (new)", string(baseline), string(final)) + // TODO(adonovan): abstract the I/O. + os.Stdout.WriteString(unified) + + } else { + // write file + totalFiles++ + if err := writeFile(file, final); err != nil { + log.Println(err) + continue // (causes ApplyFix to return an error) + } + filesUpdated++ + } + } + + // TODO(adonovan): consider returning a structured result that + // maps each SuggestedFix to its status: + // - invalid + // - secondary, not selected + // - applied + // - had conflicts. + // and a mapping from each affected file to: + // - its final/original content pair, and + // - whether formatting was successful. + // Then file writes and the UI can be applied by the caller + // in whatever form they like. + + // If victory was incomplete, report an error that indicates partial progress. + // + // badFixes > 0 indicates that we decided not to attempt some + // fixes due to conflicts or failure to read the source; still + // it's a relatively benign situation since the user can + // re-run the tool, and we may still make progress. + // + // filesUpdated < totalFiles indicates that some file updates + // failed. This should be rare, but is a serious error as it + // may apply half a fix, or leave the files in a bad state. + // + // These numbers are potentially misleading: + // The denominator includes duplicate conflicting fixes due to + // common files in packages "p" and "p [p.test]", which may + // have been fixed and won't appear in the re-run. + // TODO(adonovan): eliminate identical fixes as an initial + // filtering step. + // + // TODO(adonovan): should we log that n files were updated in case of total victory? + if badFixes > 0 || filesUpdated < totalFiles { + if printDiff { + return fmt.Errorf("%d of %s skipped (e.g. due to conflicts)", + badFixes, + plural(len(fixes), "fix", "fixes")) + } else { + return fmt.Errorf("applied %d of %s; %s updated. (Re-run the command to apply more.)", + goodFixes, + plural(len(fixes), "fix", "fixes"), + plural(filesUpdated, "file", "files")) + } + } + + if verbose { + if skippedFixes > 0 { + log.Printf("skipped %s that would edit generated files", + plural(skippedFixes, "fix", "fixes")) + } + log.Printf("applied %s, updated %s", + plural(len(fixes), "fix", "fixes"), + plural(filesUpdated, "file", "files")) + } + + return nil +} + +// FormatSourceRemoveImports is a variant of [format.Source] that +// removes imports that became redundant when fixes were applied. +// +// Import removal is necessarily heuristic since we do not have type +// information for the fixed file and thus cannot accurately tell +// whether k is among the free names of T{k: 0}, which requires +// knowledge of whether T is a struct type. +// +// Like [imports.Process] (the core of x/tools/cmd/goimports), it also +// merges import decls. +func FormatSourceRemoveImports(pkg *types.Package, src []byte) ([]byte, error) { + // This function was reduced from the "strict entire file" + // path through [format.Source]. + + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "fixed.go", src, parser.ParseComments|parser.SkipObjectResolution) + if err != nil { + return nil, err + } + + ast.SortImports(fset, file) + + removeUnneededImports(fset, pkg, file) + + // TODO(adonovan): to generate cleaner edits when adding an import, + // consider adding a call to imports.mergeImports; however, it does + // cause comments to migrate. + + // printerNormalizeNumbers means to canonicalize number literal prefixes + // and exponents while printing. See https://golang.org/doc/go1.13#gofmt. + // + // This value is defined in go/printer specifically for go/format and cmd/gofmt. + const printerNormalizeNumbers = 1 << 30 + cfg := &printer.Config{ + Mode: printer.UseSpaces | printer.TabIndent | printerNormalizeNumbers, + Tabwidth: 8, + } + var buf bytes.Buffer + if err := cfg.Fprint(&buf, fset, file); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// removeUnneededImports removes import specs that are not referenced +// within the fixed file. It uses [free.Names] to heuristically +// approximate the set of imported names needed by the body of the +// file based only on syntax. +// +// pkg provides type information about the unmodified package, in +// particular the name that would implicitly be declared by a +// non-renaming import of a given existing dependency. +func removeUnneededImports(fset *token.FileSet, pkg *types.Package, file *ast.File) { + // Map each existing dependency and its transitive dependencies to its default import name. + // (We'll need this to interpret non-renaming imports.) + packageNames := make(map[string]string) + var visit func(pkg *types.Package) + visit = func(pkg *types.Package) { + if packageNames[pkg.Path()] == "" { + packageNames[pkg.Path()] = pkg.Name() + for _, imp := range pkg.Imports() { + visit(imp) + } + } + } + for _, imp := range pkg.Imports() { + visit(imp) + } + + // Compute the set of free names of the file, + // ignoring its import decls. + freenames := make(map[string]bool) + for _, decl := range file.Decls { + if decl, ok := decl.(*ast.GenDecl); ok && decl.Tok == token.IMPORT { + continue // skip import + } + + // TODO(adonovan): we could do better than includeComplitIdents=false + // since we have type information about the unmodified package, + // which is a good source of heuristics. + const includeComplitIdents = false + maps.Copy(freenames, free.Names(decl, includeComplitIdents)) + } + + // Check whether each import's declared name is free (referenced) by the file. + var deletions []func() + for _, spec := range file.Imports { + path, err := strconv.Unquote(spec.Path.Value) + if err != nil { + continue // malformed import; ignore + } + explicit := "" // explicit PkgName, if any + if spec.Name != nil { + explicit = spec.Name.Name + } + name := explicit // effective PkgName + if name == "" { + // Non-renaming import: use package's default name. + name = packageNames[path] + } + switch name { + case "": + continue // assume it's a new import, and we didn't find its default name while searching the import graph + case ".": + continue // dot imports are tricky + case "_": + continue // keep blank imports + } + if !freenames[name] { + // Import's effective name is not free in (not used by) the file. + // Enqueue it for deletion after the loop. + deletions = append(deletions, func() { + astutil.DeleteNamedImport(fset, file, explicit, path) + }) + } + } + + // Apply the deletions. + for _, del := range deletions { + del() + } +} + +// plural returns "n nouns", selecting the plural form as approriate. +func plural(n int, singular, plural string) string { + if n == 1 { + return "1 " + singular + } else { + return fmt.Sprintf("%d %s", n, plural) + } +} diff --git a/hack/tools/vendor/golang.org/x/tools/internal/analysis/driverutil/print.go b/hack/tools/vendor/golang.org/x/tools/internal/analysis/driverutil/print.go new file mode 100644 index 000000000000..5458846857d5 --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/internal/analysis/driverutil/print.go @@ -0,0 +1,162 @@ +// Copyright 2025 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 driverutil + +// This file defined output helpers common to all drivers. + +import ( + "cmp" + "encoding/json" + "fmt" + "go/token" + "io" + "log" + "os" + "strings" + + "golang.org/x/tools/go/analysis" +) + +// TODO(adonovan): don't accept an io.Writer if we don't report errors. +// Either accept a bytes.Buffer (infallible), or return a []byte. + +// PrintPlain prints a diagnostic in plain text form. +// If contextLines is nonnegative, it also prints the +// offending line plus this many lines of context. +func PrintPlain(out io.Writer, fset *token.FileSet, contextLines int, diag analysis.Diagnostic) { + print := func(pos, end token.Pos, message string) { + posn := fset.Position(pos) + fmt.Fprintf(out, "%s: %s\n", posn, message) + + // show offending line plus N lines of context. + if contextLines >= 0 { + end := fset.Position(end) + if !end.IsValid() { + end = posn + } + // TODO(adonovan): highlight the portion of the line indicated + // by pos...end using ASCII art, terminal colors, etc? + data, _ := os.ReadFile(posn.Filename) + lines := strings.Split(string(data), "\n") + for i := posn.Line - contextLines; i <= end.Line+contextLines; i++ { + if 1 <= i && i <= len(lines) { + fmt.Fprintf(out, "%d\t%s\n", i, lines[i-1]) + } + } + } + } + + print(diag.Pos, diag.End, diag.Message) + for _, rel := range diag.Related { + print(rel.Pos, rel.End, "\t"+rel.Message) + } +} + +// A JSONTree is a mapping from package ID to analysis name to result. +// Each result is either a jsonError or a list of JSONDiagnostic. +type JSONTree map[string]map[string]any + +// A TextEdit describes the replacement of a portion of a file. +// Start and End are zero-based half-open indices into the original byte +// sequence of the file, and New is the new text. +type JSONTextEdit struct { + Filename string `json:"filename"` + Start int `json:"start"` + End int `json:"end"` + New string `json:"new"` +} + +// A JSONSuggestedFix describes an edit that should be applied as a whole or not +// at all. It might contain multiple TextEdits/text_edits if the SuggestedFix +// consists of multiple non-contiguous edits. +type JSONSuggestedFix struct { + Message string `json:"message"` + Edits []JSONTextEdit `json:"edits"` +} + +// A JSONDiagnostic describes the JSON schema of an analysis.Diagnostic. +type JSONDiagnostic struct { + Category string `json:"category,omitempty"` + Posn string `json:"posn"` // e.g. "file.go:line:column" + End string `json:"end"` // (ditto) + Message string `json:"message"` + SuggestedFixes []JSONSuggestedFix `json:"suggested_fixes,omitempty"` + Related []JSONRelatedInformation `json:"related,omitempty"` +} + +// A JSONRelated describes a secondary position and message related to +// a primary diagnostic. +type JSONRelatedInformation struct { + Posn string `json:"posn"` // e.g. "file.go:line:column" + End string `json:"end"` // (ditto) + Message string `json:"message"` +} + +// Add adds the result of analysis 'name' on package 'id'. +// The result is either a list of diagnostics or an error. +func (tree JSONTree) Add(fset *token.FileSet, id, name string, diags []analysis.Diagnostic, err error) { + var v any + if err != nil { + type jsonError struct { + Err string `json:"error"` + } + v = jsonError{err.Error()} + } else if len(diags) > 0 { + diagnostics := make([]JSONDiagnostic, 0, len(diags)) + for _, f := range diags { + var fixes []JSONSuggestedFix + for _, fix := range f.SuggestedFixes { + var edits []JSONTextEdit + for _, edit := range fix.TextEdits { + edits = append(edits, JSONTextEdit{ + Filename: fset.Position(edit.Pos).Filename, + Start: fset.Position(edit.Pos).Offset, + End: fset.Position(edit.End).Offset, + New: string(edit.NewText), + }) + } + fixes = append(fixes, JSONSuggestedFix{ + Message: fix.Message, + Edits: edits, + }) + } + var related []JSONRelatedInformation + for _, r := range f.Related { + related = append(related, JSONRelatedInformation{ + Posn: fset.Position(r.Pos).String(), + End: fset.Position(cmp.Or(r.End, r.Pos)).String(), + Message: r.Message, + }) + } + jdiag := JSONDiagnostic{ + Category: f.Category, + Posn: fset.Position(f.Pos).String(), + End: fset.Position(cmp.Or(f.End, f.Pos)).String(), + Message: f.Message, + SuggestedFixes: fixes, + Related: related, + } + diagnostics = append(diagnostics, jdiag) + } + v = diagnostics + } + if v != nil { + m, ok := tree[id] + if !ok { + m = make(map[string]any) + tree[id] = m + } + m[name] = v + } +} + +func (tree JSONTree) Print(out io.Writer) error { + data, err := json.MarshalIndent(tree, "", "\t") + if err != nil { + log.Panicf("internal error: JSON marshaling failed: %v", err) + } + _, err = fmt.Fprintf(out, "%s\n", data) + return err +} diff --git a/hack/tools/vendor/golang.org/x/tools/internal/analysis/driverutil/readfile.go b/hack/tools/vendor/golang.org/x/tools/internal/analysis/driverutil/readfile.go new file mode 100644 index 000000000000..dc1d54dd8bd3 --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/internal/analysis/driverutil/readfile.go @@ -0,0 +1,43 @@ +// Copyright 2020 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 driverutil + +// This file defines helpers for implementing [analysis.Pass.ReadFile]. + +import ( + "fmt" + "slices" + + "golang.org/x/tools/go/analysis" +) + +// A ReadFileFunc is a function that returns the +// contents of a file, such as [os.ReadFile]. +type ReadFileFunc = func(filename string) ([]byte, error) + +// CheckedReadFile returns a wrapper around a Pass.ReadFile +// function that performs the appropriate checks. +func CheckedReadFile(pass *analysis.Pass, readFile ReadFileFunc) ReadFileFunc { + return func(filename string) ([]byte, error) { + if err := CheckReadable(pass, filename); err != nil { + return nil, err + } + return readFile(filename) + } +} + +// CheckReadable enforces the access policy defined by the ReadFile field of [analysis.Pass]. +func CheckReadable(pass *analysis.Pass, filename string) error { + if slices.Contains(pass.OtherFiles, filename) || + slices.Contains(pass.IgnoredFiles, filename) { + return nil + } + for _, f := range pass.Files { + if pass.Fset.File(f.FileStart).Name() == filename { + return nil + } + } + return fmt.Errorf("Pass.ReadFile: %s is not among OtherFiles, IgnoredFiles, or names of Files", filename) +} diff --git a/hack/tools/vendor/golang.org/x/tools/internal/analysis/driverutil/url.go b/hack/tools/vendor/golang.org/x/tools/internal/analysis/driverutil/url.go new file mode 100644 index 000000000000..93b3ecfd4917 --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/internal/analysis/driverutil/url.go @@ -0,0 +1,33 @@ +// Copyright 2023 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 driverutil + +import ( + "fmt" + "net/url" + + "golang.org/x/tools/go/analysis" +) + +// ResolveURL resolves the URL field for a Diagnostic from an Analyzer +// and returns the URL. See Diagnostic.URL for details. +func ResolveURL(a *analysis.Analyzer, d analysis.Diagnostic) (string, error) { + if d.URL == "" && d.Category == "" && a.URL == "" { + return "", nil // do nothing + } + raw := d.URL + if d.URL == "" && d.Category != "" { + raw = "#" + d.Category + } + u, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("invalid Diagnostic.URL %q: %s", raw, err) + } + base, err := url.Parse(a.URL) + if err != nil { + return "", fmt.Errorf("invalid Analyzer.URL %q: %s", a.URL, err) + } + return base.ResolveReference(u).String(), nil +} diff --git a/hack/tools/vendor/golang.org/x/tools/internal/analysis/driverutil/validatefix.go b/hack/tools/vendor/golang.org/x/tools/internal/analysis/driverutil/validatefix.go new file mode 100644 index 000000000000..7efc4197d685 --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/internal/analysis/driverutil/validatefix.go @@ -0,0 +1,118 @@ +// Copyright 2025 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 driverutil + +// This file defines the validation of SuggestedFixes. + +import ( + "cmp" + "fmt" + "go/token" + "slices" + + "golang.org/x/tools/go/analysis" +) + +// ValidateFixes validates the set of fixes for a single diagnostic. +// Any error indicates a bug in the originating analyzer. +// +// It updates fixes so that fixes[*].End.IsValid(). +// +// It may be used as part of an analysis driver implementation. +func ValidateFixes(fset *token.FileSet, a *analysis.Analyzer, fixes []analysis.SuggestedFix) error { + fixMessages := make(map[string]bool) + for i := range fixes { + fix := &fixes[i] + if fixMessages[fix.Message] { + return fmt.Errorf("analyzer %q suggests two fixes with same Message (%s)", a.Name, fix.Message) + } + fixMessages[fix.Message] = true + if err := validateFix(fset, fix); err != nil { + return fmt.Errorf("analyzer %q suggests invalid fix (%s): %v", a.Name, fix.Message, err) + } + } + return nil +} + +// validateFix validates a single fix. +// Any error indicates a bug in the originating analyzer. +// +// It updates fix so that fix.End.IsValid(). +func validateFix(fset *token.FileSet, fix *analysis.SuggestedFix) error { + + // Stably sort edits by Pos. This ordering puts insertions + // (end = start) before deletions (end > start) at the same + // point, but uses a stable sort to preserve the order of + // multiple insertions at the same point. + slices.SortStableFunc(fix.TextEdits, func(x, y analysis.TextEdit) int { + if sign := cmp.Compare(x.Pos, y.Pos); sign != 0 { + return sign + } + return cmp.Compare(x.End, y.End) + }) + + var prev *analysis.TextEdit + for i := range fix.TextEdits { + edit := &fix.TextEdits[i] + + // Validate edit individually. + start := edit.Pos + file := fset.File(start) + if file == nil { + return fmt.Errorf("no token.File for TextEdit.Pos (%v)", edit.Pos) + } + fileEnd := token.Pos(file.Base() + file.Size()) + if end := edit.End; end.IsValid() { + if end < start { + return fmt.Errorf("TextEdit.Pos (%v) > TextEdit.End (%v)", edit.Pos, edit.End) + } + endFile := fset.File(end) + if endFile != file && end < fileEnd+10 { + // Relax the checks below in the special case when the end position + // is only slightly beyond EOF, as happens when End is computed + // (as in ast.{Struct,Interface}Type) rather than based on + // actual token positions. In such cases, truncate end to EOF. + // + // This is a workaround for #71659; see: + // https://github.com/golang/go/issues/71659#issuecomment-2651606031 + // A better fix would be more faithful recording of token + // positions (or their absence) in the AST. + edit.End = fileEnd + continue + } + if endFile == nil { + return fmt.Errorf("no token.File for TextEdit.End (%v; File(start).FileEnd is %d)", end, file.Base()+file.Size()) + } + if endFile != file { + return fmt.Errorf("edit #%d spans files (%v and %v)", + i, file.Position(edit.Pos), endFile.Position(edit.End)) + } + } else { + edit.End = start // update the SuggestedFix + } + if eof := fileEnd; edit.End > eof { + return fmt.Errorf("end is (%v) beyond end of file (%v)", edit.End, eof) + } + + // Validate the sequence of edits: + // properly ordered, no overlapping deletions + if prev != nil && edit.Pos < prev.End { + xpos := fset.Position(prev.Pos) + xend := fset.Position(prev.End) + ypos := fset.Position(edit.Pos) + yend := fset.Position(edit.End) + return fmt.Errorf("overlapping edits to %s (%d:%d-%d:%d and %d:%d-%d:%d)", + xpos.Filename, + xpos.Line, xpos.Column, + xend.Line, xend.Column, + ypos.Line, ypos.Column, + yend.Line, yend.Column, + ) + } + prev = edit + } + + return nil +} diff --git a/hack/tools/vendor/golang.org/x/tools/internal/astutil/free/free.go b/hack/tools/vendor/golang.org/x/tools/internal/astutil/free/free.go new file mode 100644 index 000000000000..2c4d2c4e52f4 --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/internal/astutil/free/free.go @@ -0,0 +1,418 @@ +// Copyright 2025 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 free defines utilities for computing the free variables of +// a syntax tree without type information. This is inherently +// heuristic because of the T{f: x} ambiguity, in which f may or may +// not be a lexical reference depending on whether T is a struct type. +package free + +import ( + "go/ast" + "go/token" +) + +// Copied, with considerable changes, from go/parser/resolver.go +// at af53bd2c03. + +// Names computes an approximation to the set of free names of the AST +// at node n based solely on syntax. +// +// In the absence of composite literals, the set of free names is exact. Composite +// literals introduce an ambiguity that can only be resolved with type information: +// whether F is a field name or a value in `T{F: ...}`. +// If includeComplitIdents is true, this function conservatively assumes +// T is not a struct type, so freeishNames overapproximates: the resulting +// set may contain spurious entries that are not free lexical references +// but are references to struct fields. +// If includeComplitIdents is false, this function assumes that T *is* +// a struct type, so freeishNames underapproximates: the resulting set +// may omit names that are free lexical references. +// +// TODO(adonovan): includeComplitIdents is a crude hammer: the caller +// may have partial or heuristic information about whether a given T +// is struct type. Replace includeComplitIdents with a hook to query +// the caller. +// +// The code is based on go/parser.resolveFile, but heavily simplified. Crucial +// differences are: +// - Instead of resolving names to their objects, this function merely records +// whether they are free. +// - Labels are ignored: they do not refer to values. +// - This is never called on ImportSpecs, so the function panics if it sees one. +func Names(n ast.Node, includeComplitIdents bool) map[string]bool { + v := &freeVisitor{ + free: make(map[string]bool), + includeComplitIdents: includeComplitIdents, + } + // Begin with a scope, even though n might not be a form that establishes a scope. + // For example, n might be: + // x := ... + // Then we need to add the first x to some scope. + v.openScope() + ast.Walk(v, n) + v.closeScope() + if v.scope != nil { + panic("unbalanced scopes") + } + return v.free +} + +// A freeVisitor holds state for a free-name analysis. +type freeVisitor struct { + scope *scope // the current innermost scope + free map[string]bool // free names seen so far + includeComplitIdents bool // include identifier key in composite literals +} + +// scope contains all the names defined in a lexical scope. +// It is like ast.Scope, but without deprecation warnings. +type scope struct { + names map[string]bool + outer *scope +} + +func (s *scope) defined(name string) bool { + for ; s != nil; s = s.outer { + if s.names[name] { + return true + } + } + return false +} + +func (v *freeVisitor) Visit(n ast.Node) ast.Visitor { + switch n := n.(type) { + + // Expressions. + case *ast.Ident: + v.use(n) + + case *ast.FuncLit: + v.openScope() + defer v.closeScope() + v.walkFuncType(nil, n.Type) + v.walkBody(n.Body) + + case *ast.SelectorExpr: + v.walk(n.X) + // Skip n.Sel: it cannot be free. + + case *ast.StructType: + v.openScope() + defer v.closeScope() + v.walkFieldList(n.Fields) + + case *ast.FuncType: + v.openScope() + defer v.closeScope() + v.walkFuncType(nil, n) + + case *ast.CompositeLit: + v.walk(n.Type) + for _, e := range n.Elts { + if kv, _ := e.(*ast.KeyValueExpr); kv != nil { + if ident, _ := kv.Key.(*ast.Ident); ident != nil { + // It is not possible from syntax alone to know whether + // an identifier used as a composite literal key is + // a struct field (if n.Type is a struct) or a value + // (if n.Type is a map, slice or array). + if v.includeComplitIdents { + // Over-approximate by treating both cases as potentially + // free names. + v.use(ident) + } else { + // Under-approximate by ignoring potentially free names. + } + } else { + v.walk(kv.Key) + } + v.walk(kv.Value) + } else { + v.walk(e) + } + } + + case *ast.InterfaceType: + v.openScope() + defer v.closeScope() + v.walkFieldList(n.Methods) + + // Statements + case *ast.AssignStmt: + walkSlice(v, n.Rhs) + if n.Tok == token.DEFINE { + v.shortVarDecl(n.Lhs) + } else { + walkSlice(v, n.Lhs) + } + + case *ast.LabeledStmt: + // Ignore labels. + v.walk(n.Stmt) + + case *ast.BranchStmt: + // Ignore labels. + + case *ast.BlockStmt: + v.openScope() + defer v.closeScope() + walkSlice(v, n.List) + + case *ast.IfStmt: + v.openScope() + defer v.closeScope() + v.walk(n.Init) + v.walk(n.Cond) + v.walk(n.Body) + v.walk(n.Else) + + case *ast.CaseClause: + walkSlice(v, n.List) + v.openScope() + defer v.closeScope() + walkSlice(v, n.Body) + + case *ast.SwitchStmt: + v.openScope() + defer v.closeScope() + v.walk(n.Init) + v.walk(n.Tag) + v.walkBody(n.Body) + + case *ast.TypeSwitchStmt: + v.openScope() + defer v.closeScope() + if n.Init != nil { + v.walk(n.Init) + } + v.walk(n.Assign) + // We can use walkBody here because we don't track label scopes. + v.walkBody(n.Body) + + case *ast.CommClause: + v.openScope() + defer v.closeScope() + v.walk(n.Comm) + walkSlice(v, n.Body) + + case *ast.SelectStmt: + v.walkBody(n.Body) + + case *ast.ForStmt: + v.openScope() + defer v.closeScope() + v.walk(n.Init) + v.walk(n.Cond) + v.walk(n.Post) + v.walk(n.Body) + + case *ast.RangeStmt: + v.openScope() + defer v.closeScope() + v.walk(n.X) + var lhs []ast.Expr + if n.Key != nil { + lhs = append(lhs, n.Key) + } + if n.Value != nil { + lhs = append(lhs, n.Value) + } + if len(lhs) > 0 { + if n.Tok == token.DEFINE { + v.shortVarDecl(lhs) + } else { + walkSlice(v, lhs) + } + } + v.walk(n.Body) + + // Declarations + case *ast.GenDecl: + switch n.Tok { + case token.CONST, token.VAR: + for _, spec := range n.Specs { + spec := spec.(*ast.ValueSpec) + walkSlice(v, spec.Values) + v.walk(spec.Type) + v.declare(spec.Names...) + } + + case token.TYPE: + for _, spec := range n.Specs { + spec := spec.(*ast.TypeSpec) + // Go spec: The scope of a type identifier declared inside a + // function begins at the identifier in the TypeSpec and ends + // at the end of the innermost containing block. + v.declare(spec.Name) + if spec.TypeParams != nil { + v.openScope() + defer v.closeScope() + v.walkTypeParams(spec.TypeParams) + } + v.walk(spec.Type) + } + + case token.IMPORT: + panic("encountered import declaration in free analysis") + } + + case *ast.FuncDecl: + if n.Recv == nil && n.Name.Name != "init" { // package-level function + v.declare(n.Name) + } + v.openScope() + defer v.closeScope() + v.walkTypeParams(n.Type.TypeParams) + v.walkFuncType(n.Recv, n.Type) + v.walkBody(n.Body) + + default: + return v + } + + return nil +} + +func (v *freeVisitor) openScope() { + v.scope = &scope{map[string]bool{}, v.scope} +} + +func (v *freeVisitor) closeScope() { + v.scope = v.scope.outer +} + +func (v *freeVisitor) walk(n ast.Node) { + if n != nil { + ast.Walk(v, n) + } +} + +func (v *freeVisitor) walkFuncType(recv *ast.FieldList, typ *ast.FuncType) { + // First use field types... + v.walkRecvFieldType(recv) + v.walkFieldTypes(typ.Params) + v.walkFieldTypes(typ.Results) + + // ...then declare field names. + v.declareFieldNames(recv) + v.declareFieldNames(typ.Params) + v.declareFieldNames(typ.Results) +} + +// A receiver field is not like a param or result field because +// "func (recv R[T]) method()" uses R but declares T. +func (v *freeVisitor) walkRecvFieldType(list *ast.FieldList) { + if list == nil { + return + } + for _, f := range list.List { // valid => len=1 + typ := f.Type + if ptr, ok := typ.(*ast.StarExpr); ok { + typ = ptr.X + } + + // Analyze receiver type as Base[Index, ...] + var ( + base ast.Expr + indices []ast.Expr + ) + switch typ := typ.(type) { + case *ast.IndexExpr: // B[T] + base, indices = typ.X, []ast.Expr{typ.Index} + case *ast.IndexListExpr: // B[K, V] + base, indices = typ.X, typ.Indices + default: // B + base = typ + } + for _, expr := range indices { + if id, ok := expr.(*ast.Ident); ok { + v.declare(id) + } + } + v.walk(base) + } +} + +// walkTypeParams is like walkFieldList, but declares type parameters eagerly so +// that they may be resolved in the constraint expressions held in the field +// Type. +func (v *freeVisitor) walkTypeParams(list *ast.FieldList) { + v.declareFieldNames(list) + v.walkFieldTypes(list) // constraints +} + +func (v *freeVisitor) walkBody(body *ast.BlockStmt) { + if body == nil { + return + } + walkSlice(v, body.List) +} + +func (v *freeVisitor) walkFieldList(list *ast.FieldList) { + if list == nil { + return + } + v.walkFieldTypes(list) // .Type may contain references + v.declareFieldNames(list) // .Names declares names +} + +func (v *freeVisitor) shortVarDecl(lhs []ast.Expr) { + // Go spec: A short variable declaration may redeclare variables provided + // they were originally declared in the same block with the same type, and + // at least one of the non-blank variables is new. + // + // However, it doesn't matter to free analysis whether a variable is declared + // fresh or redeclared. + for _, x := range lhs { + // In a well-formed program each expr must be an identifier, + // but be forgiving. + if id, ok := x.(*ast.Ident); ok { + v.declare(id) + } + } +} + +func walkSlice[S ~[]E, E ast.Node](r *freeVisitor, list S) { + for _, e := range list { + r.walk(e) + } +} + +// walkFieldTypes resolves the types of the walkFieldTypes in list. +// The companion method declareFieldList declares the names of the walkFieldTypes. +func (v *freeVisitor) walkFieldTypes(list *ast.FieldList) { + if list != nil { + for _, f := range list.List { + v.walk(f.Type) + } + } +} + +// declareFieldNames declares the names of the fields in list. +// (Names in a FieldList always establish new bindings.) +// The companion method resolveFieldList resolves the types of the fields. +func (v *freeVisitor) declareFieldNames(list *ast.FieldList) { + if list != nil { + for _, f := range list.List { + v.declare(f.Names...) + } + } +} + +// use marks ident as free if it is not in scope. +func (v *freeVisitor) use(ident *ast.Ident) { + if s := ident.Name; s != "_" && !v.scope.defined(s) { + v.free[s] = true + } +} + +// declare adds each non-blank ident to the current scope. +func (v *freeVisitor) declare(idents ...*ast.Ident) { + for _, id := range idents { + if id.Name != "_" { + v.scope.names[id.Name] = true + } + } +} diff --git a/hack/tools/vendor/golang.org/x/tools/internal/diff/diff.go b/hack/tools/vendor/golang.org/x/tools/internal/diff/diff.go new file mode 100644 index 000000000000..c12bdfd2acd6 --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/internal/diff/diff.go @@ -0,0 +1,177 @@ +// 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 diff computes differences between text files or strings. +package diff + +import ( + "fmt" + "slices" + "sort" + "strings" +) + +// An Edit describes the replacement of a portion of a text file. +type Edit struct { + Start, End int // byte offsets of the region to replace + New string // the replacement +} + +func (e Edit) String() string { + return fmt.Sprintf("{Start:%d,End:%d,New:%q}", e.Start, e.End, e.New) +} + +// Apply applies a sequence of edits to the src buffer and returns the +// result. Edits are applied in order of start offset; edits with the +// same start offset are applied in they order they were provided. +// +// Apply returns an error if any edit is out of bounds, +// or if any pair of edits is overlapping. +func Apply(src string, edits []Edit) (string, error) { + edits, size, err := validate(src, edits) + if err != nil { + return "", err + } + + // Apply edits. + out := make([]byte, 0, size) + lastEnd := 0 + for _, edit := range edits { + if lastEnd < edit.Start { + out = append(out, src[lastEnd:edit.Start]...) + } + out = append(out, edit.New...) + lastEnd = edit.End + } + out = append(out, src[lastEnd:]...) + + if len(out) != size { + panic("wrong size") + } + + return string(out), nil +} + +// ApplyBytes is like Apply, but it accepts a byte slice. +// The result is always a new array. +func ApplyBytes(src []byte, edits []Edit) ([]byte, error) { + res, err := Apply(string(src), edits) + return []byte(res), err +} + +// validate checks that edits are consistent with src, +// and returns the size of the patched output. +// It may return a different slice. +func validate(src string, edits []Edit) ([]Edit, int, error) { + if !sort.IsSorted(editsSort(edits)) { + edits = slices.Clone(edits) + SortEdits(edits) + } + + // Check validity of edits and compute final size. + size := len(src) + lastEnd := 0 + for _, edit := range edits { + if !(0 <= edit.Start && edit.Start <= edit.End && edit.End <= len(src)) { + return nil, 0, fmt.Errorf("diff has out-of-bounds edits") + } + if edit.Start < lastEnd { + return nil, 0, fmt.Errorf("diff has overlapping edits") + } + size += len(edit.New) + edit.Start - edit.End + lastEnd = edit.End + } + + return edits, size, nil +} + +// SortEdits orders a slice of Edits by (start, end) offset. +// This ordering puts insertions (end = start) before deletions +// (end > start) at the same point, but uses a stable sort to preserve +// the order of multiple insertions at the same point. +// (Apply detects multiple deletions at the same point as an error.) +func SortEdits(edits []Edit) { + sort.Stable(editsSort(edits)) +} + +type editsSort []Edit + +func (a editsSort) Len() int { return len(a) } +func (a editsSort) Less(i, j int) bool { + if cmp := a[i].Start - a[j].Start; cmp != 0 { + return cmp < 0 + } + return a[i].End < a[j].End +} +func (a editsSort) Swap(i, j int) { a[i], a[j] = a[j], a[i] } + +// lineEdits expands and merges a sequence of edits so that each +// resulting edit replaces one or more complete lines. +// See ApplyEdits for preconditions. +func lineEdits(src string, edits []Edit) ([]Edit, error) { + edits, _, err := validate(src, edits) + if err != nil { + return nil, err + } + + // Do all deletions begin and end at the start of a line, + // and all insertions end with a newline? + // (This is merely a fast path.) + for _, edit := range edits { + if edit.Start >= len(src) || // insertion at EOF + edit.Start > 0 && src[edit.Start-1] != '\n' || // not at line start + edit.End > 0 && src[edit.End-1] != '\n' || // not at line start + edit.New != "" && edit.New[len(edit.New)-1] != '\n' { // partial insert + goto expand // slow path + } + } + return edits, nil // aligned + +expand: + if len(edits) == 0 { + return edits, nil // no edits (unreachable due to fast path) + } + expanded := make([]Edit, 0, len(edits)) // a guess + prev := edits[0] + // TODO(adonovan): opt: start from the first misaligned edit. + // TODO(adonovan): opt: avoid quadratic cost of string += string. + for _, edit := range edits[1:] { + between := src[prev.End:edit.Start] + if !strings.Contains(between, "\n") { + // overlapping lines: combine with previous edit. + prev.New += between + edit.New + prev.End = edit.End + } else { + // non-overlapping lines: flush previous edit. + expanded = append(expanded, expandEdit(prev, src)) + prev = edit + } + } + return append(expanded, expandEdit(prev, src)), nil // flush final edit +} + +// expandEdit returns edit expanded to complete whole lines. +func expandEdit(edit Edit, src string) Edit { + // Expand start left to start of line. + // (delta is the zero-based column number of start.) + start := edit.Start + if delta := start - 1 - strings.LastIndex(src[:start], "\n"); delta > 0 { + edit.Start -= delta + edit.New = src[start-delta:start] + edit.New + } + + // Expand end right to end of line. + end := edit.End + if end > 0 && src[end-1] != '\n' || + edit.New != "" && edit.New[len(edit.New)-1] != '\n' { + if nl := strings.IndexByte(src[end:], '\n'); nl < 0 { + edit.End = len(src) // extend to EOF + } else { + edit.End = end + nl + 1 // extend beyond \n + } + } + edit.New += src[end:edit.End] + + return edit +} diff --git a/hack/tools/vendor/golang.org/x/tools/internal/diff/lcs/common.go b/hack/tools/vendor/golang.org/x/tools/internal/diff/lcs/common.go new file mode 100644 index 000000000000..27fa9ecbd5c5 --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/internal/diff/lcs/common.go @@ -0,0 +1,179 @@ +// Copyright 2022 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 lcs + +import ( + "log" + "sort" +) + +// lcs is a longest common sequence +type lcs []diag + +// A diag is a piece of the edit graph where A[X+i] == B[Y+i], for 0<=i l[j].Len + }) + return l +} + +// validate that the elements of the lcs do not overlap +// (can only happen when the two-sided algorithm ends early) +// expects the lcs to be sorted +func (l lcs) valid() bool { + for i := 1; i < len(l); i++ { + if l[i-1].X+l[i-1].Len > l[i].X { + return false + } + if l[i-1].Y+l[i-1].Len > l[i].Y { + return false + } + } + return true +} + +// repair overlapping lcs +// only called if two-sided stops early +func (l lcs) fix() lcs { + // from the set of diagonals in l, find a maximal non-conflicting set + // this problem may be NP-complete, but we use a greedy heuristic, + // which is quadratic, but with a better data structure, could be D log D. + // independent is not enough: {0,3,1} and {3,0,2} can't both occur in an lcs + // which has to have monotone x and y + if len(l) == 0 { + return nil + } + sort.Slice(l, func(i, j int) bool { return l[i].Len > l[j].Len }) + tmp := make(lcs, 0, len(l)) + tmp = append(tmp, l[0]) + for i := 1; i < len(l); i++ { + var dir direction + nxt := l[i] + for _, in := range tmp { + if dir, nxt = overlap(in, nxt); dir == empty || dir == bad { + break + } + } + if nxt.Len > 0 && dir != bad { + tmp = append(tmp, nxt) + } + } + tmp.sort() + if false && !tmp.valid() { // debug checking + log.Fatalf("here %d", len(tmp)) + } + return tmp +} + +type direction int + +const ( + empty direction = iota // diag is empty (so not in lcs) + leftdown // proposed acceptably to the left and below + rightup // proposed diag is acceptably to the right and above + bad // proposed diag is inconsistent with the lcs so far +) + +// overlap trims the proposed diag prop so it doesn't overlap with +// the existing diag that has already been added to the lcs. +func overlap(exist, prop diag) (direction, diag) { + if prop.X <= exist.X && exist.X < prop.X+prop.Len { + // remove the end of prop where it overlaps with the X end of exist + delta := prop.X + prop.Len - exist.X + prop.Len -= delta + if prop.Len <= 0 { + return empty, prop + } + } + if exist.X <= prop.X && prop.X < exist.X+exist.Len { + // remove the beginning of prop where overlaps with exist + delta := exist.X + exist.Len - prop.X + prop.Len -= delta + if prop.Len <= 0 { + return empty, prop + } + prop.X += delta + prop.Y += delta + } + if prop.Y <= exist.Y && exist.Y < prop.Y+prop.Len { + // remove the end of prop that overlaps (in Y) with exist + delta := prop.Y + prop.Len - exist.Y + prop.Len -= delta + if prop.Len <= 0 { + return empty, prop + } + } + if exist.Y <= prop.Y && prop.Y < exist.Y+exist.Len { + // remove the beginning of peop that overlaps with exist + delta := exist.Y + exist.Len - prop.Y + prop.Len -= delta + if prop.Len <= 0 { + return empty, prop + } + prop.X += delta // no test reaches this code + prop.Y += delta + } + if prop.X+prop.Len <= exist.X && prop.Y+prop.Len <= exist.Y { + return leftdown, prop + } + if exist.X+exist.Len <= prop.X && exist.Y+exist.Len <= prop.Y { + return rightup, prop + } + // prop can't be in an lcs that contains exist + return bad, prop +} + +// manipulating Diag and lcs + +// prepend a diagonal (x,y)-(x+1,y+1) segment either to an empty lcs +// or to its first Diag. prepend is only called to extend diagonals +// the backward direction. +func (lcs lcs) prepend(x, y int) lcs { + if len(lcs) > 0 { + d := &lcs[0] + if int(d.X) == x+1 && int(d.Y) == y+1 { + // extend the diagonal down and to the left + d.X, d.Y = int(x), int(y) + d.Len++ + return lcs + } + } + + r := diag{X: int(x), Y: int(y), Len: 1} + lcs = append([]diag{r}, lcs...) + return lcs +} + +// append appends a diagonal, or extends the existing one. +// by adding the edge (x,y)-(x+1.y+1). append is only called +// to extend diagonals in the forward direction. +func (lcs lcs) append(x, y int) lcs { + if len(lcs) > 0 { + last := &lcs[len(lcs)-1] + // Expand last element if adjoining. + if last.X+last.Len == x && last.Y+last.Len == y { + last.Len++ + return lcs + } + } + + return append(lcs, diag{X: x, Y: y, Len: 1}) +} + +// enforce constraint on d, k +func ok(d, k int) bool { + return d >= 0 && -d <= k && k <= d +} diff --git a/hack/tools/vendor/golang.org/x/tools/internal/diff/lcs/doc.go b/hack/tools/vendor/golang.org/x/tools/internal/diff/lcs/doc.go new file mode 100644 index 000000000000..aa4b0fb5910e --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/internal/diff/lcs/doc.go @@ -0,0 +1,156 @@ +// Copyright 2022 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 lcs contains code to find longest-common-subsequences +// (and diffs) +package lcs + +/* +Compute longest-common-subsequences of two slices A, B using +algorithms from Myers' paper. A longest-common-subsequence +(LCS from now on) of A and B is a maximal set of lexically increasing +pairs of subscripts (x,y) with A[x]==B[y]. There may be many LCS, but +they all have the same length. An LCS determines a sequence of edits +that changes A into B. + +The key concept is the edit graph of A and B. +If A has length N and B has length M, then the edit graph has +vertices v[i][j] for 0 <= i <= N, 0 <= j <= M. There is a +horizontal edge from v[i][j] to v[i+1][j] whenever both are in +the graph, and a vertical edge from v[i][j] to f[i][j+1] similarly. +When A[i] == B[j] there is a diagonal edge from v[i][j] to v[i+1][j+1]. + +A path between in the graph between (0,0) and (N,M) determines a sequence +of edits converting A into B: each horizontal edge corresponds to removing +an element of A, and each vertical edge corresponds to inserting an +element of B. + +A vertex (x,y) is on (forward) diagonal k if x-y=k. A path in the graph +is of length D if it has D non-diagonal edges. The algorithms generate +forward paths (in which at least one of x,y increases at each edge), +or backward paths (in which at least one of x,y decreases at each edge), +or a combination. (Note that the orientation is the traditional mathematical one, +with the origin in the lower-left corner.) + +Here is the edit graph for A:"aabbaa", B:"aacaba". (I know the diagonals look weird.) + ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ + a | ___/‾‾‾ | ___/‾‾‾ | | | ___/‾‾‾ | ___/‾‾‾ | + ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ + b | | | ___/‾‾‾ | ___/‾‾‾ | | | + ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ + a | ___/‾‾‾ | ___/‾‾‾ | | | ___/‾‾‾ | ___/‾‾‾ | + ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ + c | | | | | | | + ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ + a | ___/‾‾‾ | ___/‾‾‾ | | | ___/‾‾‾ | ___/‾‾‾ | + ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ + a | ___/‾‾‾ | ___/‾‾‾ | | | ___/‾‾‾ | ___/‾‾‾ | + ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ + a a b b a a + + +The algorithm labels a vertex (x,y) with D,k if it is on diagonal k and at +the end of a maximal path of length D. (Because x-y=k it suffices to remember +only the x coordinate of the vertex.) + +The forward algorithm: Find the longest diagonal starting at (0,0) and +label its end with D=0,k=0. From that vertex take a vertical step and +then follow the longest diagonal (up and to the right), and label that vertex +with D=1,k=-1. From the D=0,k=0 point take a horizontal step and the follow +the longest diagonal (up and to the right) and label that vertex +D=1,k=1. In the same way, having labelled all the D vertices, +from a vertex labelled D,k find two vertices +tentatively labelled D+1,k-1 and D+1,k+1. There may be two on the same +diagonal, in which case take the one with the larger x. + +Eventually the path gets to (N,M), and the diagonals on it are the LCS. + +Here is the edit graph with the ends of D-paths labelled. (So, for instance, +0/2,2 indicates that x=2,y=2 is labelled with 0, as it should be, since the first +step is to go up the longest diagonal from (0,0).) +A:"aabbaa", B:"aacaba" + ⊙ ------- ⊙ ------- ⊙ -------(3/3,6)------- ⊙ -------(3/5,6)-------(4/6,6) + a | ___/‾‾‾ | ___/‾‾‾ | | | ___/‾‾‾ | ___/‾‾‾ | + ⊙ ------- ⊙ ------- ⊙ -------(2/3,5)------- ⊙ ------- ⊙ ------- ⊙ + b | | | ___/‾‾‾ | ___/‾‾‾ | | | + ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ -------(3/5,4)------- ⊙ + a | ___/‾‾‾ | ___/‾‾‾ | | | ___/‾‾‾ | ___/‾‾‾ | + ⊙ ------- ⊙ -------(1/2,3)-------(2/3,3)------- ⊙ ------- ⊙ ------- ⊙ + c | | | | | | | + ⊙ ------- ⊙ -------(0/2,2)-------(1/3,2)-------(2/4,2)-------(3/5,2)-------(4/6,2) + a | ___/‾‾‾ | ___/‾‾‾ | | | ___/‾‾‾ | ___/‾‾‾ | + ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ + a | ___/‾‾‾ | ___/‾‾‾ | | | ___/‾‾‾ | ___/‾‾‾ | + ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ ------- ⊙ + a a b b a a + +The 4-path is reconstructed starting at (4/6,6), horizontal to (3/5,6), diagonal to (3,4), vertical +to (2/3,3), horizontal to (1/2,3), vertical to (0/2,2), and diagonal to (0,0). As expected, +there are 4 non-diagonal steps, and the diagonals form an LCS. + +There is a symmetric backward algorithm, which gives (backwards labels are prefixed with a colon): +A:"aabbaa", B:"aacaba" + ⊙ -------- ⊙ -------- ⊙ -------- ⊙ -------- ⊙ -------- ⊙ -------- ⊙ + a | ____/‾‾‾ | ____/‾‾‾ | | | ____/‾‾‾ | ____/‾‾‾ | + ⊙ -------- ⊙ -------- ⊙ -------- ⊙ -------- ⊙ --------(:0/5,5)-------- ⊙ + b | | | ____/‾‾‾ | ____/‾‾‾ | | | + ⊙ -------- ⊙ -------- ⊙ --------(:1/3,4)-------- ⊙ -------- ⊙ -------- ⊙ + a | ____/‾‾‾ | ____/‾‾‾ | | | ____/‾‾‾ | ____/‾‾‾ | + (:3/0,3)--------(:2/1,3)-------- ⊙ --------(:2/3,3)--------(:1/4,3)-------- ⊙ -------- ⊙ + c | | | | | | | + ⊙ -------- ⊙ -------- ⊙ --------(:3/3,2)--------(:2/4,2)-------- ⊙ -------- ⊙ + a | ____/‾‾‾ | ____/‾‾‾ | | | ____/‾‾‾ | ____/‾‾‾ | + (:3/0,1)-------- ⊙ -------- ⊙ -------- ⊙ --------(:3/4,1)-------- ⊙ -------- ⊙ + a | ____/‾‾‾ | ____/‾‾‾ | | | ____/‾‾‾ | ____/‾‾‾ | + (:4/0,0)-------- ⊙ -------- ⊙ -------- ⊙ --------(:4/4,0)-------- ⊙ -------- ⊙ + a a b b a a + +Neither of these is ideal for use in an editor, where it is undesirable to send very long diffs to the +front end. It's tricky to decide exactly what 'very long diffs' means, as "replace A by B" is very short. +We want to control how big D can be, by stopping when it gets too large. The forward algorithm then +privileges common prefixes, and the backward algorithm privileges common suffixes. Either is an undesirable +asymmetry. + +Fortunately there is a two-sided algorithm, implied by results in Myers' paper. Here's what the labels in +the edit graph look like. +A:"aabbaa", B:"aacaba" + ⊙ --------- ⊙ --------- ⊙ --------- ⊙ --------- ⊙ --------- ⊙ --------- ⊙ + a | ____/‾‾‾‾ | ____/‾‾‾‾ | | | ____/‾‾‾‾ | ____/‾‾‾‾ | + ⊙ --------- ⊙ --------- ⊙ --------- (2/3,5) --------- ⊙ --------- (:0/5,5)--------- ⊙ + b | | | ____/‾‾‾‾ | ____/‾‾‾‾ | | | + ⊙ --------- ⊙ --------- ⊙ --------- (:1/3,4)--------- ⊙ --------- ⊙ --------- ⊙ + a | ____/‾‾‾‾ | ____/‾‾‾‾ | | | ____/‾‾‾‾ | ____/‾‾‾‾ | + ⊙ --------- (:2/1,3)--------- (1/2,3) ---------(2:2/3,3)--------- (:1/4,3)--------- ⊙ --------- ⊙ + c | | | | | | | + ⊙ --------- ⊙ --------- (0/2,2) --------- (1/3,2) ---------(2:2/4,2)--------- ⊙ --------- ⊙ + a | ____/‾‾‾‾ | ____/‾‾‾‾ | | | ____/‾‾‾‾ | ____/‾‾‾‾ | + ⊙ --------- ⊙ --------- ⊙ --------- ⊙ --------- ⊙ --------- ⊙ --------- ⊙ + a | ____/‾‾‾‾ | ____/‾‾‾‾ | | | ____/‾‾‾‾ | ____/‾‾‾‾ | + ⊙ --------- ⊙ --------- ⊙ --------- ⊙ --------- ⊙ --------- ⊙ --------- ⊙ + a a b b a a + +The algorithm stopped when it saw the backwards 2-path ending at (1,3) and the forwards 2-path ending at (3,5). The criterion +is a backwards path ending at (u,v) and a forward path ending at (x,y), where u <= x and the two points are on the same +diagonal. (Here the edgegraph has a diagonal, but the criterion is x-y=u-v.) Myers proves there is a forward +2-path from (0,0) to (1,3), and that together with the backwards 2-path ending at (1,3) gives the expected 4-path. +Unfortunately the forward path has to be constructed by another run of the forward algorithm; it can't be found from the +computed labels. That is the worst case. Had the code noticed (x,y)=(u,v)=(3,3) the whole path could be reconstructed +from the edgegraph. The implementation looks for a number of special cases to try to avoid computing an extra forward path. + +If the two-sided algorithm has stop early (because D has become too large) it will have found a forward LCS and a +backwards LCS. Ideally these go with disjoint prefixes and suffixes of A and B, but disjointedness may fail and the two +computed LCS may conflict. (An easy example is where A is a suffix of B, and shares a short prefix. The backwards LCS +is all of A, and the forward LCS is a prefix of A.) The algorithm combines the two +to form a best-effort LCS. In the worst case the forward partial LCS may have to +be recomputed. +*/ + +/* Eugene Myers paper is titled +"An O(ND) Difference Algorithm and Its Variations" +and can be found at +http://www.xmailserver.org/diff2.pdf + +(There is a generic implementation of the algorithm the repository with git hash +b9ad7e4ade3a686d608e44475390ad428e60e7fc) +*/ diff --git a/hack/tools/vendor/golang.org/x/tools/internal/diff/lcs/git.sh b/hack/tools/vendor/golang.org/x/tools/internal/diff/lcs/git.sh new file mode 100644 index 000000000000..b25ba4aac74b --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/internal/diff/lcs/git.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# +# Copyright 2022 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. +# +# Creates a zip file containing all numbered versions +# of the commit history of a large source file, for use +# as input data for the tests of the diff algorithm. +# +# Run script from root of the x/tools repo. + +set -eu + +# WARNING: This script will install the latest version of $file +# The largest real source file in the x/tools repo. +# file=internal/golang/completion/completion.go +# file=internal/golang/diagnostics.go +file=internal/protocol/tsprotocol.go + +tmp=$(mktemp -d) +git log $file | + awk '/^commit / {print $2}' | + nl -ba -nrz | + while read n hash; do + git checkout --quiet $hash $file + cp -f $file $tmp/$n + done +(cd $tmp && zip -q - *) > testdata.zip +rm -fr $tmp +git restore --staged $file +git restore $file +echo "Created testdata.zip" diff --git a/hack/tools/vendor/golang.org/x/tools/internal/diff/lcs/labels.go b/hack/tools/vendor/golang.org/x/tools/internal/diff/lcs/labels.go new file mode 100644 index 000000000000..504913d1da3c --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/internal/diff/lcs/labels.go @@ -0,0 +1,55 @@ +// Copyright 2022 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 lcs + +import ( + "fmt" +) + +// For each D, vec[D] has length D+1, +// and the label for (D, k) is stored in vec[D][(D+k)/2]. +type label struct { + vec [][]int +} + +// Temporary checking DO NOT COMMIT true TO PRODUCTION CODE +const debug = false + +// debugging. check that the (d,k) pair is valid +// (that is, -d<=k<=d and d+k even) +func checkDK(D, k int) { + if k >= -D && k <= D && (D+k)%2 == 0 { + return + } + panic(fmt.Sprintf("out of range, d=%d,k=%d", D, k)) +} + +func (t *label) set(D, k, x int) { + if debug { + checkDK(D, k) + } + for len(t.vec) <= D { + t.vec = append(t.vec, nil) + } + if t.vec[D] == nil { + t.vec[D] = make([]int, D+1) + } + t.vec[D][(D+k)/2] = x // known that D+k is even +} + +func (t *label) get(d, k int) int { + if debug { + checkDK(d, k) + } + return int(t.vec[d][(d+k)/2]) +} + +func newtriang(limit int) label { + if limit < 100 { + // Preallocate if limit is not large. + return label{vec: make([][]int, limit)} + } + return label{} +} diff --git a/hack/tools/vendor/golang.org/x/tools/internal/diff/lcs/old.go b/hack/tools/vendor/golang.org/x/tools/internal/diff/lcs/old.go new file mode 100644 index 000000000000..d6265c8c7f6c --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/internal/diff/lcs/old.go @@ -0,0 +1,475 @@ +// Copyright 2022 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 lcs + +// TODO(adonovan): remove unclear references to "old" in this package. + +import ( + "fmt" +) + +// A Diff is a replacement of a portion of A by a portion of B. +type Diff struct { + Start, End int // offsets of portion to delete in A + ReplStart, ReplEnd int // offset of replacement text in B +} + +// DiffBytes returns the differences between two byte sequences. +// It does not respect rune boundaries. +func DiffBytes(a, b []byte) []Diff { return diff(bytesSeqs{a, b}) } + +// DiffRunes returns the differences between two rune sequences. +func DiffRunes(a, b []rune) []Diff { return diff(runesSeqs{a, b}) } + +// DiffLines returns the differences between two string sequences. +func DiffLines(a, b []string) []Diff { return diff(linesSeqs{a, b}) } + +// A limit on how deeply the LCS algorithm should search. The value is just a guess. +var maxDiffs = 100 + +func diff(seqs sequences) []Diff { + diff, _ := compute(seqs, twosided, maxDiffs/2) + return diff +} + +// compute computes the list of differences between two sequences, +// along with the LCS. It is exercised directly by tests. +// The algorithm is one of {forward, backward, twosided}. +func compute(seqs sequences, algo func(*editGraph) lcs, limit int) ([]Diff, lcs) { + if limit <= 0 { + limit = 1 << 25 // effectively infinity + } + alen, blen := seqs.lengths() + g := &editGraph{ + seqs: seqs, + vf: newtriang(limit), + vb: newtriang(limit), + limit: limit, + ux: alen, + uy: blen, + delta: alen - blen, + } + lcs := algo(g) + diffs := lcs.toDiffs(alen, blen) + return diffs, lcs +} + +// editGraph carries the information for computing the lcs of two sequences. +type editGraph struct { + seqs sequences + vf, vb label // forward and backward labels + + limit int // maximal value of D + // the bounding rectangle of the current edit graph + lx, ly, ux, uy int + delta int // common subexpression: (ux-lx)-(uy-ly) +} + +// toDiffs converts an LCS to a list of edits. +func (lcs lcs) toDiffs(alen, blen int) []Diff { + var diffs []Diff + var pa, pb int // offsets in a, b + for _, l := range lcs { + if pa < l.X || pb < l.Y { + diffs = append(diffs, Diff{pa, l.X, pb, l.Y}) + } + pa = l.X + l.Len + pb = l.Y + l.Len + } + if pa < alen || pb < blen { + diffs = append(diffs, Diff{pa, alen, pb, blen}) + } + return diffs +} + +// --- FORWARD --- + +// fdone decides if the forward path has reached the upper right +// corner of the rectangle. If so, it also returns the computed lcs. +func (e *editGraph) fdone(D, k int) (bool, lcs) { + // x, y, k are relative to the rectangle + x := e.vf.get(D, k) + y := x - k + if x == e.ux && y == e.uy { + return true, e.forwardlcs(D, k) + } + return false, nil +} + +// run the forward algorithm, until success or up to the limit on D. +func forward(e *editGraph) lcs { + e.setForward(0, 0, e.lx) + if ok, ans := e.fdone(0, 0); ok { + return ans + } + // from D to D+1 + for D := range e.limit { + e.setForward(D+1, -(D + 1), e.getForward(D, -D)) + if ok, ans := e.fdone(D+1, -(D + 1)); ok { + return ans + } + e.setForward(D+1, D+1, e.getForward(D, D)+1) + if ok, ans := e.fdone(D+1, D+1); ok { + return ans + } + for k := -D + 1; k <= D-1; k += 2 { + // these are tricky and easy to get backwards + lookv := e.lookForward(k, e.getForward(D, k-1)+1) + lookh := e.lookForward(k, e.getForward(D, k+1)) + if lookv > lookh { + e.setForward(D+1, k, lookv) + } else { + e.setForward(D+1, k, lookh) + } + if ok, ans := e.fdone(D+1, k); ok { + return ans + } + } + } + // D is too large + // find the D path with maximal x+y inside the rectangle and + // use that to compute the found part of the lcs + kmax := -e.limit - 1 + diagmax := -1 + for k := -e.limit; k <= e.limit; k += 2 { + x := e.getForward(e.limit, k) + y := x - k + if x+y > diagmax && x <= e.ux && y <= e.uy { + diagmax, kmax = x+y, k + } + } + return e.forwardlcs(e.limit, kmax) +} + +// recover the lcs by backtracking from the farthest point reached +func (e *editGraph) forwardlcs(D, k int) lcs { + var ans lcs + for x := e.getForward(D, k); x != 0 || x-k != 0; { + if ok(D-1, k-1) && x-1 == e.getForward(D-1, k-1) { + // if (x-1,y) is labelled D-1, x--,D--,k--,continue + D, k, x = D-1, k-1, x-1 + continue + } else if ok(D-1, k+1) && x == e.getForward(D-1, k+1) { + // if (x,y-1) is labelled D-1, x, D--,k++, continue + D, k = D-1, k+1 + continue + } + // if (x-1,y-1)--(x,y) is a diagonal, prepend,x--,y--, continue + y := x - k + ans = ans.prepend(x+e.lx-1, y+e.ly-1) + x-- + } + return ans +} + +// start at (x,y), go up the diagonal as far as possible, +// and label the result with d +func (e *editGraph) lookForward(k, relx int) int { + rely := relx - k + x, y := relx+e.lx, rely+e.ly + if x < e.ux && y < e.uy { + x += e.seqs.commonPrefixLen(x, e.ux, y, e.uy) + } + return x +} + +func (e *editGraph) setForward(d, k, relx int) { + x := e.lookForward(k, relx) + e.vf.set(d, k, x-e.lx) +} + +func (e *editGraph) getForward(d, k int) int { + x := e.vf.get(d, k) + return x +} + +// --- BACKWARD --- + +// bdone decides if the backward path has reached the lower left corner +func (e *editGraph) bdone(D, k int) (bool, lcs) { + // x, y, k are relative to the rectangle + x := e.vb.get(D, k) + y := x - (k + e.delta) + if x == 0 && y == 0 { + return true, e.backwardlcs(D, k) + } + return false, nil +} + +// run the backward algorithm, until success or up to the limit on D. +// (used only by tests) +func backward(e *editGraph) lcs { + e.setBackward(0, 0, e.ux) + if ok, ans := e.bdone(0, 0); ok { + return ans + } + // from D to D+1 + for D := range e.limit { + e.setBackward(D+1, -(D + 1), e.getBackward(D, -D)-1) + if ok, ans := e.bdone(D+1, -(D + 1)); ok { + return ans + } + e.setBackward(D+1, D+1, e.getBackward(D, D)) + if ok, ans := e.bdone(D+1, D+1); ok { + return ans + } + for k := -D + 1; k <= D-1; k += 2 { + // these are tricky and easy to get wrong + lookv := e.lookBackward(k, e.getBackward(D, k-1)) + lookh := e.lookBackward(k, e.getBackward(D, k+1)-1) + if lookv < lookh { + e.setBackward(D+1, k, lookv) + } else { + e.setBackward(D+1, k, lookh) + } + if ok, ans := e.bdone(D+1, k); ok { + return ans + } + } + } + + // D is too large + // find the D path with minimal x+y inside the rectangle and + // use that to compute the part of the lcs found + kmax := -e.limit - 1 + diagmin := 1 << 25 + for k := -e.limit; k <= e.limit; k += 2 { + x := e.getBackward(e.limit, k) + y := x - (k + e.delta) + if x+y < diagmin && x >= 0 && y >= 0 { + diagmin, kmax = x+y, k + } + } + if kmax < -e.limit { + panic(fmt.Sprintf("no paths when limit=%d?", e.limit)) + } + return e.backwardlcs(e.limit, kmax) +} + +// recover the lcs by backtracking +func (e *editGraph) backwardlcs(D, k int) lcs { + var ans lcs + for x := e.getBackward(D, k); x != e.ux || x-(k+e.delta) != e.uy; { + if ok(D-1, k-1) && x == e.getBackward(D-1, k-1) { + // D--, k--, x unchanged + D, k = D-1, k-1 + continue + } else if ok(D-1, k+1) && x+1 == e.getBackward(D-1, k+1) { + // D--, k++, x++ + D, k, x = D-1, k+1, x+1 + continue + } + y := x - (k + e.delta) + ans = ans.append(x+e.lx, y+e.ly) + x++ + } + return ans +} + +// start at (x,y), go down the diagonal as far as possible, +func (e *editGraph) lookBackward(k, relx int) int { + rely := relx - (k + e.delta) // forward k = k + e.delta + x, y := relx+e.lx, rely+e.ly + if x > 0 && y > 0 { + x -= e.seqs.commonSuffixLen(0, x, 0, y) + } + return x +} + +// convert to rectangle, and label the result with d +func (e *editGraph) setBackward(d, k, relx int) { + x := e.lookBackward(k, relx) + e.vb.set(d, k, x-e.lx) +} + +func (e *editGraph) getBackward(d, k int) int { + x := e.vb.get(d, k) + return x +} + +// -- TWOSIDED --- + +func twosided(e *editGraph) lcs { + // The termination condition could be improved, as either the forward + // or backward pass could succeed before Myers' Lemma applies. + // Aside from questions of efficiency (is the extra testing cost-effective) + // this is more likely to matter when e.limit is reached. + e.setForward(0, 0, e.lx) + e.setBackward(0, 0, e.ux) + + // from D to D+1 + for D := range e.limit { + // just finished a backwards pass, so check + if got, ok := e.twoDone(D, D); ok { + return e.twolcs(D, D, got) + } + // do a forwards pass (D to D+1) + e.setForward(D+1, -(D + 1), e.getForward(D, -D)) + e.setForward(D+1, D+1, e.getForward(D, D)+1) + for k := -D + 1; k <= D-1; k += 2 { + // these are tricky and easy to get backwards + lookv := e.lookForward(k, e.getForward(D, k-1)+1) + lookh := e.lookForward(k, e.getForward(D, k+1)) + if lookv > lookh { + e.setForward(D+1, k, lookv) + } else { + e.setForward(D+1, k, lookh) + } + } + // just did a forward pass, so check + if got, ok := e.twoDone(D+1, D); ok { + return e.twolcs(D+1, D, got) + } + // do a backward pass, D to D+1 + e.setBackward(D+1, -(D + 1), e.getBackward(D, -D)-1) + e.setBackward(D+1, D+1, e.getBackward(D, D)) + for k := -D + 1; k <= D-1; k += 2 { + // these are tricky and easy to get wrong + lookv := e.lookBackward(k, e.getBackward(D, k-1)) + lookh := e.lookBackward(k, e.getBackward(D, k+1)-1) + if lookv < lookh { + e.setBackward(D+1, k, lookv) + } else { + e.setBackward(D+1, k, lookh) + } + } + } + + // D too large. combine a forward and backward partial lcs + // first, a forward one + kmax := -e.limit - 1 + diagmax := -1 + for k := -e.limit; k <= e.limit; k += 2 { + x := e.getForward(e.limit, k) + y := x - k + if x+y > diagmax && x <= e.ux && y <= e.uy { + diagmax, kmax = x+y, k + } + } + if kmax < -e.limit { + panic(fmt.Sprintf("no forward paths when limit=%d?", e.limit)) + } + lcs := e.forwardlcs(e.limit, kmax) + // now a backward one + // find the D path with minimal x+y inside the rectangle and + // use that to compute the lcs + diagmin := 1 << 25 // infinity + for k := -e.limit; k <= e.limit; k += 2 { + x := e.getBackward(e.limit, k) + y := x - (k + e.delta) + if x+y < diagmin && x >= 0 && y >= 0 { + diagmin, kmax = x+y, k + } + } + if kmax < -e.limit { + panic(fmt.Sprintf("no backward paths when limit=%d?", e.limit)) + } + lcs = append(lcs, e.backwardlcs(e.limit, kmax)...) + // These may overlap (e.forwardlcs and e.backwardlcs return sorted lcs) + ans := lcs.fix() + return ans +} + +// Does Myers' Lemma apply? +func (e *editGraph) twoDone(df, db int) (int, bool) { + if (df+db+e.delta)%2 != 0 { + return 0, false // diagonals cannot overlap + } + kmin := max(-df, -db+e.delta) + kmax := min(df, db+e.delta) + for k := kmin; k <= kmax; k += 2 { + x := e.vf.get(df, k) + u := e.vb.get(db, k-e.delta) + if u <= x { + // is it worth looking at all the other k? + for l := k; l <= kmax; l += 2 { + x := e.vf.get(df, l) + y := x - l + u := e.vb.get(db, l-e.delta) + v := u - l + if x == u || u == 0 || v == 0 || y == e.uy || x == e.ux { + return l, true + } + } + return k, true + } + } + return 0, false +} + +func (e *editGraph) twolcs(df, db, kf int) lcs { + // db==df || db+1==df + x := e.vf.get(df, kf) + y := x - kf + kb := kf - e.delta + u := e.vb.get(db, kb) + v := u - kf + + // Myers proved there is a df-path from (0,0) to (u,v) + // and a db-path from (x,y) to (N,M). + // In the first case the overall path is the forward path + // to (u,v) followed by the backward path to (N,M). + // In the second case the path is the backward path to (x,y) + // followed by the forward path to (x,y) from (0,0). + + // Look for some special cases to avoid computing either of these paths. + if x == u { + // "babaab" "cccaba" + // already patched together + lcs := e.forwardlcs(df, kf) + lcs = append(lcs, e.backwardlcs(db, kb)...) + return lcs.sort() + } + + // is (u-1,v) or (u,v-1) labelled df-1? + // if so, that forward df-1-path plus a horizontal or vertical edge + // is the df-path to (u,v), then plus the db-path to (N,M) + if u > 0 && ok(df-1, u-1-v) && e.vf.get(df-1, u-1-v) == u-1 { + // "aabbab" "cbcabc" + lcs := e.forwardlcs(df-1, u-1-v) + lcs = append(lcs, e.backwardlcs(db, kb)...) + return lcs.sort() + } + if v > 0 && ok(df-1, (u-(v-1))) && e.vf.get(df-1, u-(v-1)) == u { + // "abaabb" "bcacab" + lcs := e.forwardlcs(df-1, u-(v-1)) + lcs = append(lcs, e.backwardlcs(db, kb)...) + return lcs.sort() + } + + // The path can't possibly contribute to the lcs because it + // is all horizontal or vertical edges + if u == 0 || v == 0 || x == e.ux || y == e.uy { + // "abaabb" "abaaaa" + if u == 0 || v == 0 { + return e.backwardlcs(db, kb) + } + return e.forwardlcs(df, kf) + } + + // is (x+1,y) or (x,y+1) labelled db-1? + if x+1 <= e.ux && ok(db-1, x+1-y-e.delta) && e.vb.get(db-1, x+1-y-e.delta) == x+1 { + // "bababb" "baaabb" + lcs := e.backwardlcs(db-1, kb+1) + lcs = append(lcs, e.forwardlcs(df, kf)...) + return lcs.sort() + } + if y+1 <= e.uy && ok(db-1, x-(y+1)-e.delta) && e.vb.get(db-1, x-(y+1)-e.delta) == x { + // "abbbaa" "cabacc" + lcs := e.backwardlcs(db-1, kb-1) + lcs = append(lcs, e.forwardlcs(df, kf)...) + return lcs.sort() + } + + // need to compute another path + // "aabbaa" "aacaba" + lcs := e.backwardlcs(db, kb) + oldx, oldy := e.ux, e.uy + e.ux = u + e.uy = v + lcs = append(lcs, forward(e)...) + e.ux, e.uy = oldx, oldy + return lcs.sort() +} diff --git a/hack/tools/vendor/golang.org/x/tools/internal/diff/lcs/sequence.go b/hack/tools/vendor/golang.org/x/tools/internal/diff/lcs/sequence.go new file mode 100644 index 000000000000..429e8c6192dc --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/internal/diff/lcs/sequence.go @@ -0,0 +1,70 @@ +// Copyright 2022 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 lcs + +// This file defines the abstract sequence over which the LCS algorithm operates. + +// sequences abstracts a pair of sequences, A and B. +type sequences interface { + lengths() (int, int) // len(A), len(B) + commonPrefixLen(ai, aj, bi, bj int) int // len(commonPrefix(A[ai:aj], B[bi:bj])) + commonSuffixLen(ai, aj, bi, bj int) int // len(commonSuffix(A[ai:aj], B[bi:bj])) +} + +// The explicit capacity in s[i:j:j] leads to more efficient code. + +type bytesSeqs struct{ a, b []byte } + +func (s bytesSeqs) lengths() (int, int) { return len(s.a), len(s.b) } +func (s bytesSeqs) commonPrefixLen(ai, aj, bi, bj int) int { + return commonPrefixLen(s.a[ai:aj:aj], s.b[bi:bj:bj]) +} +func (s bytesSeqs) commonSuffixLen(ai, aj, bi, bj int) int { + return commonSuffixLen(s.a[ai:aj:aj], s.b[bi:bj:bj]) +} + +type runesSeqs struct{ a, b []rune } + +func (s runesSeqs) lengths() (int, int) { return len(s.a), len(s.b) } +func (s runesSeqs) commonPrefixLen(ai, aj, bi, bj int) int { + return commonPrefixLen(s.a[ai:aj:aj], s.b[bi:bj:bj]) +} +func (s runesSeqs) commonSuffixLen(ai, aj, bi, bj int) int { + return commonSuffixLen(s.a[ai:aj:aj], s.b[bi:bj:bj]) +} + +type linesSeqs struct{ a, b []string } + +func (s linesSeqs) lengths() (int, int) { return len(s.a), len(s.b) } +func (s linesSeqs) commonPrefixLen(ai, aj, bi, bj int) int { + return commonPrefixLen(s.a[ai:aj], s.b[bi:bj]) +} +func (s linesSeqs) commonSuffixLen(ai, aj, bi, bj int) int { + return commonSuffixLen(s.a[ai:aj], s.b[bi:bj]) +} + +// TODO(adonovan): optimize these functions using ideas from: +// - https://go.dev/cl/408116 common.go +// - https://go.dev/cl/421435 xor_generic.go + +// commonPrefixLen returns the length of the common prefix of a[ai:aj] and b[bi:bj]. +func commonPrefixLen[T comparable](a, b []T) int { + n := min(len(a), len(b)) + i := 0 + for i < n && a[i] == b[i] { + i++ + } + return i +} + +// commonSuffixLen returns the length of the common suffix of a[ai:aj] and b[bi:bj]. +func commonSuffixLen[T comparable](a, b []T) int { + n := min(len(a), len(b)) + i := 0 + for i < n && a[len(a)-1-i] == b[len(b)-1-i] { + i++ + } + return i +} diff --git a/hack/tools/vendor/golang.org/x/tools/internal/diff/merge.go b/hack/tools/vendor/golang.org/x/tools/internal/diff/merge.go new file mode 100644 index 000000000000..eeae98adf763 --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/internal/diff/merge.go @@ -0,0 +1,81 @@ +// Copyright 2025 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 diff + +import ( + "slices" +) + +// Merge merges two valid, ordered lists of edits. +// It returns zero if there was a conflict. +// +// If corresponding edits in x and y are identical, +// they are coalesced in the result. +// +// If x and y both provide different insertions at the same point, +// the insertions from x will be first in the result. +// +// TODO(adonovan): this algorithm could be improved, for example by +// working harder to coalesce non-identical edits that share a common +// deletion or common prefix of insertion (see the tests). +// Survey the academic literature for insights. +func Merge(x, y []Edit) ([]Edit, bool) { + // Make a defensive (premature) copy of the arrays. + x = slices.Clone(x) + y = slices.Clone(y) + + var merged []Edit + add := func(edit Edit) { + merged = append(merged, edit) + } + var xi, yi int + for xi < len(x) && yi < len(y) { + px := &x[xi] + py := &y[yi] + + if *px == *py { + // x and y are identical: coalesce. + add(*px) + xi++ + yi++ + + } else if px.End <= py.Start { + // x is entirely before y, + // or an insertion at start of y. + add(*px) + xi++ + + } else if py.End <= px.Start { + // y is entirely before x, + // or an insertion at start of x. + add(*py) + yi++ + + } else if px.Start < py.Start { + // x is partly before y: + // split it into a deletion and an edit. + add(Edit{px.Start, py.Start, ""}) + px.Start = py.Start + + } else if py.Start < px.Start { + // y is partly before x: + // split it into a deletion and an edit. + add(Edit{py.Start, px.Start, ""}) + py.Start = px.Start + + } else { + // x and y are unequal non-insertions + // at the same point: conflict. + return nil, false + } + } + for ; xi < len(x); xi++ { + add(x[xi]) + } + for ; yi < len(y); yi++ { + add(y[yi]) + } + return merged, true +} diff --git a/hack/tools/vendor/golang.org/x/tools/internal/diff/ndiff.go b/hack/tools/vendor/golang.org/x/tools/internal/diff/ndiff.go new file mode 100644 index 000000000000..448c8ce65efd --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/internal/diff/ndiff.go @@ -0,0 +1,118 @@ +// Copyright 2022 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 diff + +import ( + "bytes" + "strings" + "unicode/utf8" + + "golang.org/x/tools/internal/diff/lcs" +) + +// Lines computes differences between two strings. All edits are at line boundaries. +func Lines(before, after string) []Edit { + beforeLines, bOffsets := splitLines(before) + afterLines, _ := splitLines(after) + diffs := lcs.DiffLines(beforeLines, afterLines) + + // Convert from LCS diffs to Edits + res := make([]Edit, len(diffs)) + for i, d := range diffs { + res[i] = Edit{ + Start: bOffsets[d.Start], + End: bOffsets[d.End], + New: strings.Join(afterLines[d.ReplStart:d.ReplEnd], ""), + } + } + return res +} + +// Strings computes the differences between two strings. +// The resulting edits respect rune boundaries. +func Strings(before, after string) []Edit { + if before == after { + return nil // common case + } + + if isASCII(before) && isASCII(after) { + // TODO(adonovan): opt: specialize diffASCII for strings. + return diffASCII([]byte(before), []byte(after)) + } + return diffRunes([]rune(before), []rune(after)) +} + +// Bytes computes the differences between two byte slices. +// The resulting edits respect rune boundaries. +func Bytes(before, after []byte) []Edit { + if bytes.Equal(before, after) { + return nil // common case + } + + if isASCII(before) && isASCII(after) { + return diffASCII(before, after) + } + return diffRunes(runes(before), runes(after)) +} + +func diffASCII(before, after []byte) []Edit { + diffs := lcs.DiffBytes(before, after) + + // Convert from LCS diffs. + res := make([]Edit, len(diffs)) + for i, d := range diffs { + res[i] = Edit{d.Start, d.End, string(after[d.ReplStart:d.ReplEnd])} + } + return res +} + +func diffRunes(before, after []rune) []Edit { + diffs := lcs.DiffRunes(before, after) + + // The diffs returned by the lcs package use indexes + // into whatever slice was passed in. + // Convert rune offsets to byte offsets. + res := make([]Edit, len(diffs)) + lastEnd := 0 + utf8Len := 0 + for i, d := range diffs { + utf8Len += runesLen(before[lastEnd:d.Start]) // text between edits + start := utf8Len + utf8Len += runesLen(before[d.Start:d.End]) // text deleted by this edit + res[i] = Edit{start, utf8Len, string(after[d.ReplStart:d.ReplEnd])} + lastEnd = d.End + } + return res +} + +// runes is like []rune(string(bytes)) without the duplicate allocation. +func runes(bytes []byte) []rune { + n := utf8.RuneCount(bytes) + runes := make([]rune, n) + for i := range n { + r, sz := utf8.DecodeRune(bytes) + bytes = bytes[sz:] + runes[i] = r + } + return runes +} + +// runesLen returns the length in bytes of the UTF-8 encoding of runes. +func runesLen(runes []rune) (len int) { + for _, r := range runes { + len += utf8.RuneLen(r) + } + return len +} + +// isASCII reports whether s contains only ASCII. +func isASCII[S string | []byte](s S) bool { + for i := 0; i < len(s); i++ { + if s[i] >= utf8.RuneSelf { + return false + } + } + return true +} diff --git a/hack/tools/vendor/golang.org/x/tools/internal/diff/unified.go b/hack/tools/vendor/golang.org/x/tools/internal/diff/unified.go new file mode 100644 index 000000000000..df8f2fcc1211 --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/internal/diff/unified.go @@ -0,0 +1,314 @@ +// 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 diff + +import ( + "fmt" + "log" + "regexp" + "strconv" + "strings" +) + +// DefaultContextLines is the number of unchanged lines of surrounding +// context displayed by Unified. Use ToUnified to specify a different value. +const DefaultContextLines = 3 + +// Unified returns a unified diff of the old and new strings. +// The old and new labels are the names of the old and new files. +// If the strings are equal, it returns the empty string. +func Unified(oldLabel, newLabel, old, new string) string { + edits := Lines(old, new) + unified, err := ToUnified(oldLabel, newLabel, old, edits, DefaultContextLines) + if err != nil { + // Can't happen: edits are consistent. + log.Fatalf("internal error in diff.Unified: %v", err) + } + return unified +} + +// ToUnified applies the edits to content and returns a unified diff, +// with contextLines lines of (unchanged) context around each diff hunk. +// The old and new labels are the names of the content and result files. +// It returns an error if the edits are inconsistent; see ApplyEdits. +func ToUnified(oldLabel, newLabel, content string, edits []Edit, contextLines int) (string, error) { + u, err := toUnified(oldLabel, newLabel, content, edits, contextLines) + if err != nil { + return "", err + } + return u.String(), nil +} + +// unified represents a set of edits as a unified diff. +type unified struct { + // from is the name of the original file. + from string + // to is the name of the modified file. + to string + // hunks is the set of edit hunks needed to transform the file content. + hunks []*hunk +} + +// Hunk represents a contiguous set of line edits to apply. +type hunk struct { + // The line in the original source where the hunk starts. + fromLine int + // The line in the original source where the hunk finishes. + toLine int + // The set of line based edits to apply. + lines []line +} + +// Line represents a single line operation to apply as part of a Hunk. +type line struct { + // kind is the type of line this represents, deletion, insertion or copy. + kind opKind + // content is the content of this line. + // For deletion it is the line being removed, for all others it is the line + // to put in the output. + content string +} + +// opKind is used to denote the type of operation a line represents. +type opKind int + +const ( + // opDelete is the operation kind for a line that is present in the input + // but not in the output. + opDelete opKind = iota + // opInsert is the operation kind for a line that is new in the output. + opInsert + // opEqual is the operation kind for a line that is the same in the input and + // output, often used to provide context around edited lines. + opEqual +) + +// String returns a human readable representation of an OpKind. It is not +// intended for machine processing. +func (k opKind) String() string { + switch k { + case opDelete: + return "delete" + case opInsert: + return "insert" + case opEqual: + return "equal" + default: + panic("unknown operation kind") + } +} + +// toUnified takes a file contents and a sequence of edits, and calculates +// a unified diff that represents those edits. +func toUnified(fromName, toName string, content string, edits []Edit, contextLines int) (unified, error) { + gap := contextLines * 2 + u := unified{ + from: fromName, + to: toName, + } + if len(edits) == 0 { + return u, nil + } + var err error + edits, err = lineEdits(content, edits) // expand to whole lines + if err != nil { + return u, err + } + lines, _ := splitLines(content) + var h *hunk + last := 0 + toLine := 0 + for _, edit := range edits { + // Compute the zero-based line numbers of the edit start and end. + // TODO(adonovan): opt: compute incrementally, avoid O(n^2). + start := strings.Count(content[:edit.Start], "\n") + end := strings.Count(content[:edit.End], "\n") + if edit.End == len(content) && len(content) > 0 && content[len(content)-1] != '\n' { + end++ // EOF counts as an implicit newline + } + + switch { + case h != nil && start == last: + //direct extension + case h != nil && start <= last+gap: + //within range of previous lines, add the joiners + addEqualLines(h, lines, last, start) + default: + //need to start a new hunk + if h != nil { + // add the edge to the previous hunk + addEqualLines(h, lines, last, last+contextLines) + u.hunks = append(u.hunks, h) + } + toLine += start - last + h = &hunk{ + fromLine: start + 1, + toLine: toLine + 1, + } + // add the edge to the new hunk + delta := addEqualLines(h, lines, start-contextLines, start) + h.fromLine -= delta + h.toLine -= delta + } + last = start + for i := start; i < end; i++ { + h.lines = append(h.lines, line{kind: opDelete, content: lines[i]}) + last++ + } + if edit.New != "" { + v, _ := splitLines(edit.New) + for _, content := range v { + h.lines = append(h.lines, line{kind: opInsert, content: content}) + toLine++ + } + } + } + if h != nil { + // add the edge to the final hunk + addEqualLines(h, lines, last, last+contextLines) + u.hunks = append(u.hunks, h) + } + return u, nil +} + +// split into lines removing a final empty line, +// and also return the offsets of the line beginnings. +func splitLines(text string) ([]string, []int) { + var lines []string + offsets := []int{0} + start := 0 + for i, r := range text { + if r == '\n' { + lines = append(lines, text[start:i+1]) + start = i + 1 + offsets = append(offsets, start) + } + } + if start < len(text) { + lines = append(lines, text[start:]) + offsets = append(offsets, len(text)) + } + return lines, offsets +} + +func addEqualLines(h *hunk, lines []string, start, end int) int { + delta := 0 + for i := start; i < end; i++ { + if i < 0 { + continue + } + if i >= len(lines) { + return delta + } + h.lines = append(h.lines, line{kind: opEqual, content: lines[i]}) + delta++ + } + return delta +} + +// String converts a unified diff to the standard textual form for that diff. +// The output of this function can be passed to tools like patch. +func (u unified) String() string { + if len(u.hunks) == 0 { + return "" + } + b := new(strings.Builder) + fmt.Fprintf(b, "--- %s\n", u.from) + fmt.Fprintf(b, "+++ %s\n", u.to) + for _, hunk := range u.hunks { + fromCount, toCount := 0, 0 + for _, l := range hunk.lines { + switch l.kind { + case opDelete: + fromCount++ + case opInsert: + toCount++ + default: + fromCount++ + toCount++ + } + } + fmt.Fprint(b, "@@") + if fromCount > 1 { + fmt.Fprintf(b, " -%d,%d", hunk.fromLine, fromCount) + } else if hunk.fromLine == 1 && fromCount == 0 { + // Match odd GNU diff -u behavior adding to empty file. + fmt.Fprintf(b, " -0,0") + } else { + fmt.Fprintf(b, " -%d", hunk.fromLine) + } + if toCount > 1 { + fmt.Fprintf(b, " +%d,%d", hunk.toLine, toCount) + } else if hunk.toLine == 1 && toCount == 0 { + // Match odd GNU diff -u behavior adding to empty file. + fmt.Fprintf(b, " +0,0") + } else { + fmt.Fprintf(b, " +%d", hunk.toLine) + } + fmt.Fprint(b, " @@\n") + for _, l := range hunk.lines { + switch l.kind { + case opDelete: + fmt.Fprintf(b, "-%s", l.content) + case opInsert: + fmt.Fprintf(b, "+%s", l.content) + default: + fmt.Fprintf(b, " %s", l.content) + } + if !strings.HasSuffix(l.content, "\n") { + fmt.Fprintf(b, "\n\\ No newline at end of file\n") + } + } + } + return b.String() +} + +// ApplyUnified applies the unified diffs. +func ApplyUnified(udiffs, bef string) (string, error) { + before := strings.Split(bef, "\n") + unif := strings.Split(udiffs, "\n") + var got []string + left := 0 + // parse and apply the unified diffs + for _, l := range unif { + if len(l) == 0 { + continue // probably the last line (from Split) + } + switch l[0] { + case '@': // The @@ line + m := atregexp.FindStringSubmatch(l) + fromLine, err := strconv.Atoi(m[1]) + if err != nil { + return "", fmt.Errorf("missing line number in %q", l) + } + // before is a slice, so0-based; fromLine is 1-based + for ; left < fromLine-1; left++ { + got = append(got, before[left]) + } + case '+': // add this line + if strings.HasPrefix(l, "+++ ") { + continue + } + got = append(got, l[1:]) + case '-': // delete this line + if strings.HasPrefix(l, "--- ") { + continue + } + left++ + case ' ': + return "", fmt.Errorf("unexpected line %q", l) + default: + return "", fmt.Errorf("impossible unified %q", udiffs) + } + } + // copy any remaining lines + for ; left < len(before); left++ { + got = append(got, before[left]) + } + return strings.Join(got, "\n"), nil +} + +// The first number in the @@ lines is the line number in the 'before' data +var atregexp = regexp.MustCompile(`@@ -(\d+).* @@`) diff --git a/hack/tools/vendor/golang.org/x/tools/internal/testenv/exec.go b/hack/tools/vendor/golang.org/x/tools/internal/testenv/exec.go new file mode 100644 index 000000000000..f2ab5f5eb8d3 --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/internal/testenv/exec.go @@ -0,0 +1,192 @@ +// Copyright 2015 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 testenv + +import ( + "context" + "flag" + "os" + "os/exec" + "reflect" + "runtime" + "strconv" + "sync" + "testing" + "time" +) + +// HasExec reports whether the current system can start new processes +// using os.StartProcess or (more commonly) exec.Command. +func HasExec() bool { + switch runtime.GOOS { + case "aix", + "android", + "darwin", + "dragonfly", + "freebsd", + "illumos", + "linux", + "netbsd", + "openbsd", + "plan9", + "solaris", + "windows": + // Known OS that isn't ios or wasm; assume that exec works. + return true + + case "ios", "js", "wasip1": + // ios has an exec syscall but on real iOS devices it might return a + // permission error. In an emulated environment (such as a Corellium host) + // it might succeed, so try it and find out. + // + // As of 2023-04-19 wasip1 and js don't have exec syscalls at all, but we + // may as well use the same path so that this branch can be tested without + // an ios environment. + fallthrough + + default: + tryExecOnce.Do(func() { + exe, err := os.Executable() + if err != nil { + return + } + if flag.Lookup("test.list") == nil { + // We found the executable, but we don't know how to run it in a way + // that should succeed without side-effects. Just forget it. + return + } + // We know that a test executable exists and can run, because we're + // running it now. Use it to check for overall exec support, but be sure + // to remove any environment variables that might trigger non-default + // behavior in a custom TestMain. + cmd := exec.Command(exe, "-test.list=^$") + cmd.Env = []string{} + if err := cmd.Run(); err == nil { + tryExecOk = true + } + }) + return tryExecOk + } +} + +var ( + tryExecOnce sync.Once + tryExecOk bool +) + +// NeedsExec checks that the current system can start new processes +// using os.StartProcess or (more commonly) exec.Command. +// If not, NeedsExec calls t.Skip with an explanation. +func NeedsExec(t testing.TB) { + if !HasExec() { + t.Skipf("skipping test: cannot exec subprocess on %s/%s", runtime.GOOS, runtime.GOARCH) + } +} + +// CommandContext is like exec.CommandContext, but: +// - skips t if the platform does not support os/exec, +// - if supported, sends SIGQUIT instead of SIGKILL in its Cancel function +// - if the test has a deadline, adds a Context timeout and (if supported) WaitDelay +// for an arbitrary grace period before the test's deadline expires, +// - if Cmd has the Cancel field, fails the test if the command is canceled +// due to the test's deadline, and +// - sets a Cleanup function that verifies that the test did not leak a subprocess. +func CommandContext(t testing.TB, ctx context.Context, name string, args ...string) *exec.Cmd { + t.Helper() + NeedsExec(t) + + var ( + cancelCtx context.CancelFunc + gracePeriod time.Duration // unlimited unless the test has a deadline (to allow for interactive debugging) + ) + + if td, ok := Deadline(t); ok { + // Start with a minimum grace period, just long enough to consume the + // output of a reasonable program after it terminates. + gracePeriod = 100 * time.Millisecond + if s := os.Getenv("GO_TEST_TIMEOUT_SCALE"); s != "" { + scale, err := strconv.Atoi(s) + if err != nil { + t.Fatalf("invalid GO_TEST_TIMEOUT_SCALE: %v", err) + } + gracePeriod *= time.Duration(scale) + } + + // If time allows, increase the termination grace period to 5% of the + // test's remaining time. + testTimeout := time.Until(td) + if gp := testTimeout / 20; gp > gracePeriod { + gracePeriod = gp + } + + // When we run commands that execute subprocesses, we want to reserve two + // grace periods to clean up: one for the delay between the first + // termination signal being sent (via the Cancel callback when the Context + // expires) and the process being forcibly terminated (via the WaitDelay + // field), and a second one for the delay between the process being + // terminated and the test logging its output for debugging. + // + // (We want to ensure that the test process itself has enough time to + // log the output before it is also terminated.) + cmdTimeout := testTimeout - 2*gracePeriod + + if cd, ok := ctx.Deadline(); !ok || time.Until(cd) > cmdTimeout { + // Either ctx doesn't have a deadline, or its deadline would expire + // after (or too close before) the test has already timed out. + // Add a shorter timeout so that the test will produce useful output. + ctx, cancelCtx = context.WithTimeout(ctx, cmdTimeout) + } + } + + cmd := exec.CommandContext(ctx, name, args...) + + // Use reflection to set the Cancel and WaitDelay fields, if present. + // TODO(bcmills): When we no longer support Go versions below 1.20, + // remove the use of reflect and assume that the fields are always present. + rc := reflect.ValueOf(cmd).Elem() + + if rCancel := rc.FieldByName("Cancel"); rCancel.IsValid() { + rCancel.Set(reflect.ValueOf(func() error { + if cancelCtx != nil && ctx.Err() == context.DeadlineExceeded { + // The command timed out due to running too close to the test's deadline + // (because we specifically set a shorter Context deadline for that + // above). There is no way the test did that intentionally — it's too + // close to the wire! — so mark it as a test failure. That way, if the + // test expects the command to fail for some other reason, it doesn't + // have to distinguish between that reason and a timeout. + t.Errorf("test timed out while running command: %v", cmd) + } else { + // The command is being terminated due to ctx being canceled, but + // apparently not due to an explicit test deadline that we added. + // Log that information in case it is useful for diagnosing a failure, + // but don't actually fail the test because of it. + t.Logf("%v: terminating command: %v", ctx.Err(), cmd) + } + return cmd.Process.Signal(Sigquit) + })) + } + + if rWaitDelay := rc.FieldByName("WaitDelay"); rWaitDelay.IsValid() { + rWaitDelay.Set(reflect.ValueOf(gracePeriod)) + } + + t.Cleanup(func() { + if cancelCtx != nil { + cancelCtx() + } + if cmd.Process != nil && cmd.ProcessState == nil { + t.Errorf("command was started, but test did not wait for it to complete: %v", cmd) + } + }) + + return cmd +} + +// Command is like exec.Command, but applies the same changes as +// testenv.CommandContext (with a default Context). +func Command(t testing.TB, name string, args ...string) *exec.Cmd { + t.Helper() + return CommandContext(t, context.Background(), name, args...) +} diff --git a/hack/tools/vendor/golang.org/x/tools/internal/testenv/testenv.go b/hack/tools/vendor/golang.org/x/tools/internal/testenv/testenv.go new file mode 100644 index 000000000000..2bea513c7542 --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/internal/testenv/testenv.go @@ -0,0 +1,595 @@ +// 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 testenv contains helper functions for skipping tests +// based on which tools are present in the environment. +package testenv + +import ( + "bufio" + "bytes" + "context" + "fmt" + "go/build" + "log" + "os" + "os/exec" + "path/filepath" + "runtime" + "runtime/debug" + "strings" + "sync" + "testing" + "time" + + "golang.org/x/mod/modfile" + "golang.org/x/tools/internal/gocommand" +) + +// packageMainIsDevel reports whether the module containing package main +// is a development version (if module information is available). +func packageMainIsDevel() bool { + info, ok := debug.ReadBuildInfo() + if !ok { + // Most test binaries currently lack build info, but this should become more + // permissive once https://golang.org/issue/33976 is fixed. + return true + } + + // Note: info.Main.Version describes the version of the module containing + // package main, not the version of “the main module”. + // See https://golang.org/issue/33975. + return info.Main.Version == "(devel)" +} + +var checkGoBuild struct { + once sync.Once + err error +} + +// HasTool reports an error if the required tool is not available in PATH. +// +// For certain tools, it checks that the tool executable is correct. +func HasTool(tool string) error { + if tool == "cgo" { + enabled, err := cgoEnabled(false) + if err != nil { + return fmt.Errorf("checking cgo: %v", err) + } + if !enabled { + return fmt.Errorf("cgo not enabled") + } + return nil + } + + _, err := exec.LookPath(tool) + if err != nil { + return err + } + + switch tool { + case "patch": + // check that the patch tools supports the -o argument + temp, err := os.CreateTemp("", "patch-test") + if err != nil { + return err + } + temp.Close() + defer os.Remove(temp.Name()) + cmd := exec.Command(tool, "-o", temp.Name()) + if err := cmd.Run(); err != nil { + return err + } + + case "go": + checkGoBuild.once.Do(func() { + if runtime.GOROOT() != "" { + // Ensure that the 'go' command found by exec.LookPath is from the correct + // GOROOT. Otherwise, 'some/path/go test ./...' will test against some + // version of the 'go' binary other than 'some/path/go', which is almost + // certainly not what the user intended. + out, err := exec.Command(tool, "env", "GOROOT").Output() + if err != nil { + if exit, ok := err.(*exec.ExitError); ok && len(exit.Stderr) > 0 { + err = fmt.Errorf("%w\nstderr:\n%s)", err, exit.Stderr) + } + checkGoBuild.err = err + return + } + GOROOT := strings.TrimSpace(string(out)) + if GOROOT != runtime.GOROOT() { + checkGoBuild.err = fmt.Errorf("'go env GOROOT' does not match runtime.GOROOT:\n\tgo env: %s\n\tGOROOT: %s", GOROOT, runtime.GOROOT()) + return + } + } + + dir, err := os.MkdirTemp("", "testenv-*") + if err != nil { + checkGoBuild.err = err + return + } + defer os.RemoveAll(dir) + + mainGo := filepath.Join(dir, "main.go") + if err := os.WriteFile(mainGo, []byte("package main\nfunc main() {}\n"), 0644); err != nil { + checkGoBuild.err = err + return + } + cmd := exec.Command("go", "build", "-o", os.DevNull, mainGo) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + if len(out) > 0 { + checkGoBuild.err = fmt.Errorf("%v: %v\n%s", cmd, err, out) + } else { + checkGoBuild.err = fmt.Errorf("%v: %v", cmd, err) + } + } + }) + if checkGoBuild.err != nil { + return checkGoBuild.err + } + + case "diff": + // Check that diff is the GNU or Apple version, needed for the -u argument and + // to report missing newlines at the end of files. + out, err := exec.Command(tool, "-version").Output() + if err != nil { + out, _ = exec.Command(tool, "--version").Output() + if bytes.Contains(out, []byte("Apple diff")) { + return nil + } + return err + } + if !bytes.Contains(out, []byte("GNU diffutils")) { + return fmt.Errorf("diff is not the GNU version") + } + } + + return nil +} + +func cgoEnabled(bypassEnvironment bool) (bool, error) { + cmd := exec.Command("go", "env", "CGO_ENABLED") + if bypassEnvironment { + cmd.Env = append(os.Environ(), "CGO_ENABLED=") + } + out, err := cmd.Output() + if err != nil { + if exit, ok := err.(*exec.ExitError); ok && len(exit.Stderr) > 0 { + err = fmt.Errorf("%w\nstderr:\n%s", err, exit.Stderr) + } + return false, err + } + enabled := strings.TrimSpace(string(out)) + return enabled == "1", nil +} + +func allowMissingTool(tool string) bool { + switch runtime.GOOS { + case "aix", "darwin", "dragonfly", "freebsd", "illumos", "linux", "netbsd", "openbsd", "plan9", "solaris", "windows": + // Known non-mobile OS. Expect a reasonably complete environment. + default: + return true + } + + switch tool { + case "cgo": + if strings.HasSuffix(os.Getenv("GO_BUILDER_NAME"), "-nocgo") { + // Explicitly disabled on -nocgo builders. + return true + } + if enabled, err := cgoEnabled(true); err == nil && !enabled { + // No platform support. + return true + } + case "go": + if os.Getenv("GO_BUILDER_NAME") == "illumos-amd64-joyent" { + // Work around a misconfigured builder (see https://golang.org/issue/33950). + return true + } + case "diff": + if os.Getenv("GO_BUILDER_NAME") != "" { + return true + } + case "patch": + if os.Getenv("GO_BUILDER_NAME") != "" { + return true + } + } + + // If a developer is actively working on this test, we expect them to have all + // of its dependencies installed. However, if it's just a dependency of some + // other module (for example, being run via 'go test all'), we should be more + // tolerant of unusual environments. + return !packageMainIsDevel() +} + +// NeedsTool skips t if the named tool is not present in the path. +// As a special case, "cgo" means "go" is present and can compile cgo programs. +func NeedsTool(t testing.TB, tool string) { + err := HasTool(tool) + if err == nil { + return + } + + t.Helper() + if allowMissingTool(tool) { + // TODO(adonovan): if we skip because of (e.g.) + // mismatched go env GOROOT and runtime.GOROOT, don't + // we risk some users not getting the coverage they expect? + // bcmills notes: this shouldn't be a concern as of CL 404134 (Go 1.19). + // We could probably safely get rid of that GOPATH consistency + // check entirely at this point. + t.Skipf("skipping because %s tool not available: %v", tool, err) + } else { + t.Fatalf("%s tool not available: %v", tool, err) + } +} + +// NeedsGoPackages skips t if the go/packages driver (or 'go' tool) implied by +// the current process environment is not present in the path. +func NeedsGoPackages(t testing.TB) { + t.Helper() + + tool := os.Getenv("GOPACKAGESDRIVER") + switch tool { + case "off": + // "off" forces go/packages to use the go command. + tool = "go" + case "": + if _, err := exec.LookPath("gopackagesdriver"); err == nil { + tool = "gopackagesdriver" + } else { + tool = "go" + } + } + + NeedsTool(t, tool) +} + +// NeedsGoPackagesEnv skips t if the go/packages driver (or 'go' tool) implied +// by env is not present in the path. +func NeedsGoPackagesEnv(t testing.TB, env []string) { + t.Helper() + + for _, v := range env { + if after, ok := strings.CutPrefix(v, "GOPACKAGESDRIVER="); ok { + tool := after + if tool == "off" { + NeedsTool(t, "go") + } else { + NeedsTool(t, tool) + } + return + } + } + + NeedsGoPackages(t) +} + +// NeedsGoBuild skips t if the current system can't build programs with “go build” +// and then run them with os.StartProcess or exec.Command. +// Android doesn't have the userspace go build needs to run, +// and js/wasm doesn't support running subprocesses. +func NeedsGoBuild(t testing.TB) { + t.Helper() + + // This logic was derived from internal/testing.HasGoBuild and + // may need to be updated as that function evolves. + + NeedsTool(t, "go") +} + +// NeedsDefaultImporter skips t if the test uses the default importer, +// returned by [go/importer.Default]. +func NeedsDefaultImporter(t testing.TB) { + t.Helper() + // The default importer may call `go list` + // (in src/internal/exportdata/exportdata.go:lookupGorootExport), + // so check for the go tool. + NeedsTool(t, "go") +} + +// ExitIfSmallMachine emits a helpful diagnostic and calls os.Exit(0) if the +// current machine is a builder known to have scarce resources. +// +// It should be called from within a TestMain function. +func ExitIfSmallMachine() { + switch b := os.Getenv("GO_BUILDER_NAME"); b { + case "linux-arm-scaleway": + // "linux-arm" was renamed to "linux-arm-scaleway" in CL 303230. + fmt.Fprintln(os.Stderr, "skipping test: linux-arm-scaleway builder lacks sufficient memory (https://golang.org/issue/32834)") + case "plan9-arm": + fmt.Fprintln(os.Stderr, "skipping test: plan9-arm builder lacks sufficient memory (https://golang.org/issue/38772)") + case "netbsd-arm-bsiegert", "netbsd-arm64-bsiegert": + // As of 2021-06-02, these builders are running with GO_TEST_TIMEOUT_SCALE=10, + // and there is only one of each. We shouldn't waste those scarce resources + // running very slow tests. + fmt.Fprintf(os.Stderr, "skipping test: %s builder is very slow\n", b) + case "dragonfly-amd64": + // As of 2021-11-02, this builder is running with GO_TEST_TIMEOUT_SCALE=2, + // and seems to have unusually slow disk performance. + fmt.Fprintln(os.Stderr, "skipping test: dragonfly-amd64 has slow disk (https://golang.org/issue/45216)") + case "linux-riscv64-unmatched": + // As of 2021-11-03, this builder is empirically not fast enough to run + // gopls tests. Ideally we should make the tests faster in short mode + // and/or fix them to not assume arbitrary deadlines. + // For now, we'll skip them instead. + fmt.Fprintf(os.Stderr, "skipping test: %s builder is too slow (https://golang.org/issue/49321)\n", b) + default: + switch runtime.GOOS { + case "android", "ios": + fmt.Fprintf(os.Stderr, "skipping test: assuming that %s is resource-constrained\n", runtime.GOOS) + default: + return + } + } + os.Exit(0) +} + +// Go1Point returns the x in Go 1.x. +func Go1Point() int { + for i := len(build.Default.ReleaseTags) - 1; i >= 0; i-- { + var version int + if _, err := fmt.Sscanf(build.Default.ReleaseTags[i], "go1.%d", &version); err != nil { + continue + } + return version + } + panic("bad release tags") +} + +// NeedsGoCommand1Point skips t if the ambient go command version in the PATH +// of the current process is older than 1.x. +// +// NeedsGoCommand1Point memoizes the result of running the go command, so +// should be called after all mutations of PATH. +func NeedsGoCommand1Point(t testing.TB, x int) { + NeedsTool(t, "go") + go1point, err := goCommand1Point() + if err != nil { + panic(fmt.Sprintf("unable to determine go version: %v", err)) + } + if go1point < x { + t.Helper() + t.Skipf("go command is version 1.%d, older than required 1.%d", go1point, x) + } +} + +var ( + goCommand1PointOnce sync.Once + goCommand1Point_ int + goCommand1PointErr error +) + +func goCommand1Point() (int, error) { + goCommand1PointOnce.Do(func() { + goCommand1Point_, goCommand1PointErr = gocommand.GoVersion(context.Background(), gocommand.Invocation{}, new(gocommand.Runner)) + }) + return goCommand1Point_, goCommand1PointErr +} + +// NeedsGo1Point skips t if the Go version used to run the test is older than +// 1.x. +func NeedsGo1Point(t testing.TB, x int) { + if Go1Point() < x { + t.Helper() + t.Skipf("running Go version %q is version 1.%d, older than required 1.%d", runtime.Version(), Go1Point(), x) + } +} + +// SkipAfterGoCommand1Point skips t if the ambient go command version in the PATH of +// the current process is newer than 1.x. +// +// SkipAfterGoCommand1Point memoizes the result of running the go command, so +// should be called after any mutation of PATH. +func SkipAfterGoCommand1Point(t testing.TB, x int) { + NeedsTool(t, "go") + go1point, err := goCommand1Point() + if err != nil { + panic(fmt.Sprintf("unable to determine go version: %v", err)) + } + if go1point > x { + t.Helper() + t.Skipf("go command is version 1.%d, newer than maximum 1.%d", go1point, x) + } +} + +// SkipAfterGo1Point skips t if the Go version used to run the test is newer than +// 1.x. +func SkipAfterGo1Point(t testing.TB, x int) { + if Go1Point() > x { + t.Helper() + t.Skipf("running Go version %q is version 1.%d, newer than maximum 1.%d", runtime.Version(), Go1Point(), x) + } +} + +// NeedsLocalhostNet skips t if networking does not work for ports opened +// with "localhost". +func NeedsLocalhostNet(t testing.TB) { + switch runtime.GOOS { + case "js", "wasip1": + t.Skipf(`Listening on "localhost" fails on %s; see https://go.dev/issue/59718`, runtime.GOOS) + } +} + +// Deadline returns the deadline of t, if known, +// using the Deadline method added in Go 1.15. +func Deadline(t testing.TB) (time.Time, bool) { + td, ok := t.(interface { + Deadline() (time.Time, bool) + }) + if !ok { + return time.Time{}, false + } + return td.Deadline() +} + +var ( + gorootOnce sync.Once + gorootPath string + gorootErr error +) + +func findGOROOT() (string, error) { + gorootOnce.Do(func() { + gorootPath = runtime.GOROOT() + if gorootPath != "" { + // If runtime.GOROOT() is non-empty, assume that it is valid. (It might + // not be: for example, the user may have explicitly set GOROOT + // to the wrong directory.) + return + } + + cmd := exec.Command("go", "env", "GOROOT") + out, err := cmd.Output() + if err != nil { + gorootErr = fmt.Errorf("%v: %v", cmd, err) + } + gorootPath = strings.TrimSpace(string(out)) + }) + + return gorootPath, gorootErr +} + +// GOROOT reports the path to the directory containing the root of the Go +// project source tree. This is normally equivalent to runtime.GOROOT, but +// works even if the test binary was built with -trimpath. +// +// If GOROOT cannot be found, GOROOT skips t if t is non-nil, +// or panics otherwise. +func GOROOT(t testing.TB) string { + path, err := findGOROOT() + if err != nil { + if t == nil { + panic(err) + } + t.Helper() + t.Skip(err) + } + return path +} + +// NeedsLocalXTools skips t if the golang.org/x/tools module is replaced and +// its replacement directory does not exist (or does not contain the module). +func NeedsLocalXTools(t testing.TB) { + t.Helper() + + NeedsTool(t, "go") + + cmd := Command(t, "go", "list", "-f", "{{with .Replace}}{{.Dir}}{{end}}", "-m", "golang.org/x/tools") + out, err := cmd.Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 { + t.Skipf("skipping test: %v: %v\n%s", cmd, err, ee.Stderr) + } + t.Skipf("skipping test: %v: %v", cmd, err) + } + + dir := string(bytes.TrimSpace(out)) + if dir == "" { + // No replacement directory, and (since we didn't set -e) no error either. + // Maybe x/tools isn't replaced at all (as in a gopls release, or when + // using a go.work file that includes the x/tools module). + return + } + + // We found the directory where x/tools would exist if we're in a clone of the + // repo. Is it there? (If not, we're probably in the module cache instead.) + modFilePath := filepath.Join(dir, "go.mod") + b, err := os.ReadFile(modFilePath) + if err != nil { + t.Skipf("skipping test: x/tools replacement not found: %v", err) + } + modulePath := modfile.ModulePath(b) + + if want := "golang.org/x/tools"; modulePath != want { + t.Skipf("skipping test: %s module path is %q, not %q", modFilePath, modulePath, want) + } +} + +// NeedsGoExperiment skips t if the current process environment does not +// have a GOEXPERIMENT flag set. +func NeedsGoExperiment(t testing.TB, flag string) { + t.Helper() + + goexp := os.Getenv("GOEXPERIMENT") + set := false + for f := range strings.SplitSeq(goexp, ",") { + if f == "" { + continue + } + if f == "none" { + // GOEXPERIMENT=none disables all experiment flags. + set = false + break + } + val := true + if strings.HasPrefix(f, "no") { + f, val = f[2:], false + } + if f == flag { + set = val + } + } + if !set { + t.Skipf("skipping test: flag %q is not set in GOEXPERIMENT=%q", flag, goexp) + } +} + +// NeedsGOROOTDir skips the test if GOROOT/dir does not exist, and GOROOT is a +// released version of Go (=has a VERSION file). Some GOROOT directories are +// removed by cmd/distpack. +// +// See also golang/go#70081. +func NeedsGOROOTDir(t *testing.T, dir string) { + gorootTest := filepath.Join(GOROOT(t), dir) + if _, err := os.Stat(gorootTest); os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(GOROOT(t), "VERSION")); err == nil { + t.Skipf("skipping: GOROOT/%s not present", dir) + } + } +} + +// RedirectStderr causes os.Stderr (and the global logger) to be +// temporarily replaced so that writes to it are sent to t.Log. +// It is restored at test cleanup. +func RedirectStderr(t testing.TB) { + t.Setenv("RedirectStderr", "") // side effect: assert t.Parallel wasn't called + + // TODO(adonovan): if https://go.dev/issue/59928 is accepted, + // simply set w = t.Output() and dispense with the pipe. + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + done := make(chan struct{}) + go func() { + for sc := bufio.NewScanner(r); sc.Scan(); { + t.Log(sc.Text()) + } + r.Close() + close(done) + }() + + // Also do the same for the global logger. + savedWriter, savedPrefix, savedFlags := log.Writer(), log.Prefix(), log.Flags() + log.SetPrefix("log: ") + log.SetOutput(w) + log.SetFlags(0) + + oldStderr := os.Stderr + os.Stderr = w + t.Cleanup(func() { + w.Close() // ignore error + os.Stderr = oldStderr + + log.SetOutput(savedWriter) + log.SetPrefix(savedPrefix) + log.SetFlags(savedFlags) + + // Don't let test finish before final t.Log. + <-done + }) +} diff --git a/hack/tools/vendor/golang.org/x/tools/internal/testenv/testenv_notunix.go b/hack/tools/vendor/golang.org/x/tools/internal/testenv/testenv_notunix.go new file mode 100644 index 000000000000..85b3820e3fb5 --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/internal/testenv/testenv_notunix.go @@ -0,0 +1,13 @@ +// Copyright 2021 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. + +//go:build !(unix || aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris) + +package testenv + +import "os" + +// Sigquit is the signal to send to kill a hanging subprocess. +// On Unix we send SIGQUIT, but on non-Unix we only have os.Kill. +var Sigquit = os.Kill diff --git a/hack/tools/vendor/golang.org/x/tools/internal/testenv/testenv_unix.go b/hack/tools/vendor/golang.org/x/tools/internal/testenv/testenv_unix.go new file mode 100644 index 000000000000..d635b96b31b2 --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/internal/testenv/testenv_unix.go @@ -0,0 +1,13 @@ +// Copyright 2021 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. + +//go:build unix || aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris + +package testenv + +import "syscall" + +// Sigquit is the signal to send to kill a hanging subprocess. +// Send SIGQUIT to get a stack trace. +var Sigquit = syscall.SIGQUIT diff --git a/hack/tools/vendor/golang.org/x/tools/txtar/archive.go b/hack/tools/vendor/golang.org/x/tools/txtar/archive.go new file mode 100644 index 000000000000..85e4dc46ac31 --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/txtar/archive.go @@ -0,0 +1,143 @@ +// Copyright 2018 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 txtar implements a trivial text-based file archive format. +// +// The goals for the format are: +// +// - be trivial enough to create and edit by hand. +// - be able to store trees of text files describing go command test cases. +// - diff nicely in git history and code reviews. +// +// Non-goals include being a completely general archive format, +// storing binary data, storing file modes, storing special files like +// symbolic links, and so on. +// +// # Txtar format +// +// A txtar archive is zero or more comment lines and then a sequence of file entries. +// Each file entry begins with a file marker line of the form "-- FILENAME --" +// and is followed by zero or more file content lines making up the file data. +// The comment or file content ends at the next file marker line. +// The file marker line must begin with the three-byte sequence "-- " +// and end with the three-byte sequence " --", but the enclosed +// file name can be surrounding by additional white space, +// all of which is stripped. +// +// If the txtar file is missing a trailing newline on the final line, +// parsers should consider a final newline to be present anyway. +// +// There are no possible syntax errors in a txtar archive. +package txtar + +import ( + "bytes" + "fmt" + "os" + "strings" +) + +// An Archive is a collection of files. +type Archive struct { + Comment []byte + Files []File +} + +// A File is a single file in an archive. +type File struct { + Name string // name of file ("foo/bar.txt") + Data []byte // text content of file +} + +// Format returns the serialized form of an Archive. +// It is assumed that the Archive data structure is well-formed: +// a.Comment and all a.File[i].Data contain no file marker lines, +// and all a.File[i].Name is non-empty. +func Format(a *Archive) []byte { + var buf bytes.Buffer + buf.Write(fixNL(a.Comment)) + for _, f := range a.Files { + fmt.Fprintf(&buf, "-- %s --\n", f.Name) + buf.Write(fixNL(f.Data)) + } + return buf.Bytes() +} + +// ParseFile parses the named file as an archive. +func ParseFile(file string) (*Archive, error) { + data, err := os.ReadFile(file) + if err != nil { + return nil, err + } + return Parse(data), nil +} + +// Parse parses the serialized form of an Archive. +// The returned Archive holds slices of data. +func Parse(data []byte) *Archive { + a := new(Archive) + var name string + a.Comment, name, data = findFileMarker(data) + for name != "" { + f := File{name, nil} + f.Data, name, data = findFileMarker(data) + a.Files = append(a.Files, f) + } + return a +} + +var ( + newlineMarker = []byte("\n-- ") + marker = []byte("-- ") + markerEnd = []byte(" --") +) + +// findFileMarker finds the next file marker in data, +// extracts the file name, and returns the data before the marker, +// the file name, and the data after the marker. +// If there is no next marker, findFileMarker returns before = fixNL(data), name = "", after = nil. +func findFileMarker(data []byte) (before []byte, name string, after []byte) { + var i int + for { + if name, after = isMarker(data[i:]); name != "" { + return data[:i], name, after + } + j := bytes.Index(data[i:], newlineMarker) + if j < 0 { + return fixNL(data), "", nil + } + i += j + 1 // positioned at start of new possible marker + } +} + +// isMarker checks whether data begins with a file marker line. +// If so, it returns the name from the line and the data after the line. +// Otherwise it returns name == "" with an unspecified after. +func isMarker(data []byte) (name string, after []byte) { + if !bytes.HasPrefix(data, marker) { + return "", nil + } + if i := bytes.IndexByte(data, '\n'); i >= 0 { + data, after = data[:i], data[i+1:] + if data[i-1] == '\r' { // handle \r\n line ending + data = data[:i-1] + } + } + if !(bytes.HasSuffix(data, markerEnd) && len(data) >= len(marker)+len(markerEnd)) { + return "", nil + } + return strings.TrimSpace(string(data[len(marker) : len(data)-len(markerEnd)])), after +} + +// If data is empty or ends in \n, fixNL returns data. +// Otherwise fixNL returns a new slice consisting of data with a final \n added. +func fixNL(data []byte) []byte { + if len(data) == 0 || data[len(data)-1] == '\n' { + return data + } + d := make([]byte, len(data)+1) + copy(d, data) + d[len(data)] = '\n' + return d +} diff --git a/hack/tools/vendor/golang.org/x/tools/txtar/fs.go b/hack/tools/vendor/golang.org/x/tools/txtar/fs.go new file mode 100644 index 000000000000..fc8df12c18f7 --- /dev/null +++ b/hack/tools/vendor/golang.org/x/tools/txtar/fs.go @@ -0,0 +1,257 @@ +// Copyright 2024 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 txtar + +import ( + "errors" + "fmt" + "io" + "io/fs" + "path" + "slices" + "time" +) + +// FS returns the file system form of an Archive. +// It returns an error if any of the file names in the archive +// are not valid file system names. +// The archive must not be modified while the FS is in use. +// +// If the file system detects that it has been modified, calls to the +// file system return an ErrModified error. +func FS(a *Archive) (fs.FS, error) { + // Create a filesystem with a root directory. + root := &node{fileinfo: fileinfo{path: ".", mode: readOnlyDir}} + fsys := &filesystem{a, map[string]*node{root.path: root}} + + if err := initFiles(fsys); err != nil { + return nil, fmt.Errorf("cannot create fs.FS from txtar.Archive: %s", err) + } + return fsys, nil +} + +const ( + readOnly fs.FileMode = 0o444 // read only mode + readOnlyDir = readOnly | fs.ModeDir +) + +// ErrModified indicates that file system returned by FS +// noticed that the underlying archive has been modified +// since the call to FS. Detection of modification is best effort, +// to help diagnose misuse of the API, and is not guaranteed. +var ErrModified error = errors.New("txtar.Archive has been modified during txtar.FS") + +// A filesystem is a simple in-memory file system for txtar archives, +// represented as a map from valid path names to information about the +// files or directories they represent. +// +// File system operations are read only. Modifications to the underlying +// *Archive may race. To help prevent this, the filesystem tries +// to detect modification during Open and return ErrModified if it +// is able to detect a modification. +type filesystem struct { + ar *Archive + nodes map[string]*node +} + +// node is a file or directory in the tree of a filesystem. +type node struct { + fileinfo // fs.FileInfo and fs.DirEntry implementation + idx int // index into ar.Files (for files) + entries []fs.DirEntry // subdirectories and files (for directories) +} + +var _ fs.FS = (*filesystem)(nil) +var _ fs.DirEntry = (*node)(nil) + +// initFiles initializes fsys from fsys.ar.Files. Returns an error if there are any +// invalid file names or collisions between file or directories. +func initFiles(fsys *filesystem) error { + for idx, file := range fsys.ar.Files { + name := file.Name + if !fs.ValidPath(name) { + return fmt.Errorf("file %q is an invalid path", name) + } + + n := &node{idx: idx, fileinfo: fileinfo{path: name, size: len(file.Data), mode: readOnly}} + if err := insert(fsys, n); err != nil { + return err + } + } + return nil +} + +// insert adds node n as an entry to its parent directory within the filesystem. +func insert(fsys *filesystem, n *node) error { + if m := fsys.nodes[n.path]; m != nil { + return fmt.Errorf("duplicate path %q", n.path) + } + fsys.nodes[n.path] = n + + // fsys.nodes contains "." to prevent infinite loops. + parent, err := directory(fsys, path.Dir(n.path)) + if err != nil { + return err + } + parent.entries = append(parent.entries, n) + return nil +} + +// directory returns the directory node with the path dir and lazily-creates it +// if it does not exist. +func directory(fsys *filesystem, dir string) (*node, error) { + if m := fsys.nodes[dir]; m != nil && m.IsDir() { + return m, nil // pre-existing directory + } + + n := &node{fileinfo: fileinfo{path: dir, mode: readOnlyDir}} + if err := insert(fsys, n); err != nil { + return nil, err + } + return n, nil +} + +// dataOf returns the data associated with the file t. +// May return ErrModified if fsys.ar has been modified. +func dataOf(fsys *filesystem, n *node) ([]byte, error) { + if n.idx >= len(fsys.ar.Files) { + return nil, ErrModified + } + + f := fsys.ar.Files[n.idx] + if f.Name != n.path || len(f.Data) != n.size { + return nil, ErrModified + } + return f.Data, nil +} + +func (fsys *filesystem) Open(name string) (fs.File, error) { + if !fs.ValidPath(name) { + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid} + } + + n := fsys.nodes[name] + switch { + case n == nil: + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} + case n.IsDir(): + return &openDir{fileinfo: n.fileinfo, entries: n.entries}, nil + default: + data, err := dataOf(fsys, n) + if err != nil { + return nil, err + } + return &openFile{fileinfo: n.fileinfo, data: data}, nil + } +} + +func (fsys *filesystem) ReadFile(name string) ([]byte, error) { + file, err := fsys.Open(name) + if err != nil { + return nil, err + } + if file, ok := file.(*openFile); ok { + return slices.Clone(file.data), nil + } + return nil, &fs.PathError{Op: "read", Path: name, Err: fs.ErrInvalid} +} + +// A fileinfo implements fs.FileInfo and fs.DirEntry for a given archive file. +type fileinfo struct { + path string // unique path to the file or directory within a filesystem + size int + mode fs.FileMode +} + +var _ fs.FileInfo = (*fileinfo)(nil) +var _ fs.DirEntry = (*fileinfo)(nil) + +func (i *fileinfo) Name() string { return path.Base(i.path) } +func (i *fileinfo) Size() int64 { return int64(i.size) } +func (i *fileinfo) Mode() fs.FileMode { return i.mode } +func (i *fileinfo) Type() fs.FileMode { return i.mode.Type() } +func (i *fileinfo) ModTime() time.Time { return time.Time{} } +func (i *fileinfo) IsDir() bool { return i.mode&fs.ModeDir != 0 } +func (i *fileinfo) Sys() any { return nil } +func (i *fileinfo) Info() (fs.FileInfo, error) { return i, nil } + +// An openFile is a regular (non-directory) fs.File open for reading. +type openFile struct { + fileinfo + data []byte + offset int64 +} + +var _ fs.File = (*openFile)(nil) + +func (f *openFile) Stat() (fs.FileInfo, error) { return &f.fileinfo, nil } +func (f *openFile) Close() error { return nil } +func (f *openFile) Read(b []byte) (int, error) { + if f.offset >= int64(len(f.data)) { + return 0, io.EOF + } + if f.offset < 0 { + return 0, &fs.PathError{Op: "read", Path: f.path, Err: fs.ErrInvalid} + } + n := copy(b, f.data[f.offset:]) + f.offset += int64(n) + return n, nil +} + +func (f *openFile) Seek(offset int64, whence int) (int64, error) { + switch whence { + case 0: + // offset += 0 + case 1: + offset += f.offset + case 2: + offset += int64(len(f.data)) + } + if offset < 0 || offset > int64(len(f.data)) { + return 0, &fs.PathError{Op: "seek", Path: f.path, Err: fs.ErrInvalid} + } + f.offset = offset + return offset, nil +} + +func (f *openFile) ReadAt(b []byte, offset int64) (int, error) { + if offset < 0 || offset > int64(len(f.data)) { + return 0, &fs.PathError{Op: "read", Path: f.path, Err: fs.ErrInvalid} + } + n := copy(b, f.data[offset:]) + if n < len(b) { + return n, io.EOF + } + return n, nil +} + +// A openDir is a directory fs.File (so also an fs.ReadDirFile) open for reading. +type openDir struct { + fileinfo + entries []fs.DirEntry + offset int +} + +var _ fs.ReadDirFile = (*openDir)(nil) + +func (d *openDir) Stat() (fs.FileInfo, error) { return &d.fileinfo, nil } +func (d *openDir) Close() error { return nil } +func (d *openDir) Read(b []byte) (int, error) { + return 0, &fs.PathError{Op: "read", Path: d.path, Err: fs.ErrInvalid} +} + +func (d *openDir) ReadDir(count int) ([]fs.DirEntry, error) { + n := len(d.entries) - d.offset + if n == 0 && count > 0 { + return nil, io.EOF + } + if count > 0 && n > count { + n = count + } + list := make([]fs.DirEntry, n) + copy(list, d.entries[d.offset:d.offset+n]) + d.offset += n + return list, nil +} diff --git a/hack/tools/vendor/modules.txt b/hack/tools/vendor/modules.txt index f47e7525a8cb..a01a5405d1ac 100644 --- a/hack/tools/vendor/modules.txt +++ b/hack/tools/vendor/modules.txt @@ -1535,6 +1535,9 @@ golang.org/x/time/rate # golang.org/x/tools v0.44.0 ## explicit; go 1.25.0 golang.org/x/tools/go/analysis +golang.org/x/tools/go/analysis/analysistest +golang.org/x/tools/go/analysis/checker +golang.org/x/tools/go/analysis/internal golang.org/x/tools/go/analysis/passes/appends golang.org/x/tools/go/analysis/passes/asmdecl golang.org/x/tools/go/analysis/passes/assign @@ -1604,8 +1607,12 @@ golang.org/x/tools/go/types/typeutil golang.org/x/tools/imports golang.org/x/tools/internal/aliases golang.org/x/tools/internal/analysis/analyzerutil +golang.org/x/tools/internal/analysis/driverutil golang.org/x/tools/internal/analysis/typeindex golang.org/x/tools/internal/astutil +golang.org/x/tools/internal/astutil/free +golang.org/x/tools/internal/diff +golang.org/x/tools/internal/diff/lcs golang.org/x/tools/internal/event golang.org/x/tools/internal/event/core golang.org/x/tools/internal/event/keys @@ -1622,11 +1629,13 @@ golang.org/x/tools/internal/packagesinternal golang.org/x/tools/internal/pkgbits golang.org/x/tools/internal/refactor golang.org/x/tools/internal/stdlib +golang.org/x/tools/internal/testenv golang.org/x/tools/internal/typeparams golang.org/x/tools/internal/typesinternal golang.org/x/tools/internal/typesinternal/typeindex golang.org/x/tools/internal/versions golang.org/x/tools/refactor/satisfy +golang.org/x/tools/txtar # google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 ## explicit; go 1.25.0 google.golang.org/genproto/googleapis/api/expr/v1alpha1 From 47c85aa53c5246298f6deb179413c6f3b2c15f99 Mon Sep 17 00:00:00 2001 From: Bryan Cox Date: Mon, 10 Aug 2026 12:32:48 -0400 Subject: [PATCH 2/6] feat(hack/tools): add hypershiftlinter golangci-lint plugin framework Add the plugin entry point and settings-based analyzer selection for a custom golangci-lint plugin. The plugin builds as a shared object (.so) that golangci-lint loads at runtime. - plugin.go: BuildAnalyzers with optional enable list via settings - cmd/plugin/main.go: golangci-lint plugin entry point - Makefile: hypershiftlinter.so build target and test-linter target Co-Authored-By: Claude Opus 4.6 --- .codespellignore | 1 + Makefile | 13 +++ .../tools/hypershiftlinter/cmd/plugin/main.go | 20 +++++ hack/tools/hypershiftlinter/plugin.go | 85 +++++++++++++++++++ hack/tools/hypershiftlinter/plugin_test.go | 64 ++++++++++++++ 5 files changed, 183 insertions(+) create mode 100644 hack/tools/hypershiftlinter/cmd/plugin/main.go create mode 100644 hack/tools/hypershiftlinter/plugin.go create mode 100644 hack/tools/hypershiftlinter/plugin_test.go diff --git a/.codespellignore b/.codespellignore index 744efbc1c236..0e0a18f4fae0 100644 --- a/.codespellignore +++ b/.codespellignore @@ -11,3 +11,4 @@ MIs AfterAll SME uptodate +enbale diff --git a/Makefile b/Makefile index 2dfb1f5216c9..f36bc0fa842f 100644 --- a/Makefile +++ b/Makefile @@ -100,6 +100,11 @@ KUBEAPILINTER_PLUGIN := $(abspath $(TOOLS_BIN_DIR)/kube-api-linter.so) $(KUBEAPILINTER_PLUGIN): $(TOOLS_DIR)/go.mod # Build kube-api-linter as Go plugin cd $(TOOLS_DIR); CGO_ENABLED=1 $(GO) build -buildmode=plugin -o $(KUBEAPILINTER_PLUGIN) sigs.k8s.io/kube-api-linter/pkg/plugin +HYPERSHIFTLINTER_PLUGIN := $(abspath $(TOOLS_BIN_DIR)/hypershiftlinter.so) +HYPERSHIFTLINTER_SRC := $(shell find $(TOOLS_DIR)/hypershiftlinter -name '*.go' 2>/dev/null) +$(HYPERSHIFTLINTER_PLUGIN): $(TOOLS_DIR)/go.mod $(HYPERSHIFTLINTER_SRC) # Build hypershiftlinter as Go plugin + cd $(TOOLS_DIR); $(GO) build -a -buildmode=plugin -o $(HYPERSHIFTLINTER_PLUGIN) ./hypershiftlinter/cmd/plugin + # When not otherwise set, diff/lint against the upstream main branch. # This is always set in OpenShift CI. UPSTREAM_REMOTE ?= $(shell git remote -v 2>/dev/null | grep 'openshift/hypershift.*fetch' | head -1 | cut -f1) @@ -137,6 +142,14 @@ lint-fix: generate $(GOLANGCI_LINT) run --config ./.golangci.yml --fix -v; main_rc=$$?; \ exit $$(( api_rc > main_rc ? api_rc : main_rc )) +.PHONY: hypershift-lint-all +hypershift-lint-all: $(GOLANGCI_LINT) $(HYPERSHIFTLINTER_PLUGIN) + $(GOLANGCI_LINT) run --config ./.golangci.yml --modules-download-mode=readonly -v --enable-only hypershiftlinter --build-tags e2ev2 + +.PHONY: test-linter +test-linter: + cd $(TOOLS_DIR) && $(GO) test ./hypershiftlinter/analyzers/... -count=1 + .PHONY: verify-git-clean verify-git-clean: git update-index --refresh diff --git a/hack/tools/hypershiftlinter/cmd/plugin/main.go b/hack/tools/hypershiftlinter/cmd/plugin/main.go new file mode 100644 index 000000000000..e1ddaa8b9c99 --- /dev/null +++ b/hack/tools/hypershiftlinter/cmd/plugin/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "fmt" + + "golang.org/x/tools/go/analysis" + + "github.com/openshift/hypershift/hack/tools/hypershiftlinter" +) + +// New is the entry point for the golangci-lint Go plugin. +// golangci-lint loads the .so and calls plugin.Lookup("New") to find this function. +// See: https://golangci-lint.run/docs/plugins/go-plugins/ +func New(pluginSettings any) ([]*analysis.Analyzer, error) { + analyzers, err := hypershiftlinter.BuildAnalyzers(pluginSettings) + if err != nil { + return nil, fmt.Errorf("hypershiftlinter: %w", err) + } + return analyzers, nil +} diff --git a/hack/tools/hypershiftlinter/plugin.go b/hack/tools/hypershiftlinter/plugin.go new file mode 100644 index 000000000000..00fe56a55569 --- /dev/null +++ b/hack/tools/hypershiftlinter/plugin.go @@ -0,0 +1,85 @@ +package hypershiftlinter + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/openshift/hypershift/hack/tools/hypershiftlinter/analyzers/contextbackground" + "github.com/openshift/hypershift/hack/tools/hypershiftlinter/analyzers/guestcluster" + "github.com/openshift/hypershift/hack/tools/hypershiftlinter/analyzers/ipv6url" + "github.com/openshift/hypershift/hack/tools/hypershiftlinter/analyzers/sippyannotation" + "github.com/openshift/hypershift/hack/tools/hypershiftlinter/analyzers/testcasename" + "github.com/openshift/hypershift/hack/tools/hypershiftlinter/analyzers/testfuncname" + "github.com/openshift/hypershift/hack/tools/hypershiftlinter/analyzers/vacuouspass" + + "golang.org/x/tools/go/analysis" +) + +type Settings struct { + Analyzers *AnalyzerSettings `json:"analyzers"` +} + +type AnalyzerSettings struct { + Enable []string `json:"enable"` +} + +func BuildAnalyzers(rawSettings any) ([]*analysis.Analyzer, error) { + all := AllAnalyzers() + + if rawSettings == nil { + return all, nil + } + + s, err := decodeSettings(rawSettings) + if err != nil { + return nil, fmt.Errorf("invalid hypershiftlinter settings: %w", err) + } + + if s.Analyzers == nil || len(s.Analyzers.Enable) == 0 { + return all, nil + } + + known := make(map[string]*analysis.Analyzer, len(all)) + for _, a := range all { + known[a.Name] = a + } + + var filtered []*analysis.Analyzer + for _, name := range s.Analyzers.Enable { + a, ok := known[name] + if !ok { + return nil, fmt.Errorf("unknown hypershiftlinter analyzer %q", name) + } + filtered = append(filtered, a) + } + return filtered, nil +} + +func AllAnalyzers() []*analysis.Analyzer { + return []*analysis.Analyzer{ + testcasename.Analyzer, + testfuncname.Analyzer, + sippyannotation.Analyzer, + guestcluster.Analyzer, + contextbackground.Analyzer, + vacuouspass.Analyzer, + ipv6url.Analyzer, + } +} + +func decodeSettings(raw any) (Settings, error) { + data, err := json.Marshal(raw) + if err != nil { + return Settings{}, err + } + // Reject unknown fields so that a typo such as "enbale" surfaces as an error + // instead of silently leaving Enable empty and enabling every analyzer. + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + var s Settings + if err := dec.Decode(&s); err != nil { + return Settings{}, err + } + return s, nil +} diff --git a/hack/tools/hypershiftlinter/plugin_test.go b/hack/tools/hypershiftlinter/plugin_test.go new file mode 100644 index 000000000000..a324b9588a77 --- /dev/null +++ b/hack/tools/hypershiftlinter/plugin_test.go @@ -0,0 +1,64 @@ +package hypershiftlinter + +import ( + "strings" + "testing" + + "golang.org/x/tools/go/analysis" +) + +func TestBuildAnalyzersRejectsUnknownTopLevelField(t *testing.T) { + raw := map[string]any{ + "analyzers": map[string]any{"enable": []any{"vacuouspass"}}, + "unknown": true, + } + + if _, err := BuildAnalyzers(raw); err == nil { + t.Fatal("expected error for unknown top-level field, got nil") + } +} + +func TestBuildAnalyzersRejectsUnknownNestedField(t *testing.T) { + // "enbale" is a typo for "enable" — must not be silently ignored, otherwise + // every analyzer would be enabled instead of just the intended one. + raw := map[string]any{ + "analyzers": map[string]any{"enbale": []any{"vacuouspass"}}, + } + + if _, err := BuildAnalyzers(raw); err == nil { + t.Fatal("expected error for unknown nested field, got nil") + } +} + +func TestBuildAnalyzersAcceptsValidSettings(t *testing.T) { + raw := map[string]any{ + "analyzers": map[string]any{"enable": []any{"vacuouspass"}}, + } + + got, err := BuildAnalyzers(raw) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 1 || got[0].Name != "vacuouspass" { + t.Fatalf("expected only the vacuouspass analyzer, got %v", analyzerNames(got)) + } +} + +func TestBuildAnalyzersNilSettingsEnablesAll(t *testing.T) { + got, err := BuildAnalyzers(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != len(AllAnalyzers()) { + t.Fatalf("expected all analyzers, got %d of %d: %s", + len(got), len(AllAnalyzers()), strings.Join(analyzerNames(got), ", ")) + } +} + +func analyzerNames(analyzers []*analysis.Analyzer) []string { + names := make([]string, 0, len(analyzers)) + for _, a := range analyzers { + names = append(names, a.Name) + } + return names +} From 933ab4b2efbb134bf28cdddcd0b10d4635376575 Mon Sep 17 00:00:00 2001 From: Bryan Cox Date: Tue, 11 Aug 2026 15:22:40 -0400 Subject: [PATCH 3/6] feat(hack/tools): add hypershiftlinter static analysis analyzers Co-Authored-By: Claude Opus 4.6 --- .../contextbackground/contextbackground.go | 106 +++++ .../contextbackground_test.go | 12 + .../testdata/src/test/e2e/v2/bad/bad_test.go | 55 +++ .../src/test/e2e/v2/good/good_test.go | 75 +++ .../analyzers/guestcluster/guestcluster.go | 70 +++ .../guestcluster/guestcluster_test.go | 12 + .../testdata/src/pkg/outside/outside.go | 13 + .../testdata/src/test/e2e/v2/bad/bad.go | 50 ++ .../testdata/src/test/e2e/v2/good/good.go | 33 ++ .../analyzers/ipv6url/ipv6url.go | 95 ++++ .../analyzers/ipv6url/ipv6url_test.go | 12 + .../testdata/src/test/e2e/v2/bad/bad.go | 43 ++ .../testdata/src/test/e2e/v2/good/good.go | 44 ++ .../analyzers/pathutil/pathutil.go | 19 + .../analyzers/pathutil/pathutil_test.go | 46 ++ .../sippyannotation/sippyannotation.go | 147 ++++++ .../sippyannotation/sippyannotation_test.go | 12 + .../testdata/src/test/e2e/v2/bad/bad.go | 46 ++ .../testdata/src/test/e2e/v2/good/good.go | 60 +++ .../analyzers/testcasename/testcasename.go | 171 +++++++ .../testcasename/testcasename_test.go | 12 + .../testdata/src/a/bad/bad_test.go | 104 ++++ .../testdata/src/a/good/good_test.go | 160 +++++++ .../testdata/src/a/bad/bad_test.go | 19 + .../testdata/src/a/good/good_test.go | 60 +++ .../analyzers/testfuncname/testfuncname.go | 42 ++ .../testfuncname/testfuncname_test.go | 12 + .../testdata/src/test/e2e/v2/bad/bad.go | 125 +++++ .../testdata/src/test/e2e/v2/good/good.go | 179 +++++++ .../analyzers/vacuouspass/vacuouspass.go | 448 ++++++++++++++++++ .../analyzers/vacuouspass/vacuouspass_test.go | 12 + 31 files changed, 2294 insertions(+) create mode 100644 hack/tools/hypershiftlinter/analyzers/contextbackground/contextbackground.go create mode 100644 hack/tools/hypershiftlinter/analyzers/contextbackground/contextbackground_test.go create mode 100644 hack/tools/hypershiftlinter/analyzers/contextbackground/testdata/src/test/e2e/v2/bad/bad_test.go create mode 100644 hack/tools/hypershiftlinter/analyzers/contextbackground/testdata/src/test/e2e/v2/good/good_test.go create mode 100644 hack/tools/hypershiftlinter/analyzers/guestcluster/guestcluster.go create mode 100644 hack/tools/hypershiftlinter/analyzers/guestcluster/guestcluster_test.go create mode 100644 hack/tools/hypershiftlinter/analyzers/guestcluster/testdata/src/pkg/outside/outside.go create mode 100644 hack/tools/hypershiftlinter/analyzers/guestcluster/testdata/src/test/e2e/v2/bad/bad.go create mode 100644 hack/tools/hypershiftlinter/analyzers/guestcluster/testdata/src/test/e2e/v2/good/good.go create mode 100644 hack/tools/hypershiftlinter/analyzers/ipv6url/ipv6url.go create mode 100644 hack/tools/hypershiftlinter/analyzers/ipv6url/ipv6url_test.go create mode 100644 hack/tools/hypershiftlinter/analyzers/ipv6url/testdata/src/test/e2e/v2/bad/bad.go create mode 100644 hack/tools/hypershiftlinter/analyzers/ipv6url/testdata/src/test/e2e/v2/good/good.go create mode 100644 hack/tools/hypershiftlinter/analyzers/pathutil/pathutil.go create mode 100644 hack/tools/hypershiftlinter/analyzers/pathutil/pathutil_test.go create mode 100644 hack/tools/hypershiftlinter/analyzers/sippyannotation/sippyannotation.go create mode 100644 hack/tools/hypershiftlinter/analyzers/sippyannotation/sippyannotation_test.go create mode 100644 hack/tools/hypershiftlinter/analyzers/sippyannotation/testdata/src/test/e2e/v2/bad/bad.go create mode 100644 hack/tools/hypershiftlinter/analyzers/sippyannotation/testdata/src/test/e2e/v2/good/good.go create mode 100644 hack/tools/hypershiftlinter/analyzers/testcasename/testcasename.go create mode 100644 hack/tools/hypershiftlinter/analyzers/testcasename/testcasename_test.go create mode 100644 hack/tools/hypershiftlinter/analyzers/testcasename/testdata/src/a/bad/bad_test.go create mode 100644 hack/tools/hypershiftlinter/analyzers/testcasename/testdata/src/a/good/good_test.go create mode 100644 hack/tools/hypershiftlinter/analyzers/testfuncname/testdata/src/a/bad/bad_test.go create mode 100644 hack/tools/hypershiftlinter/analyzers/testfuncname/testdata/src/a/good/good_test.go create mode 100644 hack/tools/hypershiftlinter/analyzers/testfuncname/testfuncname.go create mode 100644 hack/tools/hypershiftlinter/analyzers/testfuncname/testfuncname_test.go create mode 100644 hack/tools/hypershiftlinter/analyzers/vacuouspass/testdata/src/test/e2e/v2/bad/bad.go create mode 100644 hack/tools/hypershiftlinter/analyzers/vacuouspass/testdata/src/test/e2e/v2/good/good.go create mode 100644 hack/tools/hypershiftlinter/analyzers/vacuouspass/vacuouspass.go create mode 100644 hack/tools/hypershiftlinter/analyzers/vacuouspass/vacuouspass_test.go diff --git a/hack/tools/hypershiftlinter/analyzers/contextbackground/contextbackground.go b/hack/tools/hypershiftlinter/analyzers/contextbackground/contextbackground.go new file mode 100644 index 000000000000..867b57d4bc50 --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/contextbackground/contextbackground.go @@ -0,0 +1,106 @@ +package contextbackground + +import ( + "go/ast" + "strings" + + "github.com/openshift/hypershift/hack/tools/hypershiftlinter/analyzers/pathutil" + + "golang.org/x/tools/go/analysis" +) + +var Analyzer = &analysis.Analyzer{ + Name: "contextbackground", + Doc: "bans context.Background() and context.TODO() in test files; use tc.Context instead", + Run: run, +} + +func run(pass *analysis.Pass) (any, error) { + for _, file := range pass.Files { + filename := pass.Fset.File(file.Pos()).Name() + if !pathutil.IsV2E2ETest(filename) || !strings.HasSuffix(filename, "_test.go") { + continue + } + + ast.Inspect(file, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + + if !isContextBackgroundOrTODO(call) { + return true + } + + if isInsideExemptFunc(file, call) { + return true + } + + pass.Report(analysis.Diagnostic{ + Pos: call.Pos(), + End: call.End(), + Message: "use tc.Context instead of context.Background()/context.TODO()", + }) + return true + }) + } + return nil, nil +} + +func isContextBackgroundOrTODO(call *ast.CallExpr) bool { + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return false + } + ident, ok := sel.X.(*ast.Ident) + if !ok { + return false + } + return ident.Name == "context" && (sel.Sel.Name == "Background" || sel.Sel.Name == "TODO") +} + +func isInsideExemptFunc(file *ast.File, target *ast.CallExpr) bool { + exempt := false + ast.Inspect(file, func(n ast.Node) bool { + if exempt { + return false + } + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + name := callName(call) + if name == "BeforeSuite" || name == "DeferCleanup" || name == "SynchronizedBeforeSuite" || name == "SynchronizedAfterSuite" { + for _, arg := range call.Args { + if containsNode(arg, target) { + exempt = true + return false + } + } + } + return true + }) + return exempt +} + +func callName(call *ast.CallExpr) string { + switch fn := call.Fun.(type) { + case *ast.Ident: + return fn.Name + case *ast.SelectorExpr: + return fn.Sel.Name + } + return "" +} + +func containsNode(tree ast.Node, target ast.Node) bool { + found := false + ast.Inspect(tree, func(n ast.Node) bool { + if n == target { + found = true + return false + } + return !found + }) + return found +} diff --git a/hack/tools/hypershiftlinter/analyzers/contextbackground/contextbackground_test.go b/hack/tools/hypershiftlinter/analyzers/contextbackground/contextbackground_test.go new file mode 100644 index 000000000000..f925cb30b70f --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/contextbackground/contextbackground_test.go @@ -0,0 +1,12 @@ +package contextbackground + +import ( + "testing" + + "golang.org/x/tools/go/analysis/analysistest" +) + +func TestAnalyzer(t *testing.T) { + testdata := analysistest.TestData() + analysistest.Run(t, testdata, Analyzer, "test/e2e/v2/good", "test/e2e/v2/bad") +} diff --git a/hack/tools/hypershiftlinter/analyzers/contextbackground/testdata/src/test/e2e/v2/bad/bad_test.go b/hack/tools/hypershiftlinter/analyzers/contextbackground/testdata/src/test/e2e/v2/bad/bad_test.go new file mode 100644 index 000000000000..dd2ff4ca1ed1 --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/contextbackground/testdata/src/test/e2e/v2/bad/bad_test.go @@ -0,0 +1,55 @@ +package bad + +import ( + "context" + "testing" +) + +// Invalid: using context.Background() in regular test +func TestBadUsage(t *testing.T) { + ctx := context.Background() // want `use tc\.Context instead of context\.Background\(\)/context\.TODO\(\)` + _ = ctx +} + +// Invalid: context.Background() in It block +func TestItBlock(t *testing.T) { + It("does something", func() { + ctx := context.Background() // want `use tc\.Context instead of context\.Background\(\)/context\.TODO\(\)` + _ = ctx + }) +} + +// Invalid: context.Background() in helper function +func helperFunction() { + ctx := context.Background() // want `use tc\.Context instead of context\.Background\(\)/context\.TODO\(\)` + _ = ctx +} + +// Invalid: context.Background() in AfterEach (not exempt, only BeforeSuite and DeferCleanup are) +func TestAfterEachNotExempt(t *testing.T) { + AfterEach(func() { + ctx := context.Background() // want `use tc\.Context instead of context\.Background\(\)/context\.TODO\(\)` + _ = ctx + }) +} + +// Invalid: context.TODO() in It block +func TestTODOInItBlock(t *testing.T) { + It("does something with TODO", func() { + ctx := context.TODO() // want `use tc\.Context instead of context\.Background\(\)/context\.TODO\(\)` + _ = ctx + }) +} + +// Invalid: context.Background() in BeforeEach (not exempt, only BeforeSuite and DeferCleanup are) +func TestBeforeEachNotExempt(t *testing.T) { + BeforeEach(func() { + ctx := context.Background() // want `use tc\.Context instead of context\.Background\(\)/context\.TODO\(\)` + _ = ctx + }) +} + +// Test helpers +func It(desc string, f func()) {} +func AfterEach(f func()) {} +func BeforeEach(f func()) {} diff --git a/hack/tools/hypershiftlinter/analyzers/contextbackground/testdata/src/test/e2e/v2/good/good_test.go b/hack/tools/hypershiftlinter/analyzers/contextbackground/testdata/src/test/e2e/v2/good/good_test.go new file mode 100644 index 000000000000..402dd049a7af --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/contextbackground/testdata/src/test/e2e/v2/good/good_test.go @@ -0,0 +1,75 @@ +package good + +import ( + "context" + "testing" +) + +// Valid: context.Background() inside BeforeSuite is exempt +func TestBeforeSuiteExempt(t *testing.T) { + BeforeSuite(func() { + ctx := context.Background() + _ = ctx + }) +} + +// Valid: context.Background() inside DeferCleanup is exempt +func TestDeferCleanupExempt(t *testing.T) { + DeferCleanup(func() { + ctx := context.Background() + _ = ctx + }) +} + +// Valid: nested BeforeSuite with context.Background() +func TestNestedBeforeSuite(t *testing.T) { + Describe("suite", func() { + BeforeSuite(func() { + ctx := context.Background() + setup(ctx) + }) + }) +} + +// Valid: DeferCleanup in cleanup chain +func TestDeferCleanupChain(t *testing.T) { + DeferCleanup(func() { + ctx := context.Background() + cleanup(ctx) + }) +} + +// Valid: multiple context.Background() calls in BeforeSuite +func TestMultipleBackgroundInBeforeSuite(t *testing.T) { + BeforeSuite(func() { + ctx1 := context.Background() + setup(ctx1) + ctx2 := context.Background() + setup(ctx2) + }) +} + +// Valid: context.Background() inside SynchronizedBeforeSuite is exempt +func TestSynchronizedBeforeSuiteExempt(t *testing.T) { + SynchronizedBeforeSuite(func() { + ctx := context.Background() + setup(ctx) + }) +} + +// Valid: context.TODO() inside BeforeSuite is exempt +func TestTODOInBeforeSuiteExempt(t *testing.T) { + BeforeSuite(func() { + ctx := context.TODO() + setup(ctx) + }) +} + +// Test helpers +func BeforeSuite(f func()) {} +func DeferCleanup(f func()) {} +func SynchronizedBeforeSuite(f ...func()) {} +func SynchronizedAfterSuite(f ...func()) {} +func Describe(name string, f func()) {} +func setup(ctx context.Context) {} +func cleanup(ctx context.Context) {} diff --git a/hack/tools/hypershiftlinter/analyzers/guestcluster/guestcluster.go b/hack/tools/hypershiftlinter/analyzers/guestcluster/guestcluster.go new file mode 100644 index 000000000000..2e23c5c3caf1 --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/guestcluster/guestcluster.go @@ -0,0 +1,70 @@ +package guestcluster + +import ( + "go/ast" + "go/token" + "strconv" + "strings" + + "github.com/openshift/hypershift/hack/tools/hypershiftlinter/analyzers/pathutil" + + "golang.org/x/tools/go/analysis" +) + +var Analyzer = &analysis.Analyzer{ + Name: "guestcluster", + Doc: `bans "guest cluster" terminology; use "hosted cluster" instead`, + Run: run, +} + +func run(pass *analysis.Pass) (any, error) { + for _, file := range pass.Files { + filename := pass.Fset.File(file.Pos()).Name() + if !pathutil.IsV2E2ETest(filename) { + continue + } + + for _, cg := range file.Comments { + for _, comment := range cg.List { + if containsGuestCluster(comment.Text) { + pass.Report(analysis.Diagnostic{ + Pos: comment.Pos(), + End: comment.End(), + Message: `use "hosted cluster" instead of "guest cluster"`, + }) + } + } + } + + ast.Inspect(file, func(n ast.Node) bool { + lit, ok := n.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return true + } + value, err := strconv.Unquote(lit.Value) + if err != nil { + return true + } + if containsGuestCluster(value) { + pass.Report(analysis.Diagnostic{ + Pos: lit.Pos(), + End: lit.End(), + Message: `use "hosted cluster" instead of "guest cluster"`, + }) + } + return true + }) + } + return nil, nil +} + +func containsGuestCluster(s string) bool { + lower := strings.ToLower(s) + if strings.Contains(lower, "guest cluster") { + return true + } + if strings.Contains(s, "guestCluster") || strings.Contains(s, "GuestCluster") || strings.Contains(s, "guestcluster") { + return true + } + return false +} diff --git a/hack/tools/hypershiftlinter/analyzers/guestcluster/guestcluster_test.go b/hack/tools/hypershiftlinter/analyzers/guestcluster/guestcluster_test.go new file mode 100644 index 000000000000..4053e9f62fbe --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/guestcluster/guestcluster_test.go @@ -0,0 +1,12 @@ +package guestcluster + +import ( + "testing" + + "golang.org/x/tools/go/analysis/analysistest" +) + +func TestAnalyzer(t *testing.T) { + testdata := analysistest.TestData() + analysistest.Run(t, testdata, Analyzer, "test/e2e/v2/good", "test/e2e/v2/bad", "pkg/outside") +} diff --git a/hack/tools/hypershiftlinter/analyzers/guestcluster/testdata/src/pkg/outside/outside.go b/hack/tools/hypershiftlinter/analyzers/guestcluster/testdata/src/pkg/outside/outside.go new file mode 100644 index 000000000000..a90478178015 --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/guestcluster/testdata/src/pkg/outside/outside.go @@ -0,0 +1,13 @@ +package outside + +// This file lives outside test/e2e/v2/ so the guestcluster analyzer should +// skip it entirely. It has no expected-diagnostic annotations, so any +// diagnostic reported here would be a test failure, proving the +// path-exclusion logic works. + +func SomeFunction() { + message := "checking guest cluster status" + _ = message + another := `guest cluster in raw literal` + _ = another +} diff --git a/hack/tools/hypershiftlinter/analyzers/guestcluster/testdata/src/test/e2e/v2/bad/bad.go b/hack/tools/hypershiftlinter/analyzers/guestcluster/testdata/src/test/e2e/v2/bad/bad.go new file mode 100644 index 000000000000..cb3fe4f60648 --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/guestcluster/testdata/src/test/e2e/v2/bad/bad.go @@ -0,0 +1,50 @@ +package bad + +// String literal with banned terminology +func TestBadStringLiteral() { + message := "checking guest cluster status" // want `use .* instead of .*` + _ = message +} + +// String with camelCase variant +func TestBadCamelCase() { + message := "the guestCluster is ready" // want `use .* instead of .*` + _ = message +} + +// String with PascalCase variant +func TestBadPascalCase() { + message := "GuestCluster configuration" // want `use .* instead of .*` + _ = message +} + +// Comment contains banned terminology +// This function checks the guest cluster readiness // want `use .* instead of .*` +func TestCommentViolation() { + // The guest cluster should be ready // want `use .* instead of .*` + _ = "ok" +} + +// String with lowercase no-space variant +func TestLowerCaseNoSpace() { + message := "the guestcluster is ready" // want `use .* instead of .*` + _ = message +} + +// Multiple violations in one string +func TestMultipleViolations() { + message := "guest cluster and guestCluster" // want `use .* instead of .*` + _ = message +} + +// String with all-caps banned term +func TestAllCaps() { + message := "checking GUEST CLUSTER status" // want `use .* instead of .*` + _ = message +} + +// Raw string literal (backtick) with banned terminology +func TestRawStringLiteral() { + message := `this mentions guest cluster` // want `use .* instead of .*` + _ = message +} diff --git a/hack/tools/hypershiftlinter/analyzers/guestcluster/testdata/src/test/e2e/v2/good/good.go b/hack/tools/hypershiftlinter/analyzers/guestcluster/testdata/src/test/e2e/v2/good/good.go new file mode 100644 index 000000000000..8d64a02d0c58 --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/guestcluster/testdata/src/test/e2e/v2/good/good.go @@ -0,0 +1,33 @@ +package good + +// Valid: using "hosted cluster" terminology +func TestGoodUsage() { + message := "checking hosted cluster status" + _ = message +} + +// Valid: using hostedCluster in code +func TestCamelCase() { + hostedCluster := "my-cluster" + _ = hostedCluster +} + +// Valid: HostedCluster type name +type HostedCluster struct { + Name string +} + +// Valid: Go identifier containing the banned term should NOT be flagged. +// The analyzer only checks string literals and comments, not identifier names. +func TestIdentifierNotFlagged() { + var guestCluster string = "some value" + _ = guestCluster + guestClusterName := "another value" + _ = guestClusterName +} + +// Valid: raw string literal with correct terminology +func TestRawStringLiteral() { + message := `this mentions hosted cluster` + _ = message +} diff --git a/hack/tools/hypershiftlinter/analyzers/ipv6url/ipv6url.go b/hack/tools/hypershiftlinter/analyzers/ipv6url/ipv6url.go new file mode 100644 index 000000000000..e16aebf39698 --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/ipv6url/ipv6url.go @@ -0,0 +1,95 @@ +package ipv6url + +import ( + "go/ast" + "go/token" + "regexp" + "strconv" + + "github.com/openshift/hypershift/hack/tools/hypershiftlinter/analyzers/pathutil" + + "golang.org/x/tools/go/analysis" +) + +var Analyzer = &analysis.Analyzer{ + Name: "ipv6url", + Doc: "detects fmt.Sprintf URL patterns that break with IPv6; use net.JoinHostPort instead", + Run: run, +} + +var hostPortPattern = regexp.MustCompile(`%s:%[dvs]`) + +func run(pass *analysis.Pass) (any, error) { + for _, file := range pass.Files { + filename := pass.Fset.File(file.Pos()).Name() + if !pathutil.IsV2E2ETest(filename) { + continue + } + + ast.Inspect(file, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok || len(call.Args) == 0 { + return true + } + + funcName := fmtFormatFunc(call) + if funcName == "" { + return true + } + + // Fprintf's first arg is io.Writer; the format string is the second arg. + fmtIdx := 0 + if funcName == "Fprintf" { + fmtIdx = 1 + } + if len(call.Args) <= fmtIdx { + return true + } + + formatLit, ok := call.Args[fmtIdx].(*ast.BasicLit) + if !ok || formatLit.Kind != token.STRING { + return true + } + + formatStr, err := strconv.Unquote(formatLit.Value) + if err != nil { + return true + } + + match := hostPortPattern.FindString(formatStr) + if match != "" { + pass.Report(analysis.Diagnostic{ + Pos: call.Pos(), + End: call.End(), + Message: "fmt." + funcName + " with " + match + " may produce invalid URLs for IPv6 addresses; use net.JoinHostPort instead", + }) + } + + return true + }) + } + return nil, nil +} + +// fmtFormatFunc returns the function name (e.g. "Sprintf", "Errorf", "Fprintf") +// if the call is a fmt format function that could break with IPv6 addresses, +// or "" if it is not. +func fmtFormatFunc(call *ast.CallExpr) string { + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return "" + } + ident, ok := sel.X.(*ast.Ident) + if !ok { + return "" + } + if ident.Name != "fmt" { + return "" + } + switch sel.Sel.Name { + case "Sprintf", "Errorf", "Fprintf": + return sel.Sel.Name + default: + return "" + } +} diff --git a/hack/tools/hypershiftlinter/analyzers/ipv6url/ipv6url_test.go b/hack/tools/hypershiftlinter/analyzers/ipv6url/ipv6url_test.go new file mode 100644 index 000000000000..942a57adf54f --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/ipv6url/ipv6url_test.go @@ -0,0 +1,12 @@ +package ipv6url + +import ( + "testing" + + "golang.org/x/tools/go/analysis/analysistest" +) + +func TestAnalyzer(t *testing.T) { + testdata := analysistest.TestData() + analysistest.Run(t, testdata, Analyzer, "test/e2e/v2/good", "test/e2e/v2/bad") +} diff --git a/hack/tools/hypershiftlinter/analyzers/ipv6url/testdata/src/test/e2e/v2/bad/bad.go b/hack/tools/hypershiftlinter/analyzers/ipv6url/testdata/src/test/e2e/v2/bad/bad.go new file mode 100644 index 000000000000..f20e6e010683 --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/ipv6url/testdata/src/test/e2e/v2/bad/bad.go @@ -0,0 +1,43 @@ +package bad + +import "fmt" + +// Invalid: fmt.Sprintf with %s:%d pattern +func TestBadPortD() { + host := "192.168.1.1" + port := 8080 + url := fmt.Sprintf("http://%s:%d/api", host, port) // want `fmt\.Sprintf with %s:%d may produce invalid URLs for IPv6 addresses; use net\.JoinHostPort instead` + _ = url +} + +// Invalid: fmt.Sprintf with %s:%v pattern +func TestBadPortV() { + host := "192.168.1.1" + port := 8080 + url := fmt.Sprintf("http://%s:%v/api", host, port) // want `fmt\.Sprintf with %s:%v may produce invalid URLs for IPv6 addresses; use net\.JoinHostPort instead` + _ = url +} + +// Invalid: simple %s:%d without http prefix +func TestSimpleHostPort() { + host := "localhost" + port := 9090 + addr := fmt.Sprintf("%s:%d", host, port) // want `fmt\.Sprintf with %s:%d may produce invalid URLs for IPv6 addresses; use net\.JoinHostPort instead` + _ = addr +} + +// Invalid: fmt.Errorf with %s:%d pattern +func TestBadErrorf() { + host := "192.168.1.1" + port := 8080 + err := fmt.Errorf("connect to %s:%d", host, port) // want `fmt\.Errorf with %s:%d may produce invalid URLs for IPv6 addresses; use net\.JoinHostPort instead` + _ = err +} + +// Invalid: fmt.Sprintf with %s:%s pattern +func TestBadPortS() { + host := "192.168.1.1" + portStr := "8080" + url := fmt.Sprintf("http://%s:%s", host, portStr) // want `fmt\.Sprintf with %s:%s may produce invalid URLs for IPv6 addresses; use net\.JoinHostPort instead` + _ = url +} diff --git a/hack/tools/hypershiftlinter/analyzers/ipv6url/testdata/src/test/e2e/v2/good/good.go b/hack/tools/hypershiftlinter/analyzers/ipv6url/testdata/src/test/e2e/v2/good/good.go new file mode 100644 index 000000000000..2e7731d63e4c --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/ipv6url/testdata/src/test/e2e/v2/good/good.go @@ -0,0 +1,44 @@ +package good + +import ( + "fmt" + "log" + "net" +) + +// Valid: using net.JoinHostPort for URL construction +func TestGoodUsage() { + host := "192.168.1.1" + port := 8080 + addr := net.JoinHostPort(host, fmt.Sprintf("%d", port)) + url := fmt.Sprintf("http://%s/api", addr) + _ = url +} + +// Valid: fmt.Sprintf without host:port pattern +func TestOtherFormats() { + name := "test" + value := 42 + message := fmt.Sprintf("name=%s value=%d", name, value) + _ = message +} + +// Valid: single %s without port +func TestNoPort() { + host := "example.com" + url := fmt.Sprintf("http://%s/path", host) + _ = url +} + +// Valid: log.Printf with %s:%d pattern (analyzer only checks fmt format funcs) +func TestLogPrintfNotFlagged() { + host := "192.168.1.1" + port := 8080 + log.Printf("http://%s:%d", host, port) +} + +// Valid: fmt.Errorf without host:port pattern +func TestErrorfNoHostPort() { + err := fmt.Errorf("failed to connect: %s", "timeout") + _ = err +} diff --git a/hack/tools/hypershiftlinter/analyzers/pathutil/pathutil.go b/hack/tools/hypershiftlinter/analyzers/pathutil/pathutil.go new file mode 100644 index 000000000000..018b2e8e0d3b --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/pathutil/pathutil.go @@ -0,0 +1,19 @@ +package pathutil + +import "strings" + +// IsUnitTest returns true for _test.go files that are NOT e2e or integration tests. +// TESTING.md conventions apply to unit tests only. +func IsUnitTest(filename string) bool { + if !strings.HasSuffix(filename, "_test.go") { + return false + } + return !strings.Contains(filename, "test/e2e/") && + !strings.Contains(filename, "test/integration/") +} + +// IsV2E2ETest returns true for files under test/e2e/v2/. +// test/e2e/v2/AGENTS.md conventions apply to v2 e2e tests only. +func IsV2E2ETest(filename string) bool { + return strings.Contains(filename, "test/e2e/v2/") +} diff --git a/hack/tools/hypershiftlinter/analyzers/pathutil/pathutil_test.go b/hack/tools/hypershiftlinter/analyzers/pathutil/pathutil_test.go new file mode 100644 index 000000000000..a035531f1224 --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/pathutil/pathutil_test.go @@ -0,0 +1,46 @@ +package pathutil + +import "testing" + +func TestIsUnitTest(t *testing.T) { + tests := []struct { + name string + filename string + want bool + }{ + {name: "When file is a unit test, it should return true", filename: "pkg/foo_test.go", want: true}, + {name: "When file is in a nested package, it should return true", filename: "hypershift-operator/controllers/nodepool/nodepool_controller_test.go", want: true}, + {name: "When file is not a test, it should return false", filename: "pkg/foo.go", want: false}, + {name: "When file is an e2e test, it should return false", filename: "test/e2e/util/util_test.go", want: false}, + {name: "When file is a v2 e2e test, it should return false", filename: "test/e2e/v2/smoke_test.go", want: false}, + {name: "When file is an integration test, it should return false", filename: "test/integration/foo_test.go", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsUnitTest(tt.filename); got != tt.want { + t.Errorf("IsUnitTest(%q) = %v, want %v", tt.filename, got, tt.want) + } + }) + } +} + +func TestIsV2E2ETest(t *testing.T) { + tests := []struct { + name string + filename string + want bool + }{ + {name: "When file is under test/e2e/v2, it should return true", filename: "test/e2e/v2/smoke_test.go", want: true}, + {name: "When file is in a v2 subdirectory, it should return true", filename: "test/e2e/v2/nodepool/nodepool_test.go", want: true}, + {name: "When file is a non-test file under v2, it should return true", filename: "test/e2e/v2/helpers.go", want: true}, + {name: "When file is under test/e2e but not v2, it should return false", filename: "test/e2e/util/util_test.go", want: false}, + {name: "When file is a regular package, it should return false", filename: "pkg/foo.go", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsV2E2ETest(tt.filename); got != tt.want { + t.Errorf("IsV2E2ETest(%q) = %v, want %v", tt.filename, got, tt.want) + } + }) + } +} diff --git a/hack/tools/hypershiftlinter/analyzers/sippyannotation/sippyannotation.go b/hack/tools/hypershiftlinter/analyzers/sippyannotation/sippyannotation.go new file mode 100644 index 000000000000..cf6e69c9221c --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/sippyannotation/sippyannotation.go @@ -0,0 +1,147 @@ +package sippyannotation + +import ( + "go/ast" + "go/token" + "strconv" + "strings" + + "github.com/openshift/hypershift/hack/tools/hypershiftlinter/analyzers/pathutil" + + "golang.org/x/tools/go/analysis" +) + +var Analyzer = &analysis.Analyzer{ + Name: "sippyannotation", + Doc: "checks that Ginkgo Describe blocks have [sig-hypershift][Jira:Hypershift] prefix and [Feature:X] annotation", + Run: run, +} + +const requiredPrefix = "[sig-hypershift][Jira:Hypershift]" + +func run(pass *analysis.Pass) (any, error) { + for _, file := range pass.Files { + filename := pass.Fset.File(file.Pos()).Name() + if !pathutil.IsV2E2ETest(filename) { + continue + } + + ast.Inspect(file, func(n ast.Node) bool { + call := extractDescribeCall(n) + if call == nil { + return true + } + + name, namePos := getFirstStringArg(call) + if name == "" { + return true + } + + if !strings.HasPrefix(name, requiredPrefix) { + pass.Report(analysis.Diagnostic{ + Pos: namePos, + Message: `Describe block must start with [sig-hypershift][Jira:Hypershift]`, + }) + return false + } + + if !hasValidFeatureAnnotation(name) { + if !hasFeatureInChildren(call) { + pass.Report(analysis.Diagnostic{ + Pos: namePos, + Message: `Describe block has no [Feature:X] annotation — add to Describe or to every child Context/When`, + }) + } + } + + return false + }) + } + return nil, nil +} + +func extractDescribeCall(n ast.Node) *ast.CallExpr { + switch node := n.(type) { + case *ast.AssignStmt: + if len(node.Rhs) == 1 { + if call, ok := node.Rhs[0].(*ast.CallExpr); ok && isGinkgoCall(call, "Describe") { + return call + } + } + case *ast.ValueSpec: + if len(node.Values) == 1 { + if call, ok := node.Values[0].(*ast.CallExpr); ok && isGinkgoCall(call, "Describe") { + return call + } + } + case *ast.ExprStmt: + if call, ok := node.X.(*ast.CallExpr); ok && isGinkgoCall(call, "Describe") { + return call + } + } + return nil +} + +func isGinkgoCall(call *ast.CallExpr, name string) bool { + switch fun := call.Fun.(type) { + case *ast.Ident: + return fun.Name == name + case *ast.SelectorExpr: + return fun.Sel.Name == name + } + return false +} + +func getFirstStringArg(call *ast.CallExpr) (string, token.Pos) { + if len(call.Args) == 0 { + return "", token.NoPos + } + lit, ok := call.Args[0].(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return "", token.NoPos + } + value, err := strconv.Unquote(lit.Value) + if err != nil { + return "", token.NoPos + } + return value, lit.Pos() +} + +func hasValidFeatureAnnotation(name string) bool { + idx := strings.Index(name, "[Feature:") + if idx == -1 { + return false + } + rest := name[idx+len("[Feature:"):] + end := strings.Index(rest, "]") + return end > 0 +} + +func hasFeatureInChildren(call *ast.CallExpr) bool { + found := false + for _, arg := range call.Args { + ast.Inspect(arg, func(n ast.Node) bool { + if found { + return false + } + innerCall, ok := n.(*ast.CallExpr) + if !ok { + return true + } + if isGinkgoCall(innerCall, "Context") || isGinkgoCall(innerCall, "When") { + name, _ := getFirstStringArg(innerCall) + if hasValidFeatureAnnotation(name) { + found = true + return false + } + } + // Register*Tests functions register Context/When blocks with Features indirectly + if ident, ok := innerCall.Fun.(*ast.Ident); ok && strings.HasPrefix(ident.Name, "Register") && strings.HasSuffix(ident.Name, "Tests") { + found = true + return false + } + return true + }) + } + return found +} diff --git a/hack/tools/hypershiftlinter/analyzers/sippyannotation/sippyannotation_test.go b/hack/tools/hypershiftlinter/analyzers/sippyannotation/sippyannotation_test.go new file mode 100644 index 000000000000..936be2520991 --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/sippyannotation/sippyannotation_test.go @@ -0,0 +1,12 @@ +package sippyannotation + +import ( + "testing" + + "golang.org/x/tools/go/analysis/analysistest" +) + +func TestAnalyzer(t *testing.T) { + testdata := analysistest.TestData() + analysistest.Run(t, testdata, Analyzer, "test/e2e/v2/good", "test/e2e/v2/bad") +} diff --git a/hack/tools/hypershiftlinter/analyzers/sippyannotation/testdata/src/test/e2e/v2/bad/bad.go b/hack/tools/hypershiftlinter/analyzers/sippyannotation/testdata/src/test/e2e/v2/bad/bad.go new file mode 100644 index 000000000000..839d06fcde68 --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/sippyannotation/testdata/src/test/e2e/v2/bad/bad.go @@ -0,0 +1,46 @@ +package bad + +// Invalid: missing required prefix +var _ = Describe("NodePool tests", func() { // want `Describe block must start with \[sig-hypershift\]\[Jira:Hypershift\]` + It("should work", func() {}) +}) + +// Invalid: has prefix but missing Feature annotation +var _ = Describe("[sig-hypershift][Jira:Hypershift] Missing feature", func() { // want `Describe block has no \[Feature:X\] annotation — add to Describe or to every child Context/When` + It("should work", func() {}) +}) + +// Invalid: wrong prefix format +var _ = Describe("[Jira:Hypershift][sig-hypershift] Wrong order", func() { // want `Describe block must start with \[sig-hypershift\]\[Jira:Hypershift\]` + It("should work", func() {}) +}) + +// Invalid: has prefix but no Feature and no children +var _ = Describe("[sig-hypershift][Jira:Hypershift] No children no feature", func() { // want `Describe block has no \[Feature:X\] annotation — add to Describe or to every child Context/When` +}) + +// Invalid: Feature annotation with empty value +var _ = Describe("[sig-hypershift][Jira:Hypershift][Feature:] Empty feature", func() { // want `Describe block has no \[Feature:X\] annotation — add to Describe or to every child Context/When` + It("should work", func() {}) +}) + +// Invalid: qualified ginkgo.Describe with bad prefix +var _ = ginkgo.Describe("Qualified bad prefix", func() { // want `Describe block must start with \[sig-hypershift\]\[Jira:Hypershift\]` + It("should work", func() {}) +}) + +// Invalid: bare ExprStmt Describe with bad prefix +func init() { + Describe("ExprStmt bad prefix", func() { // want `Describe block must start with \[sig-hypershift\]\[Jira:Hypershift\]` + It("should work", func() {}) + }) +} + +// Test helpers +var ginkgo ginkgoT + +type ginkgoT struct{} + +func (ginkgoT) Describe(name string, f func()) bool { return true } +func Describe(name string, f func()) bool { return true } +func It(name string, f func()) {} diff --git a/hack/tools/hypershiftlinter/analyzers/sippyannotation/testdata/src/test/e2e/v2/good/good.go b/hack/tools/hypershiftlinter/analyzers/sippyannotation/testdata/src/test/e2e/v2/good/good.go new file mode 100644 index 000000000000..24d3f832553d --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/sippyannotation/testdata/src/test/e2e/v2/good/good.go @@ -0,0 +1,60 @@ +package good + +// Valid: Describe with proper prefix and Feature annotation +var _ = Describe("[sig-hypershift][Jira:Hypershift][Feature:NodePool] NodePool tests", func() { + It("should create a node pool", func() {}) +}) + +// Valid: Describe with prefix and Feature in child Context +var _ = Describe("[sig-hypershift][Jira:Hypershift] Complex suite", func() { + Context("[Feature:ControlPlane]", func() { + It("should start control plane", func() {}) + }) +}) + +// Valid: Describe with prefix and Feature in child When +var _ = Describe("[sig-hypershift][Jira:Hypershift] Advanced suite", func() { + When("[Feature:Upgrade]", func() { + It("should upgrade successfully", func() {}) + }) +}) + +// Valid: Describe with Register*Tests call (exempt from Feature check) +var _ = Describe("[sig-hypershift][Jira:Hypershift] Test suite", func() { + RegisterNodePoolTests() +}) + +// Valid: AssignStmt form with proper annotations +func TestAssignStmt() { + describeResult := Describe("[sig-hypershift][Jira:Hypershift][Feature:Test] assign form", func() { + It("should work", func() {}) + }) + _ = describeResult +} + +// Valid: qualified ginkgo.Describe with proper prefix and Feature annotation +var _ = ginkgo.Describe("[sig-hypershift][Jira:Hypershift][Feature:Qualified] Qualified call", func() { + It("should work", func() {}) +}) + +// Valid: Describe with mixed children — some have Feature and some don't (at-least-one semantics) +var _ = Describe("[sig-hypershift][Jira:Hypershift] Mixed children suite", func() { + Context("[Feature:MixedA] first context", func() { + It("should work", func() {}) + }) + Context("no feature here", func() { + It("should also work", func() {}) + }) +}) + +// Test helpers +var ginkgo ginkgoT + +type ginkgoT struct{} + +func (ginkgoT) Describe(name string, f func()) bool { return true } +func Describe(name string, f func()) bool { return true } +func Context(name string, f func()) {} +func When(name string, f func()) {} +func It(name string, f func()) {} +func RegisterNodePoolTests() {} diff --git a/hack/tools/hypershiftlinter/analyzers/testcasename/testcasename.go b/hack/tools/hypershiftlinter/analyzers/testcasename/testcasename.go new file mode 100644 index 000000000000..093b7d7eb2f8 --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/testcasename/testcasename.go @@ -0,0 +1,171 @@ +package testcasename + +import ( + "go/ast" + "go/token" + "regexp" + "slices" + "strconv" + + "github.com/openshift/hypershift/hack/tools/hypershiftlinter/analyzers/pathutil" + + "golang.org/x/tools/go/analysis" +) + +var Analyzer = &analysis.Analyzer{ + Name: "testcasename", + Doc: "checks that test case name fields match \"When , it should \"", + Run: run, +} + +var namePattern = regexp.MustCompile(`(?i)^when .+,? it should .+$`) + +func run(pass *analysis.Pass) (any, error) { + for _, file := range pass.Files { + filename := pass.Fset.File(file.Pos()).Name() + if !pathutil.IsUnitTest(filename) { + continue + } + + ast.Inspect(file, func(n ast.Node) bool { + comp, ok := n.(*ast.CompositeLit) + if !ok { + return true + } + + // Check map-based test tables: map[string]struct{...}{ "name": {...}, ... } + if looksLikeTestCaseMap(comp) { + for _, elt := range comp.Elts { + kv, ok := elt.(*ast.KeyValueExpr) + if !ok { + continue + } + + lit, ok := kv.Key.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + continue + } + + value, err := strconv.Unquote(lit.Value) + if err != nil { + continue + } + + if !namePattern.MatchString(value) { + pass.Report(analysis.Diagnostic{ + Pos: lit.Pos(), + End: lit.End(), + Message: `test case name "` + value + `" must match format "When , it should "`, + }) + } + } + return true + } + + // Only check structs that appear to be test cases + if !looksLikeTestCaseStruct(comp) { + return true + } + + for _, elt := range comp.Elts { + kv, ok := elt.(*ast.KeyValueExpr) + if !ok { + continue + } + + ident, ok := kv.Key.(*ast.Ident) + if !ok || ident.Name != "name" { + continue + } + + lit, ok := kv.Value.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + continue + } + + value, err := strconv.Unquote(lit.Value) + if err != nil { + continue + } + + if !namePattern.MatchString(value) { + pass.Report(analysis.Diagnostic{ + Pos: lit.Pos(), + End: lit.End(), + Message: `test case name "` + value + `" must match format "When , it should "`, + }) + } + } + return true + }) + } + return nil, nil +} + +// looksLikeTestCaseStruct returns true if the composite literal appears to be +// a test case struct (has a "name" field plus other typical test fields). +func looksLikeTestCaseStruct(comp *ast.CompositeLit) bool { + // Must be a struct literal (no type or anonymous struct type) + if comp.Type != nil { + if _, ok := comp.Type.(*ast.StructType); !ok { + // Has a named type - not an anonymous test case struct + return false + } + } + + hasName := false + hasTestField := false + + for _, elt := range comp.Elts { + kv, ok := elt.(*ast.KeyValueExpr) + if !ok { + continue + } + ident, ok := kv.Key.(*ast.Ident) + if !ok { + continue + } + + if ident.Name == "name" { + hasName = true + } + + testFieldNames := []string{ + "want", "expected", "expectError", "expectErr", "wantErr", + "args", "input", "output", "result", "fields", "setup", + "assertion", "validate", "check", + } + if slices.Contains(testFieldNames, ident.Name) { + hasTestField = true + } + + if hasName && hasTestField { + return true + } + } + + return false +} + +// looksLikeTestCaseMap returns true if the composite literal is a +// map[string]struct{...}{...} — a common pattern for map-based test tables +// where the map keys serve as test case names. +func looksLikeTestCaseMap(comp *ast.CompositeLit) bool { + mt, ok := comp.Type.(*ast.MapType) + if !ok { + return false + } + + // Key type must be string. + keyIdent, ok := mt.Key.(*ast.Ident) + if !ok || keyIdent.Name != "string" { + return false + } + + // Value type must be a struct. + if _, ok := mt.Value.(*ast.StructType); !ok { + return false + } + + return true +} diff --git a/hack/tools/hypershiftlinter/analyzers/testcasename/testcasename_test.go b/hack/tools/hypershiftlinter/analyzers/testcasename/testcasename_test.go new file mode 100644 index 000000000000..f70465833e49 --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/testcasename/testcasename_test.go @@ -0,0 +1,12 @@ +package testcasename + +import ( + "testing" + + "golang.org/x/tools/go/analysis/analysistest" +) + +func TestAnalyzer(t *testing.T) { + testdata := analysistest.TestData() + analysistest.Run(t, testdata, Analyzer, "a/good", "a/bad") +} diff --git a/hack/tools/hypershiftlinter/analyzers/testcasename/testdata/src/a/bad/bad_test.go b/hack/tools/hypershiftlinter/analyzers/testcasename/testdata/src/a/bad/bad_test.go new file mode 100644 index 000000000000..e12da59f6f13 --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/testcasename/testdata/src/a/bad/bad_test.go @@ -0,0 +1,104 @@ +package bad + +import "testing" + +func TestBadNames(t *testing.T) { + tests := []struct { + name string + want string + }{ + { + name: "it works", // want `test case name "it works" must match format "When , it should "` + want: "ok", + }, + { + name: "should do X", // want `test case name "should do X" must match format "When , it should "` + want: "X", + }, + { + name: "When X should Y", // want `test case name "When X should Y" must match format "When , it should "` + want: "Y", + }, + { + name: "happy path", // want `test case name "happy path" must match format "When , it should "` + want: "success", + }, + { + name: "test something", // want `test case name "test something" must match format "When , it should "` + want: "result", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // test implementation + }) + } +} + +func TestWithExpectedField(t *testing.T) { + tests := []struct { + name string + expected int + }{ + { + name: "bad name here", // want `test case name "bad name here" must match format "When , it should "` + expected: 42, + }, + } + + for _, tt := range tests { + _ = tt + } +} + +func TestWithWantErrField(t *testing.T) { + tests := []struct { + name string + wantErr bool + }{ + { + name: "error case", // want `test case name "error case" must match format "When , it should "` + wantErr: true, + }, + } + + for _, tt := range tests { + _ = tt + } +} + +func TestMapBasedBadNames(t *testing.T) { + tests := map[string]struct { + input string + }{ + "it works": { // want `test case name "it works" must match format "When , it should "` + input: "a", + }, + "does the right thing": { // want `test case name "does the right thing" must match format "When , it should "` + input: "b", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + _ = tt + }) + } +} + +func TestWithSetupField(t *testing.T) { + tests := []struct { + name string + setup func() + }{ + { + name: "missing format", // want `test case name "missing format" must match format "When , it should "` + setup: func() {}, + }, + } + + for _, tt := range tests { + _ = tt + } +} diff --git a/hack/tools/hypershiftlinter/analyzers/testcasename/testdata/src/a/good/good_test.go b/hack/tools/hypershiftlinter/analyzers/testcasename/testdata/src/a/good/good_test.go new file mode 100644 index 000000000000..c80e6da2a270 --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/testcasename/testdata/src/a/good/good_test.go @@ -0,0 +1,160 @@ +package good + +import "testing" + +func TestSomething(t *testing.T) { + tests := []struct { + name string + want string + }{ + { + name: "When X is set, it should return Y", + want: "Y", + }, + { + name: "when x, it should y", + want: "y", + }, + { + name: "When the user provides valid input, it should succeed", + want: "success", + }, + { + name: "WHEN something happens, IT SHOULD respond", + want: "ok", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // test implementation + }) + } +} + +func TestStructWithoutNameField(t *testing.T) { + tests := []struct { + input string + expected string + }{ + { + input: "foo", + expected: "bar", + }, + } + + for _, tt := range tests { + // test implementation + _ = tt + } +} + +func TestNonTestStruct(t *testing.T) { + // This struct doesn't look like a test case (no test fields) + // so it should be skipped even if name doesn't match pattern + type Config struct { + name string + host string + } + + c := Config{ + name: "not a test case", + host: "localhost", + } + _ = c +} + +func TestNamedStructTypeDirect(t *testing.T) { + // Direct use of named struct type - the analyzer skips these + // because comp.Type would be *ast.Ident (not *ast.StructType) + type testCase struct { + name string + want int + } + + tc := testCase{ + name: "not checked because this uses named type directly", + want: 42, + } + _ = tc +} + +func TestNoCommaInName(t *testing.T) { + tests := []struct { + name string + want string + }{ + { + name: "When X it should Y", + want: "Y", + }, + { + name: "WHEN the condition is met IT SHOULD work", + want: "ok", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // test implementation + }) + } +} + +func TestMapBasedGoodNames(t *testing.T) { + tests := map[string]struct { + input string + }{ + "When input is valid, it should succeed": { + input: "a", + }, + "When nothing is provided it should use defaults": { + input: "", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + _ = tt + }) + } +} + +func TestNameFromVariable(t *testing.T) { + testName := "some dynamic name" + tests := []struct { + name string + want string + }{ + { + name: testName, + want: "ok", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _ = tt + }) + } +} + +func TestWithValidateAndCheckFields(t *testing.T) { + tests := []struct { + name string + validate func() bool + check func() error + }{ + { + name: "When fields include validate and check, it should pass", + validate: func() bool { return true }, + check: func() error { return nil }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _ = tt + }) + } +} diff --git a/hack/tools/hypershiftlinter/analyzers/testfuncname/testdata/src/a/bad/bad_test.go b/hack/tools/hypershiftlinter/analyzers/testfuncname/testdata/src/a/bad/bad_test.go new file mode 100644 index 000000000000..3c763bf34ad6 --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/testfuncname/testdata/src/a/bad/bad_test.go @@ -0,0 +1,19 @@ +package bad + +import "testing" + +func Test_foo(t *testing.T) { // want `test function "Test_foo" must not use Test_ prefix; use Testfoo` + // test implementation +} + +func Test_bar_baz(t *testing.T) { // want `test function "Test_bar_baz" must not use Test_ prefix; use Testbar_baz` + // test implementation +} + +func Test_something_else(t *testing.T) { // want `test function "Test_something_else" must not use Test_ prefix; use Testsomething_else` + // test implementation +} + +func Test_WithMultipleWords(t *testing.T) { // want `test function "Test_WithMultipleWords" must not use Test_ prefix; use TestWithMultipleWords` + // test implementation +} diff --git a/hack/tools/hypershiftlinter/analyzers/testfuncname/testdata/src/a/good/good_test.go b/hack/tools/hypershiftlinter/analyzers/testfuncname/testdata/src/a/good/good_test.go new file mode 100644 index 000000000000..d87ba5aa8e4f --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/testfuncname/testdata/src/a/good/good_test.go @@ -0,0 +1,60 @@ +package good + +import ( + "os" + "testing" +) + +// TestMain is a special Go test function that must be allowed +func TestMain(m *testing.M) { + // setup + code := m.Run() + // teardown + os.Exit(code) +} + +// Good test function names (no underscore after Test) +func TestFoo(t *testing.T) { + // test implementation +} + +func TestBarBaz(t *testing.T) { + // test implementation +} + +func TestSomethingElse(t *testing.T) { + // test implementation +} + +// Benchmark functions are not checked +func BenchmarkX(b *testing.B) { + // benchmark implementation +} + +// Example functions are not checked +func ExampleFoo() { + // example implementation +} + +// Methods with receivers are not checked (not top-level test functions) +type Suite struct{} + +func (s *Suite) Test_methodName(t *testing.T) { + // This is a method, not a top-level function, so it's allowed +} + +// Helper functions don't start with Test +func helperFunction(t *testing.T) { + // helper implementation +} + +// Underscores in subtest names (t.Run parameter) are fine +func TestSubtestWithUnderscores(t *testing.T) { + t.Run("test_name_with_underscores", func(t *testing.T) { + // The underscore is in the subtest name string, not the function name + }) + + t.Run("another_test_case", func(t *testing.T) { + // This is also fine + }) +} diff --git a/hack/tools/hypershiftlinter/analyzers/testfuncname/testfuncname.go b/hack/tools/hypershiftlinter/analyzers/testfuncname/testfuncname.go new file mode 100644 index 000000000000..142170b4cb3e --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/testfuncname/testfuncname.go @@ -0,0 +1,42 @@ +package testfuncname + +import ( + "go/ast" + "strings" + + "github.com/openshift/hypershift/hack/tools/hypershiftlinter/analyzers/pathutil" + + "golang.org/x/tools/go/analysis" +) + +var Analyzer = &analysis.Analyzer{ + Name: "testfuncname", + Doc: "checks that test functions do not use Test_ prefix; use TestFunctionName instead", + Run: run, +} + +func run(pass *analysis.Pass) (any, error) { + for _, file := range pass.Files { + filename := pass.Fset.File(file.Pos()).Name() + if !pathutil.IsUnitTest(filename) { + continue + } + + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv != nil { + continue + } + + name := fn.Name.Name + if strings.HasPrefix(name, "Test_") { + pass.Report(analysis.Diagnostic{ + Pos: fn.Name.Pos(), + End: fn.Name.End(), + Message: `test function "` + name + `" must not use Test_ prefix; use Test` + strings.TrimPrefix(name, "Test_"), + }) + } + } + } + return nil, nil +} diff --git a/hack/tools/hypershiftlinter/analyzers/testfuncname/testfuncname_test.go b/hack/tools/hypershiftlinter/analyzers/testfuncname/testfuncname_test.go new file mode 100644 index 000000000000..b1510ad08af0 --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/testfuncname/testfuncname_test.go @@ -0,0 +1,12 @@ +package testfuncname + +import ( + "testing" + + "golang.org/x/tools/go/analysis/analysistest" +) + +func TestAnalyzer(t *testing.T) { + testdata := analysistest.TestData() + analysistest.Run(t, testdata, Analyzer, "a/good", "a/bad") +} diff --git a/hack/tools/hypershiftlinter/analyzers/vacuouspass/testdata/src/test/e2e/v2/bad/bad.go b/hack/tools/hypershiftlinter/analyzers/vacuouspass/testdata/src/test/e2e/v2/bad/bad.go new file mode 100644 index 000000000000..ecb7cc8d4e20 --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/vacuouspass/testdata/src/test/e2e/v2/bad/bad.go @@ -0,0 +1,125 @@ +package bad + +// Invalid: range over .Items without preceding assertion +var _ = Describe("Test", func() { + It("checks items", func() { + list := getList() + for _, item := range list.Items { // want `range over \.Items without preceding non-empty assertion — add Expect\(x\.Items\)\.NotTo\(BeEmpty\(\)\) before the loop` + Expect(item.Name).NotTo(BeEmpty()) + } + }) +}) + +// Invalid: range without assertion in nested Context +var _ = Describe("Test", func() { + Context("nested", func() { + It("checks items", func() { + list := getList() + for _, item := range list.Items { // want `range over \.Items without preceding non-empty assertion — add Expect\(x\.Items\)\.NotTo\(BeEmpty\(\)\) before the loop` + Expect(item.Name).NotTo(BeEmpty()) + } + }) + }) +}) + +// Invalid: multiple range loops, second one missing assertion +var _ = Describe("Test", func() { + It("checks multiple lists", func() { + list1 := getList() + Expect(list1.Items).NotTo(BeEmpty()) + for _, item := range list1.Items { + Expect(item.Name).NotTo(BeEmpty()) + } + + list2 := getList() + for _, item := range list2.Items { // want `range over \.Items without preceding non-empty assertion — add Expect\(x\.Items\)\.NotTo\(BeEmpty\(\)\) before the loop` + Expect(item.Name).NotTo(BeEmpty()) + } + }) +}) + +// Invalid: assertion AFTER the range loop (not before) +var _ = Describe("Test", func() { + It("checks items with late assertion", func() { + list := getList() + for _, item := range list.Items { // want `range over \.Items without preceding non-empty assertion — add Expect\(x\.Items\)\.NotTo\(BeEmpty\(\)\) before the loop` + Expect(item.Name).NotTo(BeEmpty()) + } + Expect(list.Items).NotTo(BeEmpty()) + }) +}) + +// Invalid: When block with range loop missing assertion +var _ = Describe("Test", func() { + When("condition is met", func() { + It("checks items without assertion", func() { + list := getList() + for _, item := range list.Items { // want `range over \.Items without preceding non-empty assertion — add Expect\(x\.Items\)\.NotTo\(BeEmpty\(\)\) before the loop` + Expect(item.Name).NotTo(BeEmpty()) + } + }) + }) +}) + +// Invalid: HaveLen(0) permits an empty list, so it is not a non-empty guard. +var _ = Describe("Test", func() { + It("checks items with HaveLen(0)", func() { + list := getList() + Expect(list.Items).To(HaveLen(0)) + for _, item := range list.Items { // want `range over \.Items without preceding non-empty assertion — add Expect\(x\.Items\)\.NotTo\(BeEmpty\(\)\) before the loop` + Expect(item.Name).NotTo(BeEmpty()) + } + }) +}) + +// Invalid: BeNumerically("<", 1) permits an empty list. +var _ = Describe("Test", func() { + It("checks items with BeNumerically less-than-one", func() { + list := getList() + Expect(len(list.Items)).To(BeNumerically("<", 1)) + for _, item := range list.Items { // want `range over \.Items without preceding non-empty assertion — add Expect\(x\.Items\)\.NotTo\(BeEmpty\(\)\) before the loop` + Expect(item.Name).NotTo(BeEmpty()) + } + }) +}) + +// Invalid: BeNumerically("==", 0) permits (requires) an empty list. +var _ = Describe("Test", func() { + It("checks items with BeNumerically equal-zero", func() { + list := getList() + Expect(len(list.Items)).To(BeNumerically("==", 0)) + for _, item := range list.Items { // want `range over \.Items without preceding non-empty assertion — add Expect\(x\.Items\)\.NotTo\(BeEmpty\(\)\) before the loop` + Expect(item.Name).NotTo(BeEmpty()) + } + }) +}) + +// Test helpers +type ItemList struct { + Items []Item +} + +type Item struct { + Name string +} + +func Describe(name string, f func()) bool { return true } +func Context(name string, f func()) {} +func When(name string, f func()) {} +func It(name string, f func()) {} +func Expect(val interface{}) Assertion { return Assertion{} } + +type Assertion struct{} + +func (a Assertion) NotTo(matcher Matcher) {} +func (a Assertion) To(matcher Matcher) {} + +type Matcher struct{} + +func BeEmpty() Matcher { return Matcher{} } +func HaveLen(count int) Matcher { return Matcher{} } +func BeNumerically(comparator string, compareTo ...interface{}) Matcher { return Matcher{} } + +func getList() ItemList { + return ItemList{Items: []Item{{Name: "test"}}} +} diff --git a/hack/tools/hypershiftlinter/analyzers/vacuouspass/testdata/src/test/e2e/v2/good/good.go b/hack/tools/hypershiftlinter/analyzers/vacuouspass/testdata/src/test/e2e/v2/good/good.go new file mode 100644 index 000000000000..2c63abf37ad3 --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/vacuouspass/testdata/src/test/e2e/v2/good/good.go @@ -0,0 +1,179 @@ +package good + +// Valid: has Expect().NotTo(BeEmpty()) before range +var _ = Describe("Test", func() { + It("checks items", func() { + list := getList() + Expect(list.Items).NotTo(BeEmpty()) + for _, item := range list.Items { + Expect(item.Name).NotTo(BeEmpty()) + } + }) +}) + +// Valid: BeforeEach assertion covers It block +var _ = Describe("Test", func() { + BeforeEach(func() { + list := getList() + Expect(list.Items).NotTo(BeEmpty()) + }) + + It("checks items", func() { + list := getList() + for _, item := range list.Items { + Expect(item.Name).NotTo(BeEmpty()) + } + }) +}) + +// Valid: range without Expect in body (no assertions) +var _ = Describe("Test", func() { + It("processes items", func() { + list := getList() + for _, item := range list.Items { + _ = item.Name + } + }) +}) + +// Valid: range over non-.Items field +var _ = Describe("Test", func() { + It("checks names", func() { + names := []string{"a", "b"} + for _, name := range names { + Expect(name).NotTo(BeEmpty()) + } + }) +}) + +// Valid: When block with proper assertion before range +var _ = Describe("Test", func() { + When("condition is met", func() { + It("checks items", func() { + list := getList() + Expect(list.Items).NotTo(BeEmpty()) + for _, item := range list.Items { + Expect(item.Name).NotTo(BeEmpty()) + } + }) + }) +}) + +// Valid: using ShouldNot matcher +var _ = Describe("Test", func() { + It("checks items with ShouldNot", func() { + list := getList() + Expect(list.Items).ShouldNot(BeEmpty()) + for _, item := range list.Items { + Expect(item.Name).NotTo(BeEmpty()) + } + }) +}) + +// Valid: using ToNot matcher +var _ = Describe("Test", func() { + It("checks items with ToNot", func() { + list := getList() + Expect(list.Items).ToNot(BeEmpty()) + for _, item := range list.Items { + Expect(item.Name).NotTo(BeEmpty()) + } + }) +}) + +// Valid: using HaveLen assertion before range +var _ = Describe("Test", func() { + It("checks items with HaveLen", func() { + list := getList() + Expect(list.Items).To(HaveLen(3)) + for _, item := range list.Items { + Expect(item.Name).NotTo(BeEmpty()) + } + }) +}) + +// Valid: using BeNumerically with len() before range +var _ = Describe("Test", func() { + It("checks items with BeNumerically", func() { + list := getList() + Expect(len(list.Items)).To(BeNumerically(">", 0)) + for _, item := range list.Items { + Expect(item.Name).NotTo(BeEmpty()) + } + }) +}) + +// Valid: BeNumerically(">=", 1) proves a positive length +var _ = Describe("Test", func() { + It("checks items with BeNumerically at-least-one", func() { + list := getList() + Expect(len(list.Items)).To(BeNumerically(">=", 1)) + for _, item := range list.Items { + Expect(item.Name).NotTo(BeEmpty()) + } + }) +}) + +// Valid: BeNumerically("==", 3) proves a positive length +var _ = Describe("Test", func() { + It("checks items with BeNumerically equal-three", func() { + list := getList() + Expect(len(list.Items)).To(BeNumerically("==", 3)) + for _, item := range list.Items { + Expect(item.Name).NotTo(BeEmpty()) + } + }) +}) + +// Valid: deep nesting — BeforeEach at Describe level covers It in Context > When > It +var _ = Describe("Test", func() { + BeforeEach(func() { + list := getList() + Expect(list.Items).NotTo(BeEmpty()) + }) + + Context("level 1", func() { + When("level 2", func() { + It("deeply nested item check", func() { + list := getList() + for _, item := range list.Items { + Expect(item.Name).NotTo(BeEmpty()) + } + }) + }) + }) +}) + +// Test helpers +type ItemList struct { + Items []Item +} + +type Item struct { + Name string +} + +func Describe(name string, f func()) bool { return true } +func Context(name string, f func()) {} +func When(name string, f func()) {} +func It(name string, f func()) {} +func BeforeEach(f func()) {} +func Expect(val interface{}) Assertion { return Assertion{} } + +type Assertion struct{} + +func (a Assertion) NotTo(matcher Matcher) {} +func (a Assertion) ShouldNot(matcher Matcher) {} +func (a Assertion) ToNot(matcher Matcher) {} +func (a Assertion) To(matcher Matcher) {} +func (a Assertion) Should(matcher Matcher) {} + +type Matcher struct{} + +func BeEmpty() Matcher { return Matcher{} } +func HaveLen(count int) Matcher { return Matcher{} } +func BeNumerically(comparator string, compareTo ...interface{}) Matcher { return Matcher{} } + +func getList() ItemList { + return ItemList{Items: []Item{{Name: "test"}}} +} diff --git a/hack/tools/hypershiftlinter/analyzers/vacuouspass/vacuouspass.go b/hack/tools/hypershiftlinter/analyzers/vacuouspass/vacuouspass.go new file mode 100644 index 000000000000..3a68c1bbcd07 --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/vacuouspass/vacuouspass.go @@ -0,0 +1,448 @@ +package vacuouspass + +import ( + "go/ast" + "go/token" + "slices" + "strconv" + + "github.com/openshift/hypershift/hack/tools/hypershiftlinter/analyzers/pathutil" + + "golang.org/x/tools/go/analysis" +) + +var Analyzer = &analysis.Analyzer{ + Name: "vacuouspass", + Doc: "detects range over .Items without preceding non-empty assertion (vacuous pass)", + Run: run, +} + +var ginkgoContainers = map[string]bool{ + "Describe": true, "Context": true, "When": true, +} + +var ginkgoAll = map[string]bool{ + "Describe": true, "Context": true, "When": true, + "It": true, "BeforeEach": true, +} + +func run(pass *analysis.Pass) (any, error) { + for _, file := range pass.Files { + filename := pass.Fset.File(file.Pos()).Name() + if !pathutil.IsV2E2ETest(filename) { + continue + } + + for _, decl := range file.Decls { + switch d := decl.(type) { + case *ast.FuncDecl: + if d.Body != nil { + walkGinkgoBlock(pass, d.Body, nil) + } + case *ast.GenDecl: + for _, spec := range d.Specs { + vs, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + for _, val := range vs.Values { + call, ok := val.(*ast.CallExpr) + if !ok { + continue + } + if isGinkgoCall(call, ginkgoContainers) { + body := getClosureBody(call) + if body != nil { + walkGinkgoBlock(pass, body, nil) + } + } + } + } + } + } + } + return nil, nil +} + +// walkGinkgoBlock walks a block that may contain Ginkgo containers and leaves. +// It collects BeforeEach assertions and passes them to child It blocks. +// For non-Ginkgo statements, it checks range loops directly. +func walkGinkgoBlock(pass *analysis.Pass, body *ast.BlockStmt, beforeEachAssertions []string) { + localAssertions := collectBeforeEachAssertions(body) + merged := append(beforeEachAssertions, localAssertions...) + + for _, stmt := range body.List { + call := extractCallByName(stmt, ginkgoAll) + if call != nil { + name := callName(call) + closureBody := getClosureBody(call) + if closureBody == nil { + continue + } + if ginkgoContainers[name] { + walkGinkgoBlock(pass, closureBody, merged) + } else if name == "It" { + checkBlockForVacuousPass(pass, closureBody, merged) + } + continue + } + + // For non-Ginkgo statements in function bodies (e.g., helper functions + // that contain range loops), check them directly. + checkStmtForVacuousPass(pass, stmt, body, merged) + } +} + +// checkBlockForVacuousPass checks all range-over-.Items loops in a block. +func checkBlockForVacuousPass(pass *analysis.Pass, body *ast.BlockStmt, beforeEachAssertions []string) { + for i, stmt := range body.List { + rangeStmt, ok := stmt.(*ast.RangeStmt) + if !ok { + continue + } + + sel, ok := rangeStmt.X.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "Items" { + continue + } + + if !bodyContainsExpect(rangeStmt.Body) { + continue + } + + if hasBeEmptyAssertionBefore(body.List[:i], sel) { + continue + } + + targetStr := nodeString(sel) + if containsString(beforeEachAssertions, targetStr) { + continue + } + + pass.Report(analysis.Diagnostic{ + Pos: rangeStmt.Pos(), + Message: "range over .Items without preceding non-empty assertion — add Expect(x.Items).NotTo(BeEmpty()) before the loop", + }) + } +} + +// checkStmtForVacuousPass checks a single non-Ginkgo statement for range loops. +func checkStmtForVacuousPass(pass *analysis.Pass, stmt ast.Stmt, parent *ast.BlockStmt, beforeEachAssertions []string) { + rangeStmt, ok := stmt.(*ast.RangeStmt) + if !ok { + return + } + + sel, ok := rangeStmt.X.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "Items" { + return + } + + if !bodyContainsExpect(rangeStmt.Body) { + return + } + + idx := stmtIndex(parent, stmt) + if idx > 0 && hasBeEmptyAssertionBefore(parent.List[:idx], sel) { + return + } + + targetStr := nodeString(sel) + if containsString(beforeEachAssertions, targetStr) { + return + } + + pass.Report(analysis.Diagnostic{ + Pos: rangeStmt.Pos(), + Message: "range over .Items without preceding non-empty assertion — add Expect(x.Items).NotTo(BeEmpty()) before the loop", + }) +} + +func stmtIndex(block *ast.BlockStmt, target ast.Stmt) int { + for i, s := range block.List { + if s == target { + return i + } + } + return -1 +} + +func isGinkgoCall(call *ast.CallExpr, names map[string]bool) bool { + ident, ok := call.Fun.(*ast.Ident) + return ok && names[ident.Name] +} + +func callName(call *ast.CallExpr) string { + if ident, ok := call.Fun.(*ast.Ident); ok { + return ident.Name + } + return "" +} + +func extractCallByName(stmt ast.Stmt, names map[string]bool) *ast.CallExpr { + exprStmt, ok := stmt.(*ast.ExprStmt) + if !ok { + return nil + } + call, ok := exprStmt.X.(*ast.CallExpr) + if !ok { + return nil + } + if isGinkgoCall(call, names) { + return call + } + return nil +} + +// getClosureBody returns the body of the last FuncLit argument in a call. +func getClosureBody(call *ast.CallExpr) *ast.BlockStmt { + for i := len(call.Args) - 1; i >= 0; i-- { + if fn, ok := call.Args[i].(*ast.FuncLit); ok { + return fn.Body + } + } + return nil +} + +// collectBeforeEachAssertions finds all BeforeEach calls in a block and +// returns the target strings of any non-empty guard assertions. +func collectBeforeEachAssertions(body *ast.BlockStmt) []string { + var targets []string + for _, stmt := range body.List { + exprStmt, ok := stmt.(*ast.ExprStmt) + if !ok { + continue + } + call, ok := exprStmt.X.(*ast.CallExpr) + if !ok { + continue + } + ident, ok := call.Fun.(*ast.Ident) + if !ok || ident.Name != "BeforeEach" { + continue + } + closureBody := getClosureBody(call) + if closureBody == nil { + continue + } + ast.Inspect(closureBody, func(n ast.Node) bool { + c, ok := n.(*ast.CallExpr) + if !ok { + return true + } + target := extractNonEmptyGuardTarget(c) + if target != "" { + targets = append(targets, target) + } + return true + }) + } + return targets +} + +// extractNonEmptyGuardTarget returns the target string if call is a +// non-empty assertion for .Items, otherwise "". +// +// Recognized patterns (only when the matcher literally proves length > 0): +// - Expect(target).NotTo(BeEmpty()) (and ShouldNot / ToNot) +// - Expect(target).To(HaveLen(n)) (n a positive integer literal) +// - Expect(len(target)).To(BeNumerically(op, n)) where op/n prove length > 0 +// +// Matchers that permit an empty collection — e.g. HaveLen(0), +// BeNumerically("<", 1), BeNumerically("==", 0) — are intentionally NOT treated +// as non-empty guards, otherwise a range over the collection would still be a +// vacuous pass. +func extractNonEmptyGuardTarget(call *ast.CallExpr) string { + var ( + hasExpect bool + hasNegation bool // NotTo, ShouldNot, ToNot + hasPositive bool // To, Should + hasBeEmpty bool + provesNonEmpty bool // HaveLen/BeNumerically matcher that proves length > 0 + target string + ) + + ast.Inspect(call, func(n ast.Node) bool { + c, ok := n.(*ast.CallExpr) + if !ok { + return true + } + switch fn := c.Fun.(type) { + case *ast.Ident: + if fn.Name == "Expect" && len(c.Args) > 0 { + hasExpect = true + // Handle Expect(len(x.Items)) — unwrap the len() call + if lenCall, ok := c.Args[0].(*ast.CallExpr); ok { + if lenIdent, ok := lenCall.Fun.(*ast.Ident); ok && lenIdent.Name == "len" && len(lenCall.Args) > 0 { + target = nodeString(lenCall.Args[0]) + return true + } + } + target = nodeString(c.Args[0]) + } + if fn.Name == "BeEmpty" { + hasBeEmpty = true + } + if fn.Name == "HaveLen" && haveLenProvesNonEmpty(c) { + provesNonEmpty = true + } + if fn.Name == "BeNumerically" && beNumericallyProvesNonEmpty(c) { + provesNonEmpty = true + } + case *ast.SelectorExpr: + switch fn.Sel.Name { + case "NotTo", "ShouldNot", "ToNot": + hasNegation = true + case "To", "Should": + hasPositive = true + } + } + return true + }) + + if !hasExpect || target == "" { + return "" + } + + // Expect(x).NotTo(BeEmpty()) / ShouldNot / ToNot + if hasNegation && hasBeEmpty { + return target + } + // Expect(x).To(HaveLen(n>0)) or Expect(len(x)).To(BeNumerically(op, n)) that + // proves a positive length. + if hasPositive && provesNonEmpty { + return target + } + + return "" +} + +// haveLenProvesNonEmpty reports whether a HaveLen(...) matcher call proves the +// collection has a positive length, i.e. the argument is a positive integer +// literal. HaveLen(0) does not qualify. +func haveLenProvesNonEmpty(call *ast.CallExpr) bool { + if len(call.Args) != 1 { + return false + } + n, ok := intLiteralValue(call.Args[0]) + return ok && n > 0 +} + +// beNumericallyProvesNonEmpty reports whether a BeNumerically(op, n) matcher +// call proves a positive length. Only comparisons with integer-literal bounds +// that guarantee length > 0 qualify: +// +// ">", 0 (or any n >= 0) -> length strictly greater than n >= 0 +// ">=", 1 (or any n >= 1) -> length at least n >= 1 +// "==", n with n >= 1 -> length exactly n >= 1 +func beNumericallyProvesNonEmpty(call *ast.CallExpr) bool { + if len(call.Args) != 2 { + return false + } + op, ok := stringLiteralValue(call.Args[0]) + if !ok { + return false + } + n, ok := intLiteralValue(call.Args[1]) + if !ok { + return false + } + switch op { + case ">": + return n >= 0 + case ">=": + return n >= 1 + case "==": + return n >= 1 + default: + return false + } +} + +// intLiteralValue returns the integer value of an integer BasicLit expression. +func intLiteralValue(expr ast.Expr) (int, bool) { + lit, ok := expr.(*ast.BasicLit) + if !ok || lit.Kind != token.INT { + return 0, false + } + v, err := strconv.Atoi(lit.Value) + if err != nil { + return 0, false + } + return v, true +} + +// stringLiteralValue returns the unquoted value of a string BasicLit expression. +func stringLiteralValue(expr ast.Expr) (string, bool) { + lit, ok := expr.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return "", false + } + v, err := strconv.Unquote(lit.Value) + if err != nil { + return "", false + } + return v, true +} + +func hasBeEmptyAssertionBefore(stmts []ast.Stmt, target *ast.SelectorExpr) bool { + targetStr := nodeString(target) + for _, stmt := range stmts { + found := false + ast.Inspect(stmt, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + if extractNonEmptyGuardTarget(call) == targetStr { + found = true + return false + } + return true + }) + if found { + return true + } + } + return false +} + +func bodyContainsExpect(body *ast.BlockStmt) bool { + if body == nil { + return false + } + found := false + ast.Inspect(body, func(n ast.Node) bool { + if found { + return false + } + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + if ident, ok := call.Fun.(*ast.Ident); ok && ident.Name == "Expect" { + found = true + return false + } + return true + }) + return found +} + +func nodeString(n ast.Node) string { + switch x := n.(type) { + case *ast.Ident: + return x.Name + case *ast.SelectorExpr: + base := nodeString(x.X) + if base != "" { + return base + "." + x.Sel.Name + } + return x.Sel.Name + } + return "" +} + +func containsString(slice []string, s string) bool { + return slices.Contains(slice, s) +} diff --git a/hack/tools/hypershiftlinter/analyzers/vacuouspass/vacuouspass_test.go b/hack/tools/hypershiftlinter/analyzers/vacuouspass/vacuouspass_test.go new file mode 100644 index 000000000000..7196adc1e741 --- /dev/null +++ b/hack/tools/hypershiftlinter/analyzers/vacuouspass/vacuouspass_test.go @@ -0,0 +1,12 @@ +package vacuouspass + +import ( + "testing" + + "golang.org/x/tools/go/analysis/analysistest" +) + +func TestAnalyzer(t *testing.T) { + testdata := analysistest.TestData() + analysistest.Run(t, testdata, Analyzer, "test/e2e/v2/good", "test/e2e/v2/bad") +} From bbc3ebfa58cf57575963da7d91bea46bfb105669 Mon Sep 17 00:00:00 2001 From: Bryan Cox Date: Tue, 11 Aug 2026 15:22:52 -0400 Subject: [PATCH 4/6] ci: add GHA workflows for hypershiftlinter tests Co-Authored-By: Claude Opus 4.6 --- .github/workflows/lint-reusable.yaml | 12 ++++-------- .github/workflows/test-linter-reusable.yaml | 18 ++++++++++++++++++ .github/workflows/test-linter.yaml | 21 +++++++++++++++++++++ 3 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/test-linter-reusable.yaml create mode 100644 .github/workflows/test-linter.yaml diff --git a/.github/workflows/lint-reusable.yaml b/.github/workflows/lint-reusable.yaml index 983c2ae4412f..b2673d70f0e4 100644 --- a/.github/workflows/lint-reusable.yaml +++ b/.github/workflows/lint-reusable.yaml @@ -20,12 +20,8 @@ jobs: if [ -n "${{ github.base_ref }}" ]; then git fetch origin "${{ github.base_ref }}:${{ github.base_ref }}" fi - - name: Use pre-built lint tools - run: | - if [ -d /opt/lint-tools ]; then - mkdir -p hack/tools/bin - cp /opt/lint-tools/golangci-lint hack/tools/bin/ - cp /opt/lint-tools/kube-api-linter.so hack/tools/bin/ - touch hack/tools/bin/golangci-lint hack/tools/bin/kube-api-linter.so - fi + - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: hack/tools/bin + key: lint-tools-${{ hashFiles('Makefile', 'hack/tools/go.mod', 'hack/tools/go.sum', 'hack/tools/hypershiftlinter/**/*.go') }} - run: make lint diff --git a/.github/workflows/test-linter-reusable.yaml b/.github/workflows/test-linter-reusable.yaml new file mode 100644 index 000000000000..fa33a272d32a --- /dev/null +++ b/.github/workflows/test-linter-reusable.yaml @@ -0,0 +1,18 @@ +name: Unit Tests (HyperShift Linter) (Reusable) + +on: + workflow_call: + +permissions: + contents: read + +jobs: + test-linter: + name: HyperShift Linter + runs-on: arc-runner-set + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: make test-linter diff --git a/.github/workflows/test-linter.yaml b/.github/workflows/test-linter.yaml new file mode 100644 index 000000000000..82a3627213bd --- /dev/null +++ b/.github/workflows/test-linter.yaml @@ -0,0 +1,21 @@ +name: Unit Tests (HyperShift Linter) + +on: + pull_request: + branches: + - main + - release-4.22 + paths: + - 'hack/tools/hypershiftlinter/**' + - 'hack/tools/go.mod' + - 'hack/tools/go.sum' + - 'hack/tools/vendor/**' + - 'Makefile' + - '.github/workflows/test-linter.yaml' + - '.github/workflows/test-linter-reusable.yaml' + +jobs: + test-linter: + uses: openshift/hypershift/.github/workflows/test-linter-reusable.yaml@main + permissions: + contents: read From bf691b558548ae55538d3eb54745162d7f1fd750 Mon Sep 17 00:00:00 2001 From: Bryan Cox Date: Thu, 13 Aug 2026 10:52:36 -0400 Subject: [PATCH 5/6] fix(hack/tools): tighten testcasename map detection and exempt AfterSuite Address review feedback on the hypershiftlinter analyzers: - testcasename: looksLikeTestCaseMap now requires the value struct to declare at least one recognized test field, matching the corroboration looksLikeTestCaseStruct already applies. Plain lookup/fixture maps such as map[string]struct{ Addr string }{...} are no longer flagged. - contextbackground: exempt AfterSuite alongside BeforeSuite and the Synchronized*Suite hooks, since it is the same suite-level teardown hook with no per-spec context. Add testdata fixtures covering both cases. Signed-off-by: Bryan Cox Commit-Message-Assisted-by: Claude (via Claude Code) --- .../contextbackground/contextbackground.go | 8 +++- .../testdata/src/test/e2e/v2/bad/bad_test.go | 30 ++++++++++-- .../src/test/e2e/v2/good/good_test.go | 34 ++++++------- .../analyzers/testcasename/testcasename.go | 48 ++++++++++++++----- .../testdata/src/a/good/good_test.go | 13 +++++ 5 files changed, 100 insertions(+), 33 deletions(-) diff --git a/hack/tools/hypershiftlinter/analyzers/contextbackground/contextbackground.go b/hack/tools/hypershiftlinter/analyzers/contextbackground/contextbackground.go index 867b57d4bc50..32874a098894 100644 --- a/hack/tools/hypershiftlinter/analyzers/contextbackground/contextbackground.go +++ b/hack/tools/hypershiftlinter/analyzers/contextbackground/contextbackground.go @@ -70,7 +70,13 @@ func isInsideExemptFunc(file *ast.File, target *ast.CallExpr) bool { return true } name := callName(call) - if name == "BeforeSuite" || name == "DeferCleanup" || name == "SynchronizedBeforeSuite" || name == "SynchronizedAfterSuite" { + // Only suite-level setup/teardown hooks are exempt: they run before + // TestContext is initialized (or after it would be meaningful), so + // context.Background() is the correct choice there. DeferCleanup is NOT + // exempt — per test/e2e/v2/AGENTS.md, TestContext.Context is initialized + // once in BeforeSuite and is not canceled during cleanup, so cleanup + // callbacks that have access to tc must use tc.Context. + if name == "BeforeSuite" || name == "AfterSuite" || name == "SynchronizedBeforeSuite" || name == "SynchronizedAfterSuite" { for _, arg := range call.Args { if containsNode(arg, target) { exempt = true diff --git a/hack/tools/hypershiftlinter/analyzers/contextbackground/testdata/src/test/e2e/v2/bad/bad_test.go b/hack/tools/hypershiftlinter/analyzers/contextbackground/testdata/src/test/e2e/v2/bad/bad_test.go index dd2ff4ca1ed1..4aa8b77f3115 100644 --- a/hack/tools/hypershiftlinter/analyzers/contextbackground/testdata/src/test/e2e/v2/bad/bad_test.go +++ b/hack/tools/hypershiftlinter/analyzers/contextbackground/testdata/src/test/e2e/v2/bad/bad_test.go @@ -41,7 +41,7 @@ func TestTODOInItBlock(t *testing.T) { }) } -// Invalid: context.Background() in BeforeEach (not exempt, only BeforeSuite and DeferCleanup are) +// Invalid: context.Background() in BeforeEach (not exempt, only suite-level hooks are) func TestBeforeEachNotExempt(t *testing.T) { BeforeEach(func() { ctx := context.Background() // want `use tc\.Context instead of context\.Background\(\)/context\.TODO\(\)` @@ -49,7 +49,29 @@ func TestBeforeEachNotExempt(t *testing.T) { }) } +// Invalid: context.Background() in DeferCleanup is NOT exempt — TestContext.Context +// is initialized once in BeforeSuite and not canceled during cleanup, so cleanup +// callbacks must use tc.Context. +func TestDeferCleanupNotExempt(t *testing.T) { + DeferCleanup(func() { + ctx := context.Background() // want `use tc\.Context instead of context\.Background\(\)/context\.TODO\(\)` + _ = ctx + }) +} + +// Invalid: context.TODO() in DeferCleanup where tc is directly available. +func TestDeferCleanupWithTC(t *testing.T) { + It("does something", func() { + DeferCleanup(func() { + ctx := context.TODO() // want `use tc\.Context instead of context\.Background\(\)/context\.TODO\(\)` + cleanup(ctx) + }) + }) +} + // Test helpers -func It(desc string, f func()) {} -func AfterEach(f func()) {} -func BeforeEach(f func()) {} +func It(desc string, f func()) {} +func AfterEach(f func()) {} +func BeforeEach(f func()) {} +func DeferCleanup(f func()) {} +func cleanup(ctx context.Context) {} diff --git a/hack/tools/hypershiftlinter/analyzers/contextbackground/testdata/src/test/e2e/v2/good/good_test.go b/hack/tools/hypershiftlinter/analyzers/contextbackground/testdata/src/test/e2e/v2/good/good_test.go index 402dd049a7af..340ac8402973 100644 --- a/hack/tools/hypershiftlinter/analyzers/contextbackground/testdata/src/test/e2e/v2/good/good_test.go +++ b/hack/tools/hypershiftlinter/analyzers/contextbackground/testdata/src/test/e2e/v2/good/good_test.go @@ -13,14 +13,6 @@ func TestBeforeSuiteExempt(t *testing.T) { }) } -// Valid: context.Background() inside DeferCleanup is exempt -func TestDeferCleanupExempt(t *testing.T) { - DeferCleanup(func() { - ctx := context.Background() - _ = ctx - }) -} - // Valid: nested BeforeSuite with context.Background() func TestNestedBeforeSuite(t *testing.T) { Describe("suite", func() { @@ -31,14 +23,6 @@ func TestNestedBeforeSuite(t *testing.T) { }) } -// Valid: DeferCleanup in cleanup chain -func TestDeferCleanupChain(t *testing.T) { - DeferCleanup(func() { - ctx := context.Background() - cleanup(ctx) - }) -} - // Valid: multiple context.Background() calls in BeforeSuite func TestMultipleBackgroundInBeforeSuite(t *testing.T) { BeforeSuite(func() { @@ -65,9 +49,25 @@ func TestTODOInBeforeSuiteExempt(t *testing.T) { }) } +// Valid: context.Background() inside AfterSuite is exempt +func TestAfterSuiteExempt(t *testing.T) { + AfterSuite(func() { + ctx := context.Background() + cleanup(ctx) + }) +} + +// Valid: context.Background() inside SynchronizedAfterSuite is exempt +func TestSynchronizedAfterSuiteExempt(t *testing.T) { + SynchronizedAfterSuite(func() { + ctx := context.Background() + cleanup(ctx) + }) +} + // Test helpers func BeforeSuite(f func()) {} -func DeferCleanup(f func()) {} +func AfterSuite(f func()) {} func SynchronizedBeforeSuite(f ...func()) {} func SynchronizedAfterSuite(f ...func()) {} func Describe(name string, f func()) {} diff --git a/hack/tools/hypershiftlinter/analyzers/testcasename/testcasename.go b/hack/tools/hypershiftlinter/analyzers/testcasename/testcasename.go index 093b7d7eb2f8..bbb1958c2daa 100644 --- a/hack/tools/hypershiftlinter/analyzers/testcasename/testcasename.go +++ b/hack/tools/hypershiftlinter/analyzers/testcasename/testcasename.go @@ -130,12 +130,7 @@ func looksLikeTestCaseStruct(comp *ast.CompositeLit) bool { hasName = true } - testFieldNames := []string{ - "want", "expected", "expectError", "expectErr", "wantErr", - "args", "input", "output", "result", "fields", "setup", - "assertion", "validate", "check", - } - if slices.Contains(testFieldNames, ident.Name) { + if isTestFieldName(ident.Name) { hasTestField = true } @@ -148,8 +143,11 @@ func looksLikeTestCaseStruct(comp *ast.CompositeLit) bool { } // looksLikeTestCaseMap returns true if the composite literal is a -// map[string]struct{...}{...} — a common pattern for map-based test tables -// where the map keys serve as test case names. +// map[string]struct{...}{...} whose value struct declares at least one +// recognized test field — a common pattern for map-based test tables where the +// map keys serve as test case names. Requiring a recognized test field avoids +// flagging plain lookup/fixture maps such as +// map[string]struct{ Addr string }{...}. func looksLikeTestCaseMap(comp *ast.CompositeLit) bool { mt, ok := comp.Type.(*ast.MapType) if !ok { @@ -162,10 +160,38 @@ func looksLikeTestCaseMap(comp *ast.CompositeLit) bool { return false } - // Value type must be a struct. - if _, ok := mt.Value.(*ast.StructType); !ok { + // Value type must be a struct with at least one recognized test field. + st, ok := mt.Value.(*ast.StructType) + if !ok { + return false + } + + return structHasTestField(st) +} + +// structHasTestField reports whether the struct type declares at least one +// field whose name is a recognized test field. +func structHasTestField(st *ast.StructType) bool { + if st.Fields == nil { return false } + for _, field := range st.Fields.List { + for _, name := range field.Names { + if isTestFieldName(name.Name) { + return true + } + } + } + return false +} - return true +// isTestFieldName reports whether name is one of the recognized field names +// that signal a struct is a test case rather than an arbitrary data struct. +func isTestFieldName(name string) bool { + testFieldNames := []string{ + "want", "expected", "expectError", "expectErr", "wantErr", + "args", "input", "output", "result", "fields", "setup", + "assertion", "validate", "check", + } + return slices.Contains(testFieldNames, name) } diff --git a/hack/tools/hypershiftlinter/analyzers/testcasename/testdata/src/a/good/good_test.go b/hack/tools/hypershiftlinter/analyzers/testcasename/testdata/src/a/good/good_test.go index c80e6da2a270..7ccbfd661837 100644 --- a/hack/tools/hypershiftlinter/analyzers/testcasename/testdata/src/a/good/good_test.go +++ b/hack/tools/hypershiftlinter/analyzers/testcasename/testdata/src/a/good/good_test.go @@ -120,6 +120,19 @@ func TestMapBasedGoodNames(t *testing.T) { } } +func TestPlainLookupMap(t *testing.T) { + // This map[string]struct{...} has no recognized test field in its value + // struct, so it is a plain lookup/fixture map, not a test-case table. + // Its keys must not be held to the "When ... it should ..." format. + fixtures := map[string]struct { + Addr string + }{ + "primary": {Addr: "10.0.0.1"}, + "secondary": {Addr: "10.0.0.2"}, + } + _ = fixtures +} + func TestNameFromVariable(t *testing.T) { testName := "some dynamic name" tests := []struct { From 91e306830ce3bb29c37bc892064760951bef8a6a Mon Sep 17 00:00:00 2001 From: Bryan Cox Date: Thu, 13 Aug 2026 10:55:34 -0400 Subject: [PATCH 6/6] docs(hack/tools): add README explaining the hypershiftlinter plugin Document why the plugin exists (machine-enforced test conventions), the 7 analyzers and their scopes, how the plugin is built and run, and the intentionally staged rollout that lands the plugin and its tests before enforcement is enabled. Signed-off-by: Bryan Cox Commit-Message-Assisted-by: Claude (via Claude Code) --- hack/tools/hypershiftlinter/README.md | 81 +++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 hack/tools/hypershiftlinter/README.md diff --git a/hack/tools/hypershiftlinter/README.md b/hack/tools/hypershiftlinter/README.md new file mode 100644 index 000000000000..03910e1cb68a --- /dev/null +++ b/hack/tools/hypershiftlinter/README.md @@ -0,0 +1,81 @@ +# hypershiftlinter + +`hypershiftlinter` is a custom [golangci-lint](https://golangci-lint.run/) plugin +that automatically enforces HyperShift's testing conventions through static +analysis. + +## Why this exists + +We already document our testing conventions in +[`TESTING.md`](../../../TESTING.md) and +[`test/e2e/v2/AGENTS.md`](../../../test/e2e/v2/AGENTS.md), but until now nothing +enforced them. Conventions that live only in docs get followed inconsistently — +reviewers have to catch violations by hand, and many slip through. + +This plugin turns those conventions into machine-enforced checks instead of +relying on reviewer memory. That matters for several reasons: + +1. **Machine-enforced consistency.** Conventions become automated checks rather + than tribal knowledge. This is what caught real issues in review — for + example, tests that silently skipped ~60 lines of assertions because guard + strings no longer matched renamed test cases, and vacuously-passing tests. +2. **Better test quality and reliability.** The `vacuouspass` analyzer catches + tests that pass without actually asserting anything, a common source of false + confidence in a test suite. +3. **Cleaner Sippy/CI signal.** Enforcing correct + `[sig-hypershift][Jira:Hypershift]` and `[Feature:X]` annotations keeps our + e2e results properly categorized in Sippy. +4. **Lower review burden.** Reviewers spend less time on mechanical naming and + convention nits and more on substance. + +## Analyzers + +The plugin ships 7 analyzers, scoped so each rule only fires where it applies. + +### Unit test conventions (`TESTING.md`, unit tests only) + +| Analyzer | Enforces | +| -------------- | ----------------------------------------------------------------------------------------- | +| `testcasename` | Test case name fields match `When , it should `. | +| `testfuncname` | Test functions do not use the `Test_` prefix; use `TestFunctionName` instead. | + +### E2E conventions (`test/e2e/v2/` only) + +| Analyzer | Enforces | +| ------------------- | ------------------------------------------------------------------------------------------------ | +| `guestcluster` | Bans "guest cluster" terminology; use "hosted cluster" instead. | +| `contextbackground` | Bans `context.Background()` / `context.TODO()` in tests; use `tc.Context` instead. | +| `vacuouspass` | Flags vacuously-passing tests that iterate a collection without asserting it is non-empty. | +| `ipv6url` | Detects `fmt.Sprintf` URL patterns that break with IPv6; use `net.JoinHostPort` instead. | +| `sippyannotation` | Requires the correct Sippy/Jira `[Feature:X]` annotations on Ginkgo `Describe` blocks. | + +## How it's built and run + +The plugin builds as a Go shared library (`.so`) via +`go build -buildmode=plugin`. The plugin and the golangci-lint host binary must +be compiled from the same `hack/tools/go.mod` — a `golang.org/x/tools` version +mismatch causes `plugin.Open()` to fail at runtime. + +Relevant Makefile targets: + +- `make hypershiftlinter.so` — build the plugin shared library. +- `make hypershift-lint-all` — opt-in target to run the analyzers against the + current tree. +- `make test-linter` — run the analyzers' own unit tests + (`go test ./hypershiftlinter/analyzers/...`). + +Each analyzer has [`analysistest`](https://pkg.go.dev/golang.org/x/tools/go/analysis/analysistest)-based +unit tests with good/bad `testdata/` fixtures. + +## Staged rollout + +Enablement is intentionally staged. The initial change lands the plugin, the +analyzers, and their unit tests only — **it does not enable enforcement**. A +follow-up wires the plugin into `.golangci.yml` / `make lint` and fixes the +existing violations in the tree. + +Splitting it this way keeps the review surface small and lets CI actually run the +analyzers' own tests before enforcement is turned on. (A brand-new reusable +workflow can't get a green pre-merge run on the PR that introduces it, because +GitHub resolves `uses: ...@main` and the `pull_request` trigger from the base +branch — so the foundational plumbing has to land on `main` first.)