golang.org/x/tools/gopls@v0.15.3/internal/mod/diagnostics.go (about)

     1  // Copyright 2019 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  // Package mod provides core features related to go.mod file
     6  // handling for use by Go editors and tools.
     7  package mod
     8  
     9  import (
    10  	"context"
    11  	"fmt"
    12  	"runtime"
    13  	"sort"
    14  	"strings"
    15  	"sync"
    16  
    17  	"golang.org/x/mod/modfile"
    18  	"golang.org/x/mod/semver"
    19  	"golang.org/x/sync/errgroup"
    20  	"golang.org/x/tools/gopls/internal/cache"
    21  	"golang.org/x/tools/gopls/internal/file"
    22  	"golang.org/x/tools/gopls/internal/protocol"
    23  	"golang.org/x/tools/gopls/internal/protocol/command"
    24  	"golang.org/x/tools/gopls/internal/settings"
    25  	"golang.org/x/tools/gopls/internal/vulncheck/govulncheck"
    26  	"golang.org/x/tools/internal/event"
    27  )
    28  
    29  // ParseDiagnostics returns diagnostics from parsing the go.mod files in the workspace.
    30  func ParseDiagnostics(ctx context.Context, snapshot *cache.Snapshot) (map[protocol.DocumentURI][]*cache.Diagnostic, error) {
    31  	ctx, done := event.Start(ctx, "mod.Diagnostics", snapshot.Labels()...)
    32  	defer done()
    33  
    34  	return collectDiagnostics(ctx, snapshot, ModParseDiagnostics)
    35  }
    36  
    37  // Diagnostics returns diagnostics from running go mod tidy.
    38  func TidyDiagnostics(ctx context.Context, snapshot *cache.Snapshot) (map[protocol.DocumentURI][]*cache.Diagnostic, error) {
    39  	ctx, done := event.Start(ctx, "mod.Diagnostics", snapshot.Labels()...)
    40  	defer done()
    41  
    42  	return collectDiagnostics(ctx, snapshot, ModTidyDiagnostics)
    43  }
    44  
    45  // UpgradeDiagnostics returns upgrade diagnostics for the modules in the
    46  // workspace with known upgrades.
    47  func UpgradeDiagnostics(ctx context.Context, snapshot *cache.Snapshot) (map[protocol.DocumentURI][]*cache.Diagnostic, error) {
    48  	ctx, done := event.Start(ctx, "mod.UpgradeDiagnostics", snapshot.Labels()...)
    49  	defer done()
    50  
    51  	return collectDiagnostics(ctx, snapshot, ModUpgradeDiagnostics)
    52  }
    53  
    54  // VulnerabilityDiagnostics returns vulnerability diagnostics for the active modules in the
    55  // workspace with known vulnerabilities.
    56  func VulnerabilityDiagnostics(ctx context.Context, snapshot *cache.Snapshot) (map[protocol.DocumentURI][]*cache.Diagnostic, error) {
    57  	ctx, done := event.Start(ctx, "mod.VulnerabilityDiagnostics", snapshot.Labels()...)
    58  	defer done()
    59  
    60  	return collectDiagnostics(ctx, snapshot, ModVulnerabilityDiagnostics)
    61  }
    62  
    63  func collectDiagnostics(ctx context.Context, snapshot *cache.Snapshot, diagFn func(context.Context, *cache.Snapshot, file.Handle) ([]*cache.Diagnostic, error)) (map[protocol.DocumentURI][]*cache.Diagnostic, error) {
    64  	g, ctx := errgroup.WithContext(ctx)
    65  	cpulimit := runtime.GOMAXPROCS(0)
    66  	g.SetLimit(cpulimit)
    67  
    68  	var mu sync.Mutex
    69  	reports := make(map[protocol.DocumentURI][]*cache.Diagnostic)
    70  
    71  	for _, uri := range snapshot.View().ModFiles() {
    72  		uri := uri
    73  		g.Go(func() error {
    74  			fh, err := snapshot.ReadFile(ctx, uri)
    75  			if err != nil {
    76  				return err
    77  			}
    78  			diagnostics, err := diagFn(ctx, snapshot, fh)
    79  			if err != nil {
    80  				return err
    81  			}
    82  			for _, d := range diagnostics {
    83  				mu.Lock()
    84  				reports[d.URI] = append(reports[fh.URI()], d)
    85  				mu.Unlock()
    86  			}
    87  			return nil
    88  		})
    89  	}
    90  
    91  	if err := g.Wait(); err != nil {
    92  		return nil, err
    93  	}
    94  	return reports, nil
    95  }
    96  
    97  // ModParseDiagnostics reports diagnostics from parsing the mod file.
    98  func ModParseDiagnostics(ctx context.Context, snapshot *cache.Snapshot, fh file.Handle) (diagnostics []*cache.Diagnostic, err error) {
    99  	pm, err := snapshot.ParseMod(ctx, fh)
   100  	if err != nil {
   101  		if pm == nil || len(pm.ParseErrors) == 0 {
   102  			return nil, err
   103  		}
   104  		return pm.ParseErrors, nil
   105  	}
   106  	return nil, nil
   107  }
   108  
   109  // ModTidyDiagnostics reports diagnostics from running go mod tidy.
   110  func ModTidyDiagnostics(ctx context.Context, snapshot *cache.Snapshot, fh file.Handle) ([]*cache.Diagnostic, error) {
   111  	pm, err := snapshot.ParseMod(ctx, fh) // memoized
   112  	if err != nil {
   113  		return nil, nil // errors reported by ModDiagnostics above
   114  	}
   115  
   116  	tidied, err := snapshot.ModTidy(ctx, pm)
   117  	if err != nil {
   118  		if err != cache.ErrNoModOnDisk {
   119  			// TODO(rfindley): the check for ErrNoModOnDisk was historically determined
   120  			// to be benign, but may date back to the time when the Go command did not
   121  			// have overlay support.
   122  			//
   123  			// See if we can pass the overlay to the Go command, and eliminate this guard..
   124  			event.Error(ctx, fmt.Sprintf("tidy: diagnosing %s", pm.URI), err)
   125  		}
   126  		return nil, nil
   127  	}
   128  	return tidied.Diagnostics, nil
   129  }
   130  
   131  // ModUpgradeDiagnostics adds upgrade quick fixes for individual modules if the upgrades
   132  // are recorded in the view.
   133  func ModUpgradeDiagnostics(ctx context.Context, snapshot *cache.Snapshot, fh file.Handle) (upgradeDiagnostics []*cache.Diagnostic, err error) {
   134  	pm, err := snapshot.ParseMod(ctx, fh)
   135  	if err != nil {
   136  		// Don't return an error if there are parse error diagnostics to be shown, but also do not
   137  		// continue since we won't be able to show the upgrade diagnostics.
   138  		if pm != nil && len(pm.ParseErrors) != 0 {
   139  			return nil, nil
   140  		}
   141  		return nil, err
   142  	}
   143  
   144  	upgrades := snapshot.ModuleUpgrades(fh.URI())
   145  	for _, req := range pm.File.Require {
   146  		ver, ok := upgrades[req.Mod.Path]
   147  		if !ok || req.Mod.Version == ver {
   148  			continue
   149  		}
   150  		rng, err := pm.Mapper.OffsetRange(req.Syntax.Start.Byte, req.Syntax.End.Byte)
   151  		if err != nil {
   152  			return nil, err
   153  		}
   154  		// Upgrade to the exact version we offer the user, not the most recent.
   155  		title := fmt.Sprintf("%s%v", upgradeCodeActionPrefix, ver)
   156  		cmd, err := command.NewUpgradeDependencyCommand(title, command.DependencyArgs{
   157  			URI:        fh.URI(),
   158  			AddRequire: false,
   159  			GoCmdArgs:  []string{req.Mod.Path + "@" + ver},
   160  		})
   161  		if err != nil {
   162  			return nil, err
   163  		}
   164  		upgradeDiagnostics = append(upgradeDiagnostics, &cache.Diagnostic{
   165  			URI:            fh.URI(),
   166  			Range:          rng,
   167  			Severity:       protocol.SeverityInformation,
   168  			Source:         cache.UpgradeNotification,
   169  			Message:        fmt.Sprintf("%v can be upgraded", req.Mod.Path),
   170  			SuggestedFixes: []cache.SuggestedFix{cache.SuggestedFixFromCommand(cmd, protocol.QuickFix)},
   171  		})
   172  	}
   173  
   174  	return upgradeDiagnostics, nil
   175  }
   176  
   177  const upgradeCodeActionPrefix = "Upgrade to "
   178  
   179  // ModVulnerabilityDiagnostics adds diagnostics for vulnerabilities in individual modules
   180  // if the vulnerability is recorded in the view.
   181  func ModVulnerabilityDiagnostics(ctx context.Context, snapshot *cache.Snapshot, fh file.Handle) (vulnDiagnostics []*cache.Diagnostic, err error) {
   182  	pm, err := snapshot.ParseMod(ctx, fh)
   183  	if err != nil {
   184  		// Don't return an error if there are parse error diagnostics to be shown, but also do not
   185  		// continue since we won't be able to show the vulnerability diagnostics.
   186  		if pm != nil && len(pm.ParseErrors) != 0 {
   187  			return nil, nil
   188  		}
   189  		return nil, err
   190  	}
   191  
   192  	diagSource := cache.Govulncheck
   193  	vs := snapshot.Vulnerabilities(fh.URI())[fh.URI()]
   194  	if vs == nil && snapshot.Options().Vulncheck == settings.ModeVulncheckImports {
   195  		vs, err = snapshot.ModVuln(ctx, fh.URI())
   196  		if err != nil {
   197  			return nil, err
   198  		}
   199  		diagSource = cache.Vulncheck
   200  	}
   201  	if vs == nil || len(vs.Findings) == 0 {
   202  		return nil, nil
   203  	}
   204  
   205  	suggestRunOrResetGovulncheck, err := suggestGovulncheckAction(diagSource == cache.Govulncheck, fh.URI())
   206  	if err != nil {
   207  		// must not happen
   208  		return nil, err // TODO: bug report
   209  	}
   210  	vulnsByModule := make(map[string][]*govulncheck.Finding)
   211  
   212  	for _, finding := range vs.Findings {
   213  		if vuln, typ := foundVuln(finding); typ == vulnCalled || typ == vulnImported {
   214  			vulnsByModule[vuln.Module] = append(vulnsByModule[vuln.Module], finding)
   215  		}
   216  	}
   217  	for _, req := range pm.File.Require {
   218  		mod := req.Mod.Path
   219  		findings := vulnsByModule[mod]
   220  		if len(findings) == 0 {
   221  			continue
   222  		}
   223  		// note: req.Syntax is the line corresponding to 'require', which means
   224  		// req.Syntax.Start can point to the beginning of the "require" keyword
   225  		// for a single line require (e.g. "require golang.org/x/mod v0.0.0").
   226  		start := req.Syntax.Start.Byte
   227  		if len(req.Syntax.Token) == 3 {
   228  			start += len("require ")
   229  		}
   230  		rng, err := pm.Mapper.OffsetRange(start, req.Syntax.End.Byte)
   231  		if err != nil {
   232  			return nil, err
   233  		}
   234  		// Map affecting vulns to 'warning' level diagnostics,
   235  		// others to 'info' level diagnostics.
   236  		// Fixes will include only the upgrades for warning level diagnostics.
   237  		var warningFixes, infoFixes []cache.SuggestedFix
   238  		var warningSet, infoSet = map[string]bool{}, map[string]bool{}
   239  		for _, finding := range findings {
   240  			// It is possible that the source code was changed since the last
   241  			// govulncheck run and information in the `vulns` info is stale.
   242  			// For example, imagine that a user is in the middle of updating
   243  			// problematic modules detected by the govulncheck run by applying
   244  			// quick fixes. Stale diagnostics can be confusing and prevent the
   245  			// user from quickly locating the next module to fix.
   246  			// Ideally we should rerun the analysis with the updated module
   247  			// dependencies or any other code changes, but we are not yet
   248  			// in the position of automatically triggering the analysis
   249  			// (govulncheck can take a while). We also don't know exactly what
   250  			// part of source code was changed since `vulns` was computed.
   251  			// As a heuristic, we assume that a user upgrades the affecting
   252  			// module to the version with the fix or the latest one, and if the
   253  			// version in the require statement is equal to or higher than the
   254  			// fixed version, skip generating a diagnostic about the vulnerability.
   255  			// Eventually, the user has to rerun govulncheck.
   256  			if finding.FixedVersion != "" && semver.IsValid(req.Mod.Version) && semver.Compare(finding.FixedVersion, req.Mod.Version) <= 0 {
   257  				continue
   258  			}
   259  			switch _, typ := foundVuln(finding); typ {
   260  			case vulnImported:
   261  				infoSet[finding.OSV] = true
   262  			case vulnCalled:
   263  				warningSet[finding.OSV] = true
   264  			}
   265  			// Upgrade to the exact version we offer the user, not the most recent.
   266  			if fixedVersion := finding.FixedVersion; semver.IsValid(fixedVersion) && semver.Compare(req.Mod.Version, fixedVersion) < 0 {
   267  				cmd, err := getUpgradeCodeAction(fh, req, fixedVersion)
   268  				if err != nil {
   269  					return nil, err // TODO: bug report
   270  				}
   271  				sf := cache.SuggestedFixFromCommand(cmd, protocol.QuickFix)
   272  				switch _, typ := foundVuln(finding); typ {
   273  				case vulnImported:
   274  					infoFixes = append(infoFixes, sf)
   275  				case vulnCalled:
   276  					warningFixes = append(warningFixes, sf)
   277  				}
   278  			}
   279  		}
   280  
   281  		if len(warningSet) == 0 && len(infoSet) == 0 {
   282  			continue
   283  		}
   284  		// Remove affecting osvs from the non-affecting osv list if any.
   285  		if len(warningSet) > 0 {
   286  			for k := range infoSet {
   287  				if warningSet[k] {
   288  					delete(infoSet, k)
   289  				}
   290  			}
   291  		}
   292  		// Add an upgrade for module@latest.
   293  		// TODO(suzmue): verify if latest is the same as fixedVersion.
   294  		latest, err := getUpgradeCodeAction(fh, req, "latest")
   295  		if err != nil {
   296  			return nil, err // TODO: bug report
   297  		}
   298  		sf := cache.SuggestedFixFromCommand(latest, protocol.QuickFix)
   299  		if len(warningFixes) > 0 {
   300  			warningFixes = append(warningFixes, sf)
   301  		}
   302  		if len(infoFixes) > 0 {
   303  			infoFixes = append(infoFixes, sf)
   304  		}
   305  		if len(warningSet) > 0 {
   306  			warning := sortedKeys(warningSet)
   307  			warningFixes = append(warningFixes, suggestRunOrResetGovulncheck)
   308  			vulnDiagnostics = append(vulnDiagnostics, &cache.Diagnostic{
   309  				URI:            fh.URI(),
   310  				Range:          rng,
   311  				Severity:       protocol.SeverityWarning,
   312  				Source:         diagSource,
   313  				Message:        getVulnMessage(req.Mod.Path, warning, true, diagSource == cache.Govulncheck),
   314  				SuggestedFixes: warningFixes,
   315  			})
   316  		}
   317  		if len(infoSet) > 0 {
   318  			info := sortedKeys(infoSet)
   319  			infoFixes = append(infoFixes, suggestRunOrResetGovulncheck)
   320  			vulnDiagnostics = append(vulnDiagnostics, &cache.Diagnostic{
   321  				URI:            fh.URI(),
   322  				Range:          rng,
   323  				Severity:       protocol.SeverityInformation,
   324  				Source:         diagSource,
   325  				Message:        getVulnMessage(req.Mod.Path, info, false, diagSource == cache.Govulncheck),
   326  				SuggestedFixes: infoFixes,
   327  			})
   328  		}
   329  	}
   330  
   331  	// TODO(hyangah): place this diagnostic on the `go` directive or `toolchain` directive
   332  	// after https://go.dev/issue/57001.
   333  	const diagnoseStdLib = false
   334  
   335  	// If diagnosing the stdlib, add standard library vulnerability diagnostics
   336  	// on the module declaration.
   337  	//
   338  	// Only proceed if we have a valid module declaration on which to position
   339  	// the diagnostics.
   340  	if diagnoseStdLib && pm.File.Module != nil && pm.File.Module.Syntax != nil {
   341  		// Add standard library vulnerabilities.
   342  		stdlibVulns := vulnsByModule["stdlib"]
   343  		if len(stdlibVulns) == 0 {
   344  			return vulnDiagnostics, nil
   345  		}
   346  
   347  		// Put the standard library diagnostic on the module declaration.
   348  		rng, err := pm.Mapper.OffsetRange(pm.File.Module.Syntax.Start.Byte, pm.File.Module.Syntax.End.Byte)
   349  		if err != nil {
   350  			return vulnDiagnostics, nil // TODO: bug report
   351  		}
   352  
   353  		var warningSet, infoSet = map[string]bool{}, map[string]bool{}
   354  		for _, finding := range stdlibVulns {
   355  			switch _, typ := foundVuln(finding); typ {
   356  			case vulnImported:
   357  				infoSet[finding.OSV] = true
   358  			case vulnCalled:
   359  				warningSet[finding.OSV] = true
   360  			}
   361  		}
   362  		if len(warningSet) > 0 {
   363  			warning := sortedKeys(warningSet)
   364  			fixes := []cache.SuggestedFix{suggestRunOrResetGovulncheck}
   365  			vulnDiagnostics = append(vulnDiagnostics, &cache.Diagnostic{
   366  				URI:            fh.URI(),
   367  				Range:          rng,
   368  				Severity:       protocol.SeverityWarning,
   369  				Source:         diagSource,
   370  				Message:        getVulnMessage("go", warning, true, diagSource == cache.Govulncheck),
   371  				SuggestedFixes: fixes,
   372  			})
   373  
   374  			// remove affecting osvs from the non-affecting osv list if any.
   375  			for k := range infoSet {
   376  				if warningSet[k] {
   377  					delete(infoSet, k)
   378  				}
   379  			}
   380  		}
   381  		if len(infoSet) > 0 {
   382  			info := sortedKeys(infoSet)
   383  			fixes := []cache.SuggestedFix{suggestRunOrResetGovulncheck}
   384  			vulnDiagnostics = append(vulnDiagnostics, &cache.Diagnostic{
   385  				URI:            fh.URI(),
   386  				Range:          rng,
   387  				Severity:       protocol.SeverityInformation,
   388  				Source:         diagSource,
   389  				Message:        getVulnMessage("go", info, false, diagSource == cache.Govulncheck),
   390  				SuggestedFixes: fixes,
   391  			})
   392  		}
   393  	}
   394  
   395  	return vulnDiagnostics, nil
   396  }
   397  
   398  type vulnFindingType int
   399  
   400  const (
   401  	vulnUnknown vulnFindingType = iota
   402  	vulnCalled
   403  	vulnImported
   404  	vulnRequired
   405  )
   406  
   407  // foundVuln returns the frame info describing discovered vulnerable symbol/package/module
   408  // and how this vulnerability affects the analyzed package or module.
   409  func foundVuln(finding *govulncheck.Finding) (*govulncheck.Frame, vulnFindingType) {
   410  	// finding.Trace is sorted from the imported vulnerable symbol to
   411  	// the entry point in the callstack.
   412  	// If Function is set, then Package must be set. Module will always be set.
   413  	// If Function is set it was found in the call graph, otherwise if Package is set
   414  	// it was found in the import graph, otherwise it was found in the require graph.
   415  	// See the documentation of govulncheck.Finding.
   416  	if len(finding.Trace) == 0 { // this shouldn't happen, but just in case...
   417  		return nil, vulnUnknown
   418  	}
   419  	vuln := finding.Trace[0]
   420  	if vuln.Package == "" {
   421  		return vuln, vulnRequired
   422  	}
   423  	if vuln.Function == "" {
   424  		return vuln, vulnImported
   425  	}
   426  	return vuln, vulnCalled
   427  }
   428  
   429  func sortedKeys(m map[string]bool) []string {
   430  	ret := make([]string, 0, len(m))
   431  	for k := range m {
   432  		ret = append(ret, k)
   433  	}
   434  	sort.Strings(ret)
   435  	return ret
   436  }
   437  
   438  // suggestGovulncheckAction returns a code action that suggests either run govulncheck
   439  // for more accurate investigation (if the present vulncheck diagnostics are based on
   440  // analysis less accurate than govulncheck) or reset the existing govulncheck result
   441  // (if the present vulncheck diagnostics are already based on govulncheck run).
   442  func suggestGovulncheckAction(fromGovulncheck bool, uri protocol.DocumentURI) (cache.SuggestedFix, error) {
   443  	if fromGovulncheck {
   444  		resetVulncheck, err := command.NewResetGoModDiagnosticsCommand("Reset govulncheck result", command.ResetGoModDiagnosticsArgs{
   445  			URIArg:           command.URIArg{URI: uri},
   446  			DiagnosticSource: string(cache.Govulncheck),
   447  		})
   448  		if err != nil {
   449  			return cache.SuggestedFix{}, err
   450  		}
   451  		return cache.SuggestedFixFromCommand(resetVulncheck, protocol.QuickFix), nil
   452  	}
   453  	vulncheck, err := command.NewRunGovulncheckCommand("Run govulncheck to verify", command.VulncheckArgs{
   454  		URI:     uri,
   455  		Pattern: "./...",
   456  	})
   457  	if err != nil {
   458  		return cache.SuggestedFix{}, err
   459  	}
   460  	return cache.SuggestedFixFromCommand(vulncheck, protocol.QuickFix), nil
   461  }
   462  
   463  func getVulnMessage(mod string, vulns []string, used, fromGovulncheck bool) string {
   464  	var b strings.Builder
   465  	if used {
   466  		switch len(vulns) {
   467  		case 1:
   468  			fmt.Fprintf(&b, "%v has a vulnerability used in the code: %v.", mod, vulns[0])
   469  		default:
   470  			fmt.Fprintf(&b, "%v has vulnerabilities used in the code: %v.", mod, strings.Join(vulns, ", "))
   471  		}
   472  	} else {
   473  		if fromGovulncheck {
   474  			switch len(vulns) {
   475  			case 1:
   476  				fmt.Fprintf(&b, "%v has a vulnerability %v that is not used in the code.", mod, vulns[0])
   477  			default:
   478  				fmt.Fprintf(&b, "%v has known vulnerabilities %v that are not used in the code.", mod, strings.Join(vulns, ", "))
   479  			}
   480  		} else {
   481  			switch len(vulns) {
   482  			case 1:
   483  				fmt.Fprintf(&b, "%v has a vulnerability %v.", mod, vulns[0])
   484  			default:
   485  				fmt.Fprintf(&b, "%v has known vulnerabilities %v.", mod, strings.Join(vulns, ", "))
   486  			}
   487  		}
   488  	}
   489  	return b.String()
   490  }
   491  
   492  // href returns the url for the vulnerability information.
   493  // Eventually we should retrieve the url embedded in the osv.Entry.
   494  // While vuln.go.dev is under development, this always returns
   495  // the page in pkg.go.dev.
   496  func href(vulnID string) string {
   497  	return fmt.Sprintf("https://pkg.go.dev/vuln/%s", vulnID)
   498  }
   499  
   500  func getUpgradeCodeAction(fh file.Handle, req *modfile.Require, version string) (protocol.Command, error) {
   501  	cmd, err := command.NewUpgradeDependencyCommand(upgradeTitle(version), command.DependencyArgs{
   502  		URI:        fh.URI(),
   503  		AddRequire: false,
   504  		GoCmdArgs:  []string{req.Mod.Path + "@" + version},
   505  	})
   506  	if err != nil {
   507  		return protocol.Command{}, err
   508  	}
   509  	return cmd, nil
   510  }
   511  
   512  func upgradeTitle(fixedVersion string) string {
   513  	title := fmt.Sprintf("%s%v", upgradeCodeActionPrefix, fixedVersion)
   514  	return title
   515  }
   516  
   517  // SelectUpgradeCodeActions takes a list of code actions for a required module
   518  // and returns a more selective list of upgrade code actions,
   519  // where the code actions have been deduped. Code actions unrelated to upgrade
   520  // are deduplicated by the name.
   521  func SelectUpgradeCodeActions(actions []protocol.CodeAction) []protocol.CodeAction {
   522  	if len(actions) <= 1 {
   523  		return actions // return early if no sorting necessary
   524  	}
   525  	var versionedUpgrade, latestUpgrade, resetAction protocol.CodeAction
   526  	var chosenVersionedUpgrade string
   527  	var selected []protocol.CodeAction
   528  
   529  	seenTitles := make(map[string]bool)
   530  
   531  	for _, action := range actions {
   532  		if strings.HasPrefix(action.Title, upgradeCodeActionPrefix) {
   533  			if v := getUpgradeVersion(action); v == "latest" && latestUpgrade.Title == "" {
   534  				latestUpgrade = action
   535  			} else if versionedUpgrade.Title == "" || semver.Compare(v, chosenVersionedUpgrade) > 0 {
   536  				chosenVersionedUpgrade = v
   537  				versionedUpgrade = action
   538  			}
   539  		} else if strings.HasPrefix(action.Title, "Reset govulncheck") {
   540  			resetAction = action
   541  		} else if !seenTitles[action.Command.Title] {
   542  			seenTitles[action.Command.Title] = true
   543  			selected = append(selected, action)
   544  		}
   545  	}
   546  	if versionedUpgrade.Title != "" {
   547  		selected = append(selected, versionedUpgrade)
   548  	}
   549  	if latestUpgrade.Title != "" {
   550  		selected = append(selected, latestUpgrade)
   551  	}
   552  	if resetAction.Title != "" {
   553  		selected = append(selected, resetAction)
   554  	}
   555  	return selected
   556  }
   557  
   558  func getUpgradeVersion(p protocol.CodeAction) string {
   559  	return strings.TrimPrefix(p.Title, upgradeCodeActionPrefix)
   560  }