internal/analysisinternal/analyzerutil: FileUsesGoVersion

Factor and share the three extant copies of this function.
Also, rename modernize.filesUsing to ...GoVersion for consistency.

Change-Id: I393caa3220172afb6c5cfca0b3c80f2ad239f2b7
Reviewed-on: https://go-review.googlesource.com/c/tools/+/719340
Auto-Submit: Alan Donovan <adonovan@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Robert Findley <rfindley@google.com>
diff --git a/go/analysis/passes/loopclosure/loopclosure.go b/go/analysis/passes/loopclosure/loopclosure.go
index f95f526..783fd35 100644
--- a/go/analysis/passes/loopclosure/loopclosure.go
+++ b/go/analysis/passes/loopclosure/loopclosure.go
@@ -14,8 +14,6 @@
 	"golang.org/x/tools/go/ast/inspector"
 	"golang.org/x/tools/go/types/typeutil"
 	"golang.org/x/tools/internal/analysisinternal/analyzerutil"
-	"golang.org/x/tools/internal/packagepath"
-	"golang.org/x/tools/internal/stdlib"
 	"golang.org/x/tools/internal/typesinternal"
 	"golang.org/x/tools/internal/versions"
 )
@@ -57,7 +55,7 @@
 		switch n := n.(type) {
 		case *ast.File:
 			// Only traverse the file if its goversion is strictly before go1.22.
-			return !fileUsesVersion(pass, n, versions.Go1_22)
+			return !analyzerutil.FileUsesGoVersion(pass, n, versions.Go1_22)
 
 		case *ast.RangeStmt:
 			body = n.Body
@@ -373,30 +371,3 @@
 	_, named := typesinternal.ReceiverNamed(recv)
 	return typesinternal.IsTypeNamed(named, pkgPath, typeName)
 }
-
-// fileUsesVersion reports whether the specified file may use
-// features of the specified version of Go (e.g. "go1.24").
-//
-// Tip: we recommend using this check "late", just before calling
-// pass.Report, rather than "early" (when entering each ast.File, or
-// each candidate node of interest, during the traversal), because the
-// operation is not free, yet is not a highly selective filter: the
-// fraction of files that pass most version checks is high and
-// increases over time.
-//
-// TODO(adonovan): move to analyzer library.
-func fileUsesVersion(pass *analysis.Pass, file *ast.File, version string) bool {
-	// Standard packages that are part of toolchain bootstrapping
-	// are not considered to use a version of Go later than the
-	// current bootstrap toolchain version.
-	pkgpath := pass.Pkg.Path()
-	if packagepath.IsStdPackage(pkgpath) &&
-		stdlib.IsBootstrapPackage(pkgpath) &&
-		versions.Before(version, stdlib.BootstrapVersion.String()) {
-		return false // package must bootstrap
-	}
-	if versions.Before(pass.TypesInfo.FileVersions[file], version) {
-		return false // file version is too old
-	}
-	return true // ok
-}
diff --git a/go/analysis/passes/modernize/any.go b/go/analysis/passes/modernize/any.go
index b1a1940..055b200 100644
--- a/go/analysis/passes/modernize/any.go
+++ b/go/analysis/passes/modernize/any.go
@@ -29,7 +29,7 @@
 func runAny(pass *analysis.Pass) (any, error) {
 	skipGenerated(pass)
 
-	for curFile := range filesUsing(pass, versions.Go1_18) {
+	for curFile := range filesUsingGoVersion(pass, versions.Go1_18) {
 		for curIface := range curFile.Preorder((*ast.InterfaceType)(nil)) {
 			iface := curIface.Node().(*ast.InterfaceType)
 
diff --git a/go/analysis/passes/modernize/bloop.go b/go/analysis/passes/modernize/bloop.go
index c1d4c24..4a66da1 100644
--- a/go/analysis/passes/modernize/bloop.go
+++ b/go/analysis/passes/modernize/bloop.go
@@ -102,7 +102,7 @@
 		(*ast.ForStmt)(nil),
 		(*ast.RangeStmt)(nil),
 	}
-	for curFile := range filesUsing(pass, versions.Go1_24) {
+	for curFile := range filesUsingGoVersion(pass, versions.Go1_24) {
 		for curLoop := range curFile.Preorder(loops...) {
 			switch n := curLoop.Node().(type) {
 			case *ast.ForStmt:
diff --git a/go/analysis/passes/modernize/errorsastype.go b/go/analysis/passes/modernize/errorsastype.go
index 7adfd4f..d3c0776 100644
--- a/go/analysis/passes/modernize/errorsastype.go
+++ b/go/analysis/passes/modernize/errorsastype.go
@@ -98,7 +98,7 @@
 		}
 
 		file := astutil.EnclosingFile(curDeclStmt)
-		if !fileUsesVersion(pass, file, versions.Go1_26) {
+		if !analyzerutil.FileUsesGoVersion(pass, file, versions.Go1_26) {
 			continue // errors.AsType is too new
 		}
 
diff --git a/go/analysis/passes/modernize/fmtappendf.go b/go/analysis/passes/modernize/fmtappendf.go
index ee2620f..3b6aa92 100644
--- a/go/analysis/passes/modernize/fmtappendf.go
+++ b/go/analysis/passes/modernize/fmtappendf.go
@@ -51,7 +51,7 @@
 				conv := curCall.Parent().Node().(*ast.CallExpr)
 				tv := pass.TypesInfo.Types[conv.Fun]
 				if tv.IsType() && types.Identical(tv.Type, byteSliceType) &&
-					fileUsesVersion(pass, astutil.EnclosingFile(curCall), versions.Go1_19) {
+					analyzerutil.FileUsesGoVersion(pass, astutil.EnclosingFile(curCall), versions.Go1_19) {
 					// Have: []byte(fmt.SprintX(...))
 
 					// Find "Sprint" identifier.
diff --git a/go/analysis/passes/modernize/forvar.go b/go/analysis/passes/modernize/forvar.go
index 57eb014..2b0d51c 100644
--- a/go/analysis/passes/modernize/forvar.go
+++ b/go/analysis/passes/modernize/forvar.go
@@ -39,13 +39,13 @@
 // where the two idents are the same,
 // and the ident is defined (:=) as a variable in the for statement.
 // (Note that this 'fix' does not work for three clause loops
-// because the Go specification says "The variable used by each subsequent iteration
+// because the Go specfilesUsingGoVersionsays "The variable used by each subsequent iteration
 // is declared implicitly before executing the post statement and initialized to the
 // value of the previous iteration's variable at that moment.")
 func forvar(pass *analysis.Pass) (any, error) {
 	skipGenerated(pass)
 
-	for curFile := range filesUsing(pass, versions.Go1_22) {
+	for curFile := range filesUsingGoVersion(pass, versions.Go1_22) {
 		for curLoop := range curFile.Preorder((*ast.RangeStmt)(nil)) {
 			loop := curLoop.Node().(*ast.RangeStmt)
 			if loop.Tok != token.DEFINE {
diff --git a/go/analysis/passes/modernize/maps.go b/go/analysis/passes/modernize/maps.go
index 9bccedc..b98deb1 100644
--- a/go/analysis/passes/modernize/maps.go
+++ b/go/analysis/passes/modernize/maps.go
@@ -224,7 +224,7 @@
 	}
 
 	// Find all range loops around m[k] = v.
-	for curFile := range filesUsing(pass, versions.Go1_23) {
+	for curFile := range filesUsingGoVersion(pass, versions.Go1_23) {
 		file := curFile.Node().(*ast.File)
 
 		for curRange := range curFile.Preorder((*ast.RangeStmt)(nil)) {
diff --git a/go/analysis/passes/modernize/minmax.go b/go/analysis/passes/modernize/minmax.go
index 0b5c92d..457f5ab 100644
--- a/go/analysis/passes/modernize/minmax.go
+++ b/go/analysis/passes/modernize/minmax.go
@@ -202,7 +202,7 @@
 
 	// Find all "if a < b { lhs = rhs }" statements.
 	info := pass.TypesInfo
-	for curFile := range filesUsing(pass, versions.Go1_21) {
+	for curFile := range filesUsingGoVersion(pass, versions.Go1_21) {
 		astFile := curFile.Node().(*ast.File)
 		for curIfStmt := range curFile.Preorder((*ast.IfStmt)(nil)) {
 			ifStmt := curIfStmt.Node().(*ast.IfStmt)
diff --git a/go/analysis/passes/modernize/modernize.go b/go/analysis/passes/modernize/modernize.go
index 27c07ac..f69a1ca 100644
--- a/go/analysis/passes/modernize/modernize.go
+++ b/go/analysis/passes/modernize/modernize.go
@@ -19,13 +19,13 @@
 	"golang.org/x/tools/go/analysis/passes/inspect"
 	"golang.org/x/tools/go/ast/edge"
 	"golang.org/x/tools/go/ast/inspector"
+	"golang.org/x/tools/internal/analysisinternal/analyzerutil"
 	"golang.org/x/tools/internal/analysisinternal/generated"
 	"golang.org/x/tools/internal/astutil"
 	"golang.org/x/tools/internal/moreiters"
 	"golang.org/x/tools/internal/packagepath"
 	"golang.org/x/tools/internal/stdlib"
 	"golang.org/x/tools/internal/typesinternal"
-	"golang.org/x/tools/internal/versions"
 )
 
 //go:embed doc.go
@@ -93,54 +93,28 @@
 	return info.Types[e].Value == constant.MakeInt64(n)
 }
 
-// filesUsing returns a cursor for each *ast.File in the inspector
+// filesUsingGoVersion returns a cursor for each *ast.File in the inspector
 // that uses at least the specified version of Go (e.g. "go1.24").
 //
 // The pass's analyzer must require [inspect.Analyzer].
 //
 // TODO(adonovan): opt: eliminate this function, instead following the
-// approach of [fmtappendf], which uses typeindex and [fileUsesGoVersion].
-// See "Tip" at [fileUsesGoVersion] for motivation.
-func filesUsing(pass *analysis.Pass, version string) iter.Seq[inspector.Cursor] {
+// approach of [fmtappendf], which uses typeindex and
+// [analyzerutil.FileUsesGoVersion]; see "Tip" documented at the
+// latter function for motivation.
+func filesUsingGoVersion(pass *analysis.Pass, version string) iter.Seq[inspector.Cursor] {
 	inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
 
 	return func(yield func(inspector.Cursor) bool) {
 		for curFile := range inspect.Root().Children() {
 			file := curFile.Node().(*ast.File)
-			if fileUsesVersion(pass, file, version) && !yield(curFile) {
+			if analyzerutil.FileUsesGoVersion(pass, file, version) && !yield(curFile) {
 				break
 			}
 		}
 	}
 }
 
-// fileUsesVersion reports whether the specified file may use features of the
-// specified version of Go (e.g. "go1.24").
-//
-// Tip: we recommend using this check "late", just before calling
-// pass.Report, rather than "early" (when entering each ast.File, or
-// each candidate node of interest, during the traversal), because the
-// operation is not free, yet is not a highly selective filter: the
-// fraction of files that pass most version checks is high and
-// increases over time.
-//
-// TODO(adonovan): move to analyzer library.
-func fileUsesVersion(pass *analysis.Pass, file *ast.File, version string) bool {
-	// Standard packages that are part of toolchain bootstrapping
-	// are not considered to use a version of Go later than the
-	// current bootstrap toolchain version.
-	pkgpath := pass.Pkg.Path()
-	if packagepath.IsStdPackage(pkgpath) &&
-		stdlib.IsBootstrapPackage(pkgpath) &&
-		versions.Before(version, stdlib.BootstrapVersion.String()) {
-		return false // package must bootstrap
-	}
-	if versions.Before(pass.TypesInfo.FileVersions[file], version) {
-		return false // file version is too old
-	}
-	return true // ok
-}
-
 // within reports whether the current pass is analyzing one of the
 // specified standard packages or their dependencies.
 func within(pass *analysis.Pass, pkgs ...string) bool {
diff --git a/go/analysis/passes/modernize/newexpr.go b/go/analysis/passes/modernize/newexpr.go
index f984dab..ed8e3be 100644
--- a/go/analysis/passes/modernize/newexpr.go
+++ b/go/analysis/passes/modernize/newexpr.go
@@ -61,7 +61,7 @@
 
 								// Check file version.
 								file := astutil.EnclosingFile(curFuncDecl)
-								if !fileUsesVersion(pass, file, versions.Go1_26) {
+								if !analyzerutil.FileUsesGoVersion(pass, file, versions.Go1_26) {
 									continue // new(expr) not available in this file
 								}
 
@@ -134,7 +134,7 @@
 
 			// Check file version.
 			file := astutil.EnclosingFile(curCall)
-			if !fileUsesVersion(pass, file, versions.Go1_26) {
+			if !analyzerutil.FileUsesGoVersion(pass, file, versions.Go1_26) {
 				continue // new(expr) not available in this file
 			}
 
diff --git a/go/analysis/passes/modernize/omitzero.go b/go/analysis/passes/modernize/omitzero.go
index 0c14d4e..f5a64ca 100644
--- a/go/analysis/passes/modernize/omitzero.go
+++ b/go/analysis/passes/modernize/omitzero.go
@@ -95,13 +95,13 @@
 }
 
 // The omitzero pass searches for instances of "omitempty" in a json field tag on a
-// struct. Since "omitempty" does not have any effect when applied to a struct field,
+// struct. Since "omitfilesUsingGoVersions not have any effect when applied to a struct field,
 // it suggests either deleting "omitempty" or replacing it with "omitzero", which
 // correctly excludes structs from a json encoding.
 func omitzero(pass *analysis.Pass) (any, error) {
 	skipGenerated(pass)
 
-	for curFile := range filesUsing(pass, versions.Go1_24) {
+	for curFile := range filesUsingGoVersion(pass, versions.Go1_24) {
 		for curStruct := range curFile.Preorder((*ast.StructType)(nil)) {
 			for _, curField := range curStruct.Node().(*ast.StructType).Fields.List {
 				checkOmitEmptyField(pass, pass.TypesInfo, curField)
diff --git a/go/analysis/passes/modernize/plusbuild.go b/go/analysis/passes/modernize/plusbuild.go
index 9b3cd2e..d3871d3 100644
--- a/go/analysis/passes/modernize/plusbuild.go
+++ b/go/analysis/passes/modernize/plusbuild.go
@@ -29,7 +29,7 @@
 
 func plusbuild(pass *analysis.Pass) (any, error) {
 	check := func(f *ast.File) {
-		if !fileUsesVersion(pass, f, versions.Go1_18) {
+		if !analyzerutil.FileUsesGoVersion(pass, f, versions.Go1_18) {
 			return
 		}
 
diff --git a/go/analysis/passes/modernize/rangeint.go b/go/analysis/passes/modernize/rangeint.go
index 843a8a2..4841054 100644
--- a/go/analysis/passes/modernize/rangeint.go
+++ b/go/analysis/passes/modernize/rangeint.go
@@ -74,7 +74,7 @@
 		typeindex = pass.ResultOf[typeindexanalyzer.Analyzer].(*typeindex.Index)
 	)
 
-	for curFile := range filesUsing(pass, versions.Go1_22) {
+	for curFile := range filesUsingGoVersion(pass, versions.Go1_22) {
 	nextLoop:
 		for curLoop := range curFile.Preorder((*ast.ForStmt)(nil)) {
 			loop := curLoop.Node().(*ast.ForStmt)
diff --git a/go/analysis/passes/modernize/reflect.go b/go/analysis/passes/modernize/reflect.go
index e7c68b4..5292398 100644
--- a/go/analysis/passes/modernize/reflect.go
+++ b/go/analysis/passes/modernize/reflect.go
@@ -89,7 +89,7 @@
 		}
 
 		file := astutil.EnclosingFile(curCall)
-		if !fileUsesVersion(pass, file, versions.Go1_22) {
+		if !analyzerutil.FileUsesGoVersion(pass, file, versions.Go1_22) {
 			continue // TypeFor requires go1.22
 		}
 		tokFile := pass.Fset.File(file.Pos())
diff --git a/go/analysis/passes/modernize/slices.go b/go/analysis/passes/modernize/slices.go
index f3c2ed2..7d08a33 100644
--- a/go/analysis/passes/modernize/slices.go
+++ b/go/analysis/passes/modernize/slices.go
@@ -205,7 +205,7 @@
 	skip := make(map[*ast.CallExpr]bool)
 
 	// Visit calls of form append(x, y...).
-	for curFile := range filesUsing(pass, versions.Go1_21) {
+	for curFile := range filesUsingGoVersion(pass, versions.Go1_21) {
 		file := curFile.Node().(*ast.File)
 
 		for curCall := range curFile.Preorder((*ast.CallExpr)(nil)) {
diff --git a/go/analysis/passes/modernize/slicescontains.go b/go/analysis/passes/modernize/slicescontains.go
index 097a9ff..ed2e3e3 100644
--- a/go/analysis/passes/modernize/slicescontains.go
+++ b/go/analysis/passes/modernize/slicescontains.go
@@ -386,7 +386,7 @@
 		}
 	}
 
-	for curFile := range filesUsing(pass, versions.Go1_21) {
+	for curFile := range filesUsingGoVersion(pass, versions.Go1_21) {
 		file := curFile.Node().(*ast.File)
 
 		for curRange := range curFile.Preorder((*ast.RangeStmt)(nil)) {
diff --git a/go/analysis/passes/modernize/slicesdelete.go b/go/analysis/passes/modernize/slicesdelete.go
index 3860efe..98282e4 100644
--- a/go/analysis/passes/modernize/slicesdelete.go
+++ b/go/analysis/passes/modernize/slicesdelete.go
@@ -129,7 +129,7 @@
 			}},
 		})
 	}
-	for curFile := range filesUsing(pass, versions.Go1_21) {
+	for curFile := range filesUsingGoVersion(pass, versions.Go1_21) {
 		file := curFile.Node().(*ast.File)
 		for curCall := range curFile.Preorder((*ast.CallExpr)(nil)) {
 			call := curCall.Node().(*ast.CallExpr)
diff --git a/go/analysis/passes/modernize/sortslice.go b/go/analysis/passes/modernize/sortslice.go
index 2d66d02..20af422 100644
--- a/go/analysis/passes/modernize/sortslice.go
+++ b/go/analysis/passes/modernize/sortslice.go
@@ -88,7 +88,7 @@
 					}
 					file := astutil.EnclosingFile(curCall)
 					if isIndex(compare.X, i) && isIndex(compare.Y, j) &&
-						fileUsesVersion(pass, file, versions.Go1_21) {
+						analyzerutil.FileUsesGoVersion(pass, file, versions.Go1_21) {
 						// Have: sort.Slice(s, func(i, j int) bool { return s[i] < s[j] })
 
 						prefix, importEdits := refactor.AddImport(
diff --git a/go/analysis/passes/modernize/stditerators.go b/go/analysis/passes/modernize/stditerators.go
index e35bab8..cf88443 100644
--- a/go/analysis/passes/modernize/stditerators.go
+++ b/go/analysis/passes/modernize/stditerators.go
@@ -313,7 +313,7 @@
 			// may be somewhat expensive.)
 			if v, ok := methodGoVersion(row.pkgpath, row.typename, row.itermethod); !ok {
 				panic("no version found")
-			} else if !fileUsesVersion(pass, astutil.EnclosingFile(curLenCall), v.String()) {
+			} else if !analyzerutil.FileUsesGoVersion(pass, astutil.EnclosingFile(curLenCall), v.String()) {
 				continue nextCall
 			}
 
diff --git a/go/analysis/passes/modernize/stringscut.go b/go/analysis/passes/modernize/stringscut.go
index fdf47bb..dd0dc81 100644
--- a/go/analysis/passes/modernize/stringscut.go
+++ b/go/analysis/passes/modernize/stringscut.go
@@ -132,7 +132,7 @@
 	nextcall:
 		for curCall := range index.Calls(obj) {
 			// Check file version.
-			if !fileUsesVersion(pass, astutil.EnclosingFile(curCall), versions.Go1_18) {
+			if !analyzerutil.FileUsesGoVersion(pass, astutil.EnclosingFile(curCall), versions.Go1_18) {
 				continue // strings.Index not available in this file
 			}
 			indexCall := curCall.Node().(*ast.CallExpr) // the call to strings.Index, etc.
diff --git a/go/analysis/passes/modernize/stringscutprefix.go b/go/analysis/passes/modernize/stringscutprefix.go
index b76a36b..2315e61 100644
--- a/go/analysis/passes/modernize/stringscutprefix.go
+++ b/go/analysis/passes/modernize/stringscutprefix.go
@@ -71,7 +71,7 @@
 		return nil, nil
 	}
 
-	for curFile := range filesUsing(pass, versions.Go1_20) {
+	for curFile := range filesUsingGoVersion(pass, versions.Go1_20) {
 		for curIfStmt := range curFile.Preorder((*ast.IfStmt)(nil)) {
 			ifStmt := curIfStmt.Node().(*ast.IfStmt)
 
diff --git a/go/analysis/passes/modernize/stringsseq.go b/go/analysis/passes/modernize/stringsseq.go
index 96cffa6..5dbb709 100644
--- a/go/analysis/passes/modernize/stringsseq.go
+++ b/go/analysis/passes/modernize/stringsseq.go
@@ -63,7 +63,7 @@
 		return nil, nil
 	}
 
-	for curFile := range filesUsing(pass, versions.Go1_24) {
+	for curFile := range filesUsingGoVersion(pass, versions.Go1_24) {
 		for curRange := range curFile.Preorder((*ast.RangeStmt)(nil)) {
 			rng := curRange.Node().(*ast.RangeStmt)
 
diff --git a/go/analysis/passes/modernize/testingcontext.go b/go/analysis/passes/modernize/testingcontext.go
index 19cc825..1cf4a9d 100644
--- a/go/analysis/passes/modernize/testingcontext.go
+++ b/go/analysis/passes/modernize/testingcontext.go
@@ -138,7 +138,7 @@
 				testObj = isTestFn(info, n)
 			}
 		}
-		if testObj != nil && fileUsesVersion(pass, astutil.EnclosingFile(cur), versions.Go1_24) {
+		if testObj != nil && analyzerutil.FileUsesGoVersion(pass, astutil.EnclosingFile(cur), versions.Go1_24) {
 			// Have a test function. Check that we can resolve the relevant
 			// testing.{T,B,F} at the current position.
 			if _, obj := lhs[0].Parent().LookupParent(testObj.Name(), lhs[0].Pos()); obj == testObj {
diff --git a/go/analysis/passes/modernize/waitgroup.go b/go/analysis/passes/modernize/waitgroup.go
index 2844ad9..741cbc3 100644
--- a/go/analysis/passes/modernize/waitgroup.go
+++ b/go/analysis/passes/modernize/waitgroup.go
@@ -129,7 +129,7 @@
 		}
 
 		file := astutil.EnclosingFile(curAddCall)
-		if !fileUsesVersion(pass, file, versions.Go1_25) {
+		if !analyzerutil.FileUsesGoVersion(pass, file, versions.Go1_25) {
 			continue
 		}
 		tokFile := pass.Fset.File(file.Pos())
diff --git a/gopls/internal/analysis/maprange/maprange.go b/gopls/internal/analysis/maprange/maprange.go
index a8a6495..699d2ec 100644
--- a/gopls/internal/analysis/maprange/maprange.go
+++ b/gopls/internal/analysis/maprange/maprange.go
@@ -16,8 +16,6 @@
 	"golang.org/x/tools/internal/analysisinternal/analyzerutil"
 	typeindexanalyzer "golang.org/x/tools/internal/analysisinternal/typeindex"
 	"golang.org/x/tools/internal/astutil"
-	"golang.org/x/tools/internal/packagepath"
-	"golang.org/x/tools/internal/stdlib"
 	"golang.org/x/tools/internal/typesinternal/typeindex"
 	"golang.org/x/tools/internal/versions"
 )
@@ -93,7 +91,7 @@
 		// Replace with: for i := range len(m)
 		// (This requires Go 1.22.)
 		replace = "len"
-		if !fileUsesVersion(pass, astutil.EnclosingFile(curCall), versions.Go1_22) {
+		if !analyzerutil.FileUsesGoVersion(pass, astutil.EnclosingFile(curCall), versions.Go1_22) {
 			pass.Report(analysis.Diagnostic{
 				Pos:     call.Pos(),
 				End:     call.End(),
@@ -150,30 +148,3 @@
 	ident, ok := expr.(*ast.Ident)
 	return expr != nil && (!ok || ident.Name != "_")
 }
-
-// fileUsesVersion reports whether the specified file may use
-// features of the specified version of Go (e.g. "go1.24").
-//
-// Tip: we recommend using this check "late", just before calling
-// pass.Report, rather than "early" (when entering each ast.File, or
-// each candidate node of interest, during the traversal), because the
-// operation is not free, yet is not a highly selective filter: the
-// fraction of files that pass most version checks is high and
-// increases over time.
-//
-// TODO(adonovan): move to analyzer library.
-func fileUsesVersion(pass *analysis.Pass, file *ast.File, version string) bool {
-	// Standard packages that are part of toolchain bootstrapping
-	// are not considered to use a version of Go later than the
-	// current bootstrap toolchain version.
-	pkgpath := pass.Pkg.Path()
-	if packagepath.IsStdPackage(pkgpath) &&
-		stdlib.IsBootstrapPackage(pkgpath) &&
-		versions.Before(version, stdlib.BootstrapVersion.String()) {
-		return false // package must bootstrap
-	}
-	if versions.Before(pass.TypesInfo.FileVersions[file], version) {
-		return false // file version is too old
-	}
-	return true // ok
-}
diff --git a/internal/analysisinternal/analyzerutil/version.go b/internal/analysisinternal/analyzerutil/version.go
new file mode 100644
index 0000000..7336012
--- /dev/null
+++ b/internal/analysisinternal/analyzerutil/version.go
@@ -0,0 +1,39 @@
+// 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 analyzerutil
+
+import (
+	"go/ast"
+
+	"golang.org/x/tools/go/analysis"
+	"golang.org/x/tools/internal/packagepath"
+	"golang.org/x/tools/internal/stdlib"
+	"golang.org/x/tools/internal/versions"
+)
+
+// FileUsesGoVersion reports whether the specified file may use features of the
+// specified version of Go (e.g. "go1.24").
+//
+// Tip: we recommend using this check "late", just before calling
+// pass.Report, rather than "early" (when entering each ast.File, or
+// each candidate node of interest, during the traversal), because the
+// operation is not free, yet is not a highly selective filter: the
+// fraction of files that pass most version checks is high and
+// increases over time.
+func FileUsesGoVersion(pass *analysis.Pass, file *ast.File, version string) bool {
+	// Standard packages that are part of toolchain bootstrapping
+	// are not considered to use a version of Go later than the
+	// current bootstrap toolchain version.
+	pkgpath := pass.Pkg.Path()
+	if packagepath.IsStdPackage(pkgpath) &&
+		stdlib.IsBootstrapPackage(pkgpath) &&
+		versions.Before(version, stdlib.BootstrapVersion.String()) {
+		return false // package must bootstrap
+	}
+	if versions.Before(pass.TypesInfo.FileVersions[file], version) {
+		return false // file version is too old
+	}
+	return true // ok
+}